Перейти к основному содержимому

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

· 7 мин. чтения

1. Обзор

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

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

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

2.1. Ресурс Java Microbenchmark

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

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

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

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

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

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

<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.33</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.33</version>
</dependency>

Пожалуйста, обратитесь к 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, означает, что первые два запуска игнорируются при вычислении результата.
  • @Measurement : Передача параметров измерения. Значение итераций , равное 10, указывает, что JMH будет выполнять каждый метод 10 раз .
  • @BenchmarkMode : именно так JHM должен собирать результаты выполнения. Значение AverageTime требует, чтобы JMH подсчитывал среднее время, необходимое методу для завершения своих операций.
  • @OutputTimeUnit : указывает единицу времени вывода, в данном случае это миллисекунды.

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

2.4. Выполнение тестов

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

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

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

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

<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.foreach.performancetests.MappingFrameworksPerformance</mainClass>
</manifest>
</archive>
</configuration>
</plugin>

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

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

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

3.1. Метод возвращается нормально

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

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

Параметр blackhole ссылается на экземпляр Blackhole . Это класс JMH, который помогает предотвратить удаление мертвого кода, оптимизация, которую может выполнять компилятор «точно в срок».

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

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

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

Небольшое изменение времени выполнения метода doNotThrowException не имеет значения. Это просто колебания состояния базовой ОС и 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 .