Рубрики
Без рубрики

Влияние исключений на производительность в Java

Исключения Java считаются дорогостоящими и не должны использоваться для управления потоком. Этот учебник докажет, что это восприятие является правильным.

Автор оригинала: Nguyen Nam Thai.

1. Обзор

В Java исключения обычно считаются дорогостоящими и не должны использоваться для управления потоком. Этот учебник докажет, что это восприятие является правильным, и точно укажет, что вызывает проблему с производительностью.

2. Настройка Среды

Прежде чем писать код для оценки затрат на производительность, нам необходимо создать среду бенчмаркинга.

2.1. Жгут проводов Java Microbenchmark

Измерение накладных расходов на исключение не так просто, как выполнение метода в простом цикле и учет общего времени.

Причина в том, что компилятор точно в срок может помешать и оптимизировать код. Такая оптимизация может привести к тому, что код будет работать лучше, чем на самом деле в производственной среде. Другими словами, это может привести к ложноположительным результатам.

Чтобы создать управляемую среду, которая может смягчить оптимизацию JVM, мы будем использовать Java Microbenchmark Harness или сокращенно JMH.

В следующих подразделах будет рассмотрена настройка среды бенчмаркинга, не вдаваясь в подробности JMH. Для получения дополнительной информации об этом инструменте, пожалуйста, ознакомьтесь с нашим учебником по микробенчмаркингу с Java.

2.2. Получение артефактов JMH

Чтобы получить артефакты JMH, добавьте эти две зависимости в POM:


    org.openjdk.jmh
    jmh-core
    1.28


    org.openjdk.jmh
    jmh-generator-annprocess
    1.28

Пожалуйста, обратитесь к Maven Central для получения последних версий JMH Core и JMH Annotation Processor .

2.3. Эталонный класс

Нам понадобится класс для проведения тестов:

@Fork(1)
@Warmup(iterations = 2)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ExceptionBenchmark {
    private static final int LIMIT = 10_000;
    // benchmarks go here
}

Давайте рассмотрим аннотации JMH, показанные выше:

  • @Fork : Указание количества раз, когда JMH должен порождать новый процесс для запуска тестов. Мы устанавливаем его значение равным 1, чтобы генерировать только один процесс, избегая слишком долгого ожидания, чтобы увидеть результат
  • @Warmup : Перенос параметров прогрева. Элемент iterations , равный 2, означает, что первые два прогона игнорируются при вычислении результата
  • @Измерение : Перенос параметров измерения. Значение итерации 10 указывает на то, что JMH выполнит каждый метод 10 раз
  • @BenchmarkMode : Именно так JHM должен собирать результаты выполнения. Значение Среднее время требует, чтобы OMH подсчитал среднее время, необходимое методу для завершения его операций
  • @OutputTimeUnit : Указывает единицу времени вывода, которая в данном случае является миллисекундой

Кроме того, внутри тела класса есть статическое поле, а именно LIMIT . Это число итераций в теле каждого метода.

2.4. Выполнение контрольных показателей

Для выполнения тестов нам нужен метод main :

public class MappingFrameworksPerformance {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

Мы можем упаковать проект в файл JAR и запустить его в командной строке. Это, конечно, приведет к пустому результату, поскольку мы не добавили никакого метода бенчмаркинга.

Для удобства мы можем добавить maven-jar-плагин в POM. Этот плагин позволяет нам выполнять метод main внутри IDE:

org.apache.maven.plugins
    maven-jar-plugin
    3.2.0
    
        
            
                com.baeldung.performancetests.MappingFrameworksPerformance
            
        
    

Последнюю версию maven-jar-plugin можно найти здесь .

3. Измерение Производительности

Пришло время разработать некоторые методы бенчмаркинга для измерения производительности. Каждый из этих методов должен содержать аннотацию @Benchmark .

3.1. Метод, Возвращающийся Нормально

Давайте начнем с метода, возвращающегося нормально; то есть с метода, который не вызывает исключения:

@Benchmark
public void doNotThrowException(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        blackhole.consume(new Object());
    }
}

Параметр black hole ссылается на экземпляр Black hole . Это класс JMH, который помогает предотвратить устранение мертвого кода, оптимизацию, которую может выполнить компилятор just-in-time.

Бенчмарк в этом случае не создает никаких исключений. На самом деле, мы будем использовать его в качестве ссылки для оценки производительности тех, кто создает исключения.

Выполнение метода main даст нам отчет:

Benchmark                               Mode  Cnt  Score   Error  Units
ExceptionBenchmark.doNotThrowException  avgt   10  0.049 ± 0.006  ms/op

В этом результате нет ничего особенного. Среднее время выполнения бенчмарка составляет 0,049 миллисекунды, что само по себе довольно бессмысленно.

3.2. Создание и выбрасывание исключения

Вот еще один тест, который выдает и улавливает исключения:

@Benchmark
public void throwAndCatchException(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        try {
            throw new Exception();
        } catch (Exception e) {
            blackhole.consume(e);
        }
    }
}

