Leistungseffekte von Ausnahmen in Java

1. Übersicht

In Java werden Ausnahmen im Allgemeinen als teuer angesehen und sollten nicht zur Flusskontrolle verwendet werden. Dieses Tutorial wird beweisen, dass diese Wahrnehmung korrekt ist und genau bestimmen, was das Leistungsproblem verursacht.

2. Umgebung einrichten

Bevor wir Code schreiben, um die Leistungskosten zu bewerten, müssen wir eine Benchmarking-Umgebung einrichten.

2.1. Java Microbenchmark Harness

Das Messen des Ausnahme-Overheads ist nicht so einfach wie das Ausführen einer Methode in einer einfachen Schleife und das Notieren der Gesamtzeit.

Der Grund ist, dass ein Just-in-Time-Compiler den Code stören und optimieren kann. Durch eine solche Optimierung kann der Code eine bessere Leistung erzielen als in einer Produktionsumgebung. Mit anderen Worten, es könnte zu falsch positiven Ergebnissen führen.

Um eine kontrollierte Umgebung zu erstellen, die die JVM-Optimierung verringern kann, verwenden wir Java Microbenchmark Harness, kurz JMH.

In den folgenden Unterabschnitten wird das Einrichten einer Benchmarking-Umgebung beschrieben, ohne auf die Details von JMH einzugehen. Weitere Informationen zu diesem Tool finden Sie in unserem Tutorial zu Microbenchmarking mit Java.

2.2. JMH-Artefakte erhalten

Fügen Sie dem POM diese beiden Abhängigkeiten hinzu, um JMH-Artefakte zu erhalten:

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

Die neuesten Versionen von JMH Core und JMH Annotation Processor finden Sie in Maven Central.

2.3. Benchmark-Klasse

Wir brauchen eine Klasse, um Benchmarks zu halten:

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

Lassen Sie uns die oben gezeigten JMH-Anmerkungen durchgehen:

  • @Fork : Geben Sie an, wie oft JMH einen neuen Prozess erzeugen muss, um Benchmarks auszuführen. Wir setzen den Wert auf 1, um nur einen Prozess zu generieren, und vermeiden es, zu lange zu warten, um das Ergebnis zu sehen
  • @Warmup : Übertragen von Aufwärmparametern. Das Iterationselement 2 bedeutet, dass die ersten beiden Läufe bei der Berechnung des Ergebnisses ignoriert werden
  • @Messung : Tragen von Messparametern. Ein Iterationswert von 10 gibt an, dass JMH jede Methode zehnmal ausführt
  • @BenchmarkMode : So sollte JHM Ausführungsergebnisse erfassen. Für den Wert AverageTime muss JMH die durchschnittliche Zeit zählen, die eine Methode benötigt, um ihre Vorgänge abzuschließen
  • @OutputTimeUnit : Zeigt die Ausgabezeiteinheit an, in diesem Fall die Millisekunde

Zusätzlich gibt es ein statisches Feld innerhalb des Klassenkörpers, nämlich LIMIT . Dies ist die Anzahl der Iterationen in jedem Methodenkörper.

2.4. Benchmarks ausführen

Zur Ausführung Benchmarks benötigen wir eine Hauptmethode:

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

Wir können das Projekt in eine JAR-Datei packen und in der Befehlszeile ausführen. Wenn Sie dies jetzt tun, wird natürlich eine leere Ausgabe erzeugt, da wir keine Benchmarking-Methode hinzugefügt haben.

Der Einfachheit halber können wir das Maven-Jar-Plugin zum POM hinzufügen . Dieses Plugin ermöglicht es uns , die das Ausführen Haupt Methode innerhalb einer IDE:

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

Die neueste Version des Maven-Jar-Plugins finden Sie hier.

3. Leistungsmessung

Es ist Zeit, einige Benchmarking-Methoden zur Messung der Leistung zu haben. Jede dieser Methoden muss die Annotation @Benchmark enthalten .

3.1. Methode, die normal zurückgibt

Beginnen wir mit einer Methode, die normal zurückkehrt. Das heißt, eine Methode, die keine Ausnahme auslöst:

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

Der Blackhole- Parameter verweist auf eine Instanz von Blackhole . Dies ist eine JMH-Klasse, die dazu beiträgt, die Beseitigung von totem Code zu verhindern. Diese Optimierung kann ein Just-in-Time-Compiler durchführen.

Der Benchmark wirft in diesem Fall keine Ausnahme. Tatsächlich verwenden wir es als Referenz, um die Leistung derjenigen zu bewerten, die Ausnahmen auslösen.

Wenn Sie die Hauptmethode ausführen , erhalten Sie einen Bericht:

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

An diesem Ergebnis ist nichts Besonderes. Die durchschnittliche Ausführungszeit des Benchmarks beträgt 0,049 Millisekunden, was an sich ziemlich bedeutungslos ist.

3.2. Eine Ausnahme erstellen und auslösen

Hier ist ein weiterer Benchmark, der Ausnahmen auslöst und abfängt:

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

Werfen wir einen Blick auf die Ausgabe:

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

Die kleine Änderung der Ausführungszeit der Methode doNotThrowException ist nicht wichtig. Es ist nur die Schwankung im Zustand des zugrunde liegenden Betriebssystems und der JVM. Der Schlüssel zum Erfolg ist, dass durch das Auslösen einer Ausnahme eine Methode hunderte Male langsamer ausgeführt wird.

In den nächsten Unterabschnitten erfahren Sie, was genau zu einem solch dramatischen Unterschied führt.

3.3. Eine Ausnahme erstellen, ohne sie zu werfen

Anstatt eine Ausnahme zu erstellen, zu werfen und abzufangen, erstellen wir sie einfach:

@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

Allein durch das Abwickeln des Stack-Trace sehen wir eine satte Zunahme der Ausführungsdauer um das 20-fache. Anders ausgedrückt, die Leistung ist viel schlechter, wenn wir den Stack-Trace zusätzlich zum Auslösen aus einer Ausnahme extrahieren.

4. Fazit

In diesem Tutorial haben wir die Leistungseffekte von Ausnahmen analysiert. Insbesondere wurde festgestellt, dass die Leistungskosten hauptsächlich durch Hinzufügen des Stack-Trace zur Ausnahme verursacht werden. Wenn diese Stapelverfolgung danach abgewickelt wird, wird der Overhead viel größer.

Da das Auslösen und Behandeln von Ausnahmen teuer ist, sollten wir sie nicht für normale Programmabläufe verwenden. Stattdessen sollten Ausnahmen, wie der Name schon sagt, nur in Ausnahmefällen verwendet werden.

Den vollständigen Quellcode finden Sie auf GitHub.