Ефекти от производителността на изключения в Java

1. Общ преглед

В Java изключенията обикновено се считат за скъпи и не трябва да се използват за контрол на потока. Този урок ще докаже, че това възприятие е правилно и ще посочи какво причинява проблема с производителността.

2. Настройване на среда

Преди да напишем код за оценка на производителността, трябва да създадем среда за сравнение.

2.1. Сбруя на Java Microbenchmark

Измерването на режийни извънредни разходи не е толкова лесно, колкото изпълнението на метод в обикновен цикъл и отчитането на общото време.

Причината е, че съвременен компилатор може да попречи и да оптимизира кода. Такава оптимизация може да направи кода по-добър, отколкото в действителност в производствена среда. С други думи, това може да даде фалшиво положителни резултати.

За да създадем контролирана среда, която може да смекчи JVM оптимизацията, ще използваме Java Microbenchmark Harness или JMH за кратко.

Следващите подраздели ще разгледат създаването на среда за сравнителен анализ, без да навлизат в подробностите за JMH. За повече информация относно този инструмент, моля, разгледайте нашия урок за Microbenchmarking с Java.

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

За да получите JMH артефакти, добавете тези две зависимости към POM:

 org.openjdk.jmh jmh-core 1.21   org.openjdk.jmh jmh-generator-annprocess 1.21 

Моля, обърнете се към 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 : Провеждане на параметри за загряване. В повторения елемент е 2 средства на първите две движения се игнорират при изчисляването на резултата
  • @ Измерване : Пренасяне на измервателни параметри. Един повторения стойност 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 файл и да го стартираме в командния ред. Правейки това сега, разбира се, ще доведе до празен изход, тъй като не сме добавили никакъв метод за сравнение.

За удобство можем да добавим приставката maven-jar към POM. Този плъгин ни позволява да изпълним основния метод в 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()); } }

Параметърът 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()); } }

Now, let's execute the three benchmarks we've declared:

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

The result may come as a surprise: the execution time of the first and the third methods are nearly the same, while that of the second is substantially smaller.

At this point, it's clear that the throw and catch statements themselves are fairly cheap. The creation of exceptions, on the other hand, produces high overheads.

3.4. Throwing an Exception Without Adding the Stack Trace

Let's figure out why constructing an exception is much more expensive than doing an ordinary object:

@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); } } }

The only difference between this method and the one in subsection 3.2 is the jvmArgs element. Its value -XX:-StackTraceInThrowable is a JVM option, keeping the stack trace from being added to the exception.

Let's run the benchmarks again:

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

By not populating the exception with the stack trace, we reduced execution duration by more than 100 times. Apparently, walking through the stack and adding its frames to the exception bring about the sluggishness we've seen.

3.5. Throwing an Exception and Unwinding Its Stack Trace

Finally, let's see what happens if we throw an exception and unwind the stack trace when catching it:

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

Here's the outcome:

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.