Effets sur les performances des exceptions en Java

1. Vue d'ensemble

En Java, les exceptions sont généralement considérées comme coûteuses et ne doivent pas être utilisées pour le contrôle de flux. Ce tutoriel prouvera que cette perception est correcte et identifiera les causes du problème de performances.

2. Configuration de l'environnement

Avant d'écrire du code pour évaluer le coût des performances, nous devons mettre en place un environnement d'analyse comparative.

2.1. Harnais Java Microbenchmark

Mesurer la surcharge des exceptions n'est pas aussi simple que d'exécuter une méthode dans une simple boucle et de prendre note du temps total.

La raison en est qu'un compilateur juste à temps peut gêner et optimiser le code. Une telle optimisation peut rendre le code plus performant qu'il ne le ferait réellement dans un environnement de production. En d'autres termes, cela pourrait donner des résultats faussement positifs.

Pour créer un environnement contrôlé pouvant atténuer l'optimisation de la JVM, nous utiliserons Java Microbenchmark Harness, ou JMH en abrégé.

Les sous-sections suivantes expliqueront la mise en place d'un environnement d'analyse comparative sans entrer dans les détails de JMH. Pour plus d'informations sur cet outil, veuillez consulter notre tutoriel Microbenchmarking avec Java.

2.2. Obtention d'artefacts JMH

Pour obtenir des artefacts JMH, ajoutez ces deux dépendances au POM:

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

Veuillez consulter Maven Central pour les dernières versions de JMH Core et JMH Annotation Processor.

2.3. Classe de référence

Nous aurons besoin d'une classe pour tenir des benchmarks:

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

Passons en revue les annotations JMH ci-dessus:

  • @Fork : Spécifier le nombre de fois où JMH doit générer un nouveau processus pour exécuter des benchmarks. Nous définissons sa valeur sur 1 pour générer un seul processus, évitant d'attendre trop longtemps pour voir le résultat
  • @Warmup : transport des paramètres d'échauffement. L' élément iterations étant 2 signifie que les deux premières exécutions sont ignorées lors du calcul du résultat
  • @Measurement : Transport des paramètres de mesure. Une valeur d' itérations de 10 indique que JMH exécutera chaque méthode 10 fois
  • @BenchmarkMode : C'est ainsi que JHM doit collecter les résultats d'exécution. La valeur AverageTime nécessite que JMH compte le temps moyen dont une méthode a besoin pour terminer ses opérations
  • @OutputTimeUnit : Indique l'unité de temps de sortie, qui est la milliseconde dans ce cas

De plus, il y a un champ statique à l'intérieur du corps de la classe, à savoir LIMIT . Il s'agit du nombre d'itérations dans chaque corps de méthode.

2.4. Exécution des benchmarks

Pour exécuter des benchmarks, nous avons besoin d'une méthode principale :

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

Nous pouvons empaqueter le projet dans un fichier JAR et l'exécuter en ligne de commande. Faire cela maintenant produira bien sûr une sortie vide car nous n'avons ajouté aucune méthode d'analyse comparative.

Pour plus de commodité, nous pouvons ajouter le plugin maven-jar-plugin au POM. Ce plugin nous permet d'exécuter la méthode principale dans un IDE:

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

La dernière version de maven-jar-plugin peut être trouvée ici.

3. Mesure du rendement

Il est temps d'avoir des méthodes d'analyse comparative pour mesurer les performances. Chacune de ces méthodes doit porter l' annotation @Benchmark .

3.1. Méthode retournant normalement

Commençons par une méthode retournant normalement; c'est-à-dire une méthode qui ne lève pas d'exception:

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

Le BlackHole paramètre fait référence à une instance de Blackhole . Il s'agit d'une classe JMH qui permet d'éviter l'élimination du code mort, une optimisation qu'un compilateur juste à temps peut effectuer.

Le benchmark, dans ce cas, ne jette aucune exception. En fait, nous l'utiliserons comme référence pour évaluer les performances de ceux qui lancent des exceptions.

L'exécution de la méthode principale nous donnera un rapport:

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

Il n'y a rien de spécial dans ce résultat. Le temps d'exécution moyen du benchmark est de 0,049 milliseconde, ce qui est en soi assez dénué de sens.

3.2. Créer et lancer une exception

Voici un autre benchmark qui lève et intercepte des exceptions:

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

Jetons un coup d'œil à la sortie:

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

Le petit changement dans le temps d'exécution de la méthode doNotThrowException n'est pas important. C'est juste la fluctuation de l'état du système d'exploitation sous-jacent et de la JVM. La clé à retenir est que le fait de lancer une exception rend une méthode des centaines de fois plus lente.

Les prochaines sous-sections découvriront ce qui conduit exactement à une telle différence dramatique.

3.3. Créer une exception sans la lancer

Au lieu de créer, lancer et intercepter une exception, nous allons simplement la créer:

@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

Juste en déroulant la trace de la pile, nous constatons une augmentation considérable d'environ 20 fois la durée d'exécution. En d'autres termes, les performances sont bien pires si nous extrayons la trace de la pile d'une exception en plus de la lancer.

4. Conclusion

Dans ce didacticiel, nous avons analysé les effets des exceptions sur les performances. Plus précisément, il a découvert que le coût des performances résidait principalement dans l'ajout de la trace de pile à l'exception. Si cette trace de pile est déroulée par la suite, la surcharge devient beaucoup plus importante.

Étant donné que lancer et gérer des exceptions est coûteux, nous ne devons pas l'utiliser pour les flux de programme normaux. Au lieu de cela, comme son nom l'indique, les exceptions ne devraient être utilisées que pour des cas exceptionnels.

Le code source complet est disponible à l'adresse over sur GitHub.