Давайте посмотрим на результат:

Benchmark                                  Mode  Cnt   Score   Error  Units
ExceptionBenchmark.doNotThrowException     avgt   10   0.048 ± 0.003  ms/op
ExceptionBenchmark.throwAndCatchException  avgt   10  17.942 ± 0.846  ms/op

Небольшое изменение во времени выполнения метода doesnotthrowexception не имеет значения. Это просто колебания в состоянии базовой ОС и JVM. Ключевой вывод заключается в том, что выбрасывание исключения заставляет метод работать в сотни раз медленнее.

В следующих нескольких подразделах будет выяснено, что именно приводит к такой разительной разнице.

3.3. Создание Исключения Без Его выбрасывания

Вместо того, чтобы создавать, выбрасывать и перехватывать исключение, мы просто создадим его:

@Benchmark
public void createExceptionWithoutThrowingIt(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        blackhole.consume(new Exception());
    }
}

Теперь давайте выполним три контрольных показателя, которые мы объявили:

Benchmark                                            Mode  Cnt   Score   Error  Units
ExceptionBenchmark.createExceptionWithoutThrowingIt  avgt   10  17.601 ± 3.152  ms/op
ExceptionBenchmark.doNotThrowException               avgt   10   0.054 ± 0.014  ms/op
ExceptionBenchmark.throwAndCatchException            avgt   10  17.174 ± 0.474  ms/op

Результат может оказаться неожиданным: время выполнения первого и третьего методов почти одинаково, в то время как время выполнения второго существенно меньше.

На данный момент ясно, что заявления throw и catch сами по себе довольно дешевы. С другой стороны, создание исключений приводит к высоким накладным расходам.

3.4. Создание исключения Без добавления трассировки стека

Давайте выясним, почему создание исключения намного дороже, чем создание обычного объекта:

@Benchmark
@Fork(value = 1, jvmArgs = "-XX:-StackTraceInThrowable")
public void throwExceptionWithoutAddingStackTrace(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        try {
            throw new Exception();
        } catch (Exception e) {
            blackhole.consume(e);
        }
    }
}

Единственным отличием этого метода от метода, описанного в подразделе 3.2, является элемент jvmArgs . Его значение -XX:-StackTraceInThrowable является опцией JVM, не позволяющей добавить трассировку стека в исключение.

Давайте снова запустим тесты:

Benchmark                                                 Mode  Cnt   Score   Error  Units
ExceptionBenchmark.createExceptionWithoutThrowingIt       avgt   10  17.874 ± 3.199  ms/op
ExceptionBenchmark.doNotThrowException                    avgt   10   0.046 ± 0.003  ms/op
ExceptionBenchmark.throwAndCatchException                 avgt   10  16.268 ± 0.239  ms/op
ExceptionBenchmark.throwExceptionWithoutAddingStackTrace  avgt   10   1.174 ± 0.014  ms/op

Не заполняя исключение трассировкой стека, мы сократили продолжительность выполнения более чем в 100 раз. По-видимому, прохождение по стеку и добавление его кадров к исключению приводит к вялости, которую мы видели.

3.5. Выбрасывание исключения и Разматывание его трассировки стека

Наконец, давайте посмотрим, что произойдет, если мы создадим исключение и размотаем трассировку стека при его обнаружении:

@Benchmark
public void throwExceptionAndUnwindStackTrace(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        try {
            throw new Exception();
        } catch (Exception e) {
            blackhole.consume(e.getStackTrace());
        }
    }
}

Вот результат:

Benchmark                                                 Mode  Cnt    Score   Error  Units
ExceptionBenchmark.createExceptionWithoutThrowingIt       avgt   10   16.605 ± 0.988  ms/op
ExceptionBenchmark.doNotThrowException                    avgt   10    0.047 ± 0.006  ms/op
ExceptionBenchmark.throwAndCatchException                 avgt   10   16.449 ± 0.304  ms/op
ExceptionBenchmark.throwExceptionAndUnwindStackTrace      avgt   10  326.560 ± 4.991  ms/op
ExceptionBenchmark.throwExceptionWithoutAddingStackTrace  avgt   10    1.185 ± 0.015  ms/op

Просто развернув трассировку стека, мы видим колоссальное увеличение продолжительности выполнения примерно в 20 раз. Другими словами, производительность намного хуже, если мы извлекаем трассировку стека из исключения в дополнение к его выбрасыванию.

4. Заключение

В этом уроке мы проанализировали влияние исключений на производительность. В частности, выяснилось, что затраты на производительность в основном связаны с добавлением трассировки стека к исключению. Если впоследствии эта трассировка стека будет размотана, накладные расходы станут намного больше.

Поскольку выбрасывание и обработка исключений обходятся дорого, мы не должны использовать его для обычных потоков программ. Вместо этого, как следует из его названия, исключения следует использовать только в исключительных случаях.

Полный исходный код можно найти на GitHub .