Effetti sulle prestazioni delle eccezioni in Java

1. Panoramica

In Java, le eccezioni sono generalmente considerate costose e non dovrebbero essere utilizzate per il controllo del flusso. Questo tutorial dimostrerà che questa percezione è corretta e individuerà le cause del problema di prestazioni.

2. Configurazione dell'ambiente

Prima di scrivere codice per valutare il costo delle prestazioni, è necessario impostare un ambiente di benchmarking.

2.1. Java Microbenchmark Harness

Misurare il sovraccarico delle eccezioni non è facile come eseguire un metodo in un semplice ciclo e prendere nota del tempo totale.

Il motivo è che un compilatore just-in-time può intralciare e ottimizzare il codice. Tale ottimizzazione può migliorare le prestazioni del codice rispetto a quanto effettivamente farebbe in un ambiente di produzione. In altre parole, potrebbe produrre risultati falsamente positivi.

Per creare un ambiente controllato in grado di mitigare l'ottimizzazione JVM, utilizzeremo Java Microbenchmark Harness, o JMH in breve.

Le seguenti sottosezioni illustreranno la configurazione di un ambiente di benchmarking senza entrare nei dettagli di JMH. Per ulteriori informazioni su questo strumento, consulta il nostro tutorial Microbenchmarking con Java.

2.2. Ottenere gli artefatti JMH

Per ottenere artefatti JMH, aggiungi queste due dipendenze al POM:

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

Fare riferimento a Maven Central per le ultime versioni di JMH Core e JMH Annotation Processor.

2.3. Classe di riferimento

Avremo bisogno di una classe per tenere i benchmark:

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

Esaminiamo le annotazioni JMH mostrate sopra:

  • @Fork : specifica il numero di volte in cui JMH deve generare un nuovo processo per eseguire i benchmark. Impostiamo il suo valore su 1 per generare un solo processo, evitando di aspettare troppo a lungo per vedere il risultato
  • @Warmup : portare i parametri di riscaldamento. L' elemento iterazioni essendo 2 significa che le prime due esecuzioni vengono ignorate durante il calcolo del risultato
  • @Measurement : trasporto dei parametri di misurazione. Un valore di iterazioni di 10 indica che JMH eseguirà ogni metodo 10 volte
  • @BenchmarkMode : questo è il modo in cui JHM dovrebbe raccogliere i risultati dell'esecuzione. Il valore AverageTime richiede a JMH di contare il tempo medio necessario a un metodo per completare le sue operazioni
  • @OutputTimeUnit : indica l'unità di tempo di uscita, che in questo caso è il millisecondo

Inoltre, c'è un campo statico all'interno del corpo della classe, ovvero LIMIT . Questo è il numero di iterazioni in ogni corpo del metodo.

2.4. Esecuzione di benchmark

Per eseguire i benchmark, abbiamo bisogno di un metodo principale :

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

Possiamo impacchettare il progetto in un file JAR ed eseguirlo dalla riga di comando. In questo modo, ovviamente, si produrrà un output vuoto poiché non abbiamo aggiunto alcun metodo di benchmarking.

Per comodità, possiamo aggiungere il plugin maven-jar al POM. Questo plugin ci permette di eseguire il metodo principale all'interno di un IDE:

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

L'ultima versione di maven-jar-plugin può essere trovata qui.

3. Misurazione delle prestazioni

È ora di disporre di alcuni metodi di benchmarking per misurare le prestazioni. Ciascuno di questi metodi deve contenere l' annotazione @Benchmark .

3.1. Metodo di ritorno normalmente

Cominciamo con un metodo che ritorna normalmente; ovvero un metodo che non genera un'eccezione:

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

Il parametro blackhole fa riferimento a un'istanza di Blackhole . Questa è una classe JMH che aiuta a prevenire l'eliminazione del codice morto, un'ottimizzazione che può essere eseguita da un compilatore just-in-time.

Il benchmark, in questo caso, non fa eccezione. In effetti, lo useremo come riferimento per valutare le prestazioni di coloro che generano eccezioni.

L'esecuzione del metodo principale ci darà un rapporto:

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

Non c'è niente di speciale in questo risultato. Il tempo medio di esecuzione del benchmark è di 0,049 millisecondi, il che è di per sé piuttosto insignificante.

3.2. Creazione e lancio di un'eccezione

Ecco un altro benchmark che genera e cattura eccezioni:

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

Diamo un'occhiata all'output:

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

La piccola modifica nel tempo di esecuzione del metodo doNotThrowException non è importante. È solo la fluttuazione nello stato del sistema operativo sottostante e della JVM. Il punto chiave è che la generazione di un'eccezione rende un metodo eseguito centinaia di volte più lentamente.

Le prossime sottosezioni scopriranno cosa porta esattamente a una differenza così drammatica.

3.3. Creare un'eccezione senza lanciarla

Invece di creare, lanciare e catturare un'eccezione, la creeremo semplicemente:

@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

Solo svolgendo la traccia dello stack, vediamo un enorme aumento di circa 20 volte nella durata dell'esecuzione. In altre parole, le prestazioni sono molto peggiori se estraiamo la traccia dello stack da un'eccezione oltre a lanciarla.

4. Conclusione

In questo tutorial, abbiamo analizzato gli effetti sulle prestazioni delle eccezioni. In particolare, ha scoperto che il costo delle prestazioni è principalmente nell'aggiunta dell'analisi dello stack all'eccezione. Se questa traccia dello stack viene svolta in seguito, l'overhead diventa molto più grande.

Poiché lanciare e gestire eccezioni è costoso, non dovremmo usarlo per i normali flussi di programma. Invece, come suggerisce il nome, le eccezioni dovrebbero essere utilizzate solo per casi eccezionali.

Il codice sorgente completo può essere trovato su GitHub.