Le véritable coût des exceptions en Java

Sur le projet sur lequel je travaille actuellement, un de mes collègues (ouvert au débat, compétent et affable) et moi avons échangé sur la manière de gérer la validation des règles fonctionnelles. Une chose en amenant une autre, est avancé de sa part, comme argument que lever une exception présentait un coût non négligeable en terme de performance.
N’ayant jamais entendu ce type de problématique concernant la levée d’exception mais me doutant bien qu’une exception créée puis lancée coûtait bien plus qu’un Boolean ou un Integer (rien que pour la génération de la stacktrace et de l’empreinte mémoire associée), je ne pouvais ni approuver, ni contester l’existence d’un impact effectif sur les performances.

J’ai donc effectué des recherches sur ce sujet. De celles-ci, j’ai pu tirer une conclusion simple et sans équivoque : lever une exception pour arrêter un traitement si une règle obligatoire est invalidée ne pose aucun problème de performance dans la grande majorité des applications Java (>99% à mon humble avis)

Pourquoi ?

Des informations de références que j’ai lues, des tests que j’ai réalisés, et d’après également différents benchmarks sur la question, une exception créée puis levée occasionne un coût d’exécution indéniable par rapport à une instruction basique de type

 if (monEntier==5)

.
Cela peut aller de 40 à 80 fois plus cher en terme de performance selon les tests. Maintenant, il faut mettre cette information dans un contexte applicatif réel et évaluer dans l’absolu ce que ce « surcoût » représente.

Est-ce un surcoût en pratique ou un surcoût uniquement théorique ?

On peut voir des discussions et des métriques sur Stackoverflow : http://stackoverflow.com/questions/299068/how-slow-are-java-exceptions ainsi que dans ces articles très bien faits et référencés à maintes reprises sur Internet, l’estimation en terme de temps d’exécution de l’instanciation et de la levée d’une exception, ainsi que des contournements possibles si nécessaire, avec tests à l’appui :
– http://shipilev.net/blog/2014/exceptional-performance/
– http://java-performance.info/throwing-an-exception-in-java-is-very-slow/

En résumé, sur le CPU d’un développeur moyen, le coût de création d’une exception puis de sa levée est évalué entre 1 et 5 micro secondes (selon la profondeur de la stackstrace à créer) : soit entre 0.001ms 0.005ms.
On va prendre le maximum de la fourchette : soit 5 micro secondes.
Je prends le pire comme exemple car qui peut le plus peut le moins.
Alors certes, c’est bien plus qu’une instruction élémentaire telle qu’un « if » mais on est dans un ordre de grandeur extrêmement faible.

Le coût le plus important dans l’utilisation des exceptions n’est pas le fait de lever l’exception mais de l’instancier.
La création de la stacktrace en est le coût principal.
Si on veut gagner fortement en terme de temps d’exécution lors de la création de l’exception on peut redéfinir la méthode fillInStackTrace() afin de ne pas créer la stacktrace ou encore utiliser l’exception en singleton si cette dernière est toujours levé au même endroit (Sun référençait cette pratique à une époque).
On en parle ici :
http://stackoverflow.com/questions/9788993/why-is-throwable-fillinstacktrace-method-public-why-would-someone-use-it

la méthode fillInStackTrace() prend son origine dans la classe java.lang.Throwable, classe mère des classes java.lang.Exception et java.lang.Error :

public synchronized Throwable fillInStackTrace() {
     if (stackTrace != null || backtrace != null /* Out of protocol state */ ) {
         fillInStackTrace(0);
         stackTrace = UNASSIGNED_STACK;
        }
     return this;
}

Une redéfinition pas coûteuse pour notre exception pourrait être:

public synchronized Throwable fillInStackTrace() {
     return this;
}

Et dans ce cas de la redéfinition de fillstracktrace() sans stracktrace, on perd évidemment le bénéfice de pouvoir tracer avec exactitude le flux de traitement associé à l’exception, qui pourrait être utile dans les phases de développement et de qualification comme dans les traces de journalisation (si nécessaire en production).

Exemple d’information dans le flux de sortie si mon exception ne redéfinit pas fillInStackTrace() :

Exception in thread "AWT-EventQueue-0" trading.update.exception.TechnicalException: cas non prévu
at trading.highwidget.CandlestickDavid.(CandlestickDavid.java:93)
at trading.highwidget.CandlestickDavid$2.run(CandlestickDavid.java:332)
at java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:251)
at java.awt.EventQueue.dispatchEventImpl(EventQueue.java:733)
at java.awt.EventQueue.access$200(EventQueue.java:103)
at java.awt.EventQueue$3.run(EventQueue.java:694)
at java.awt.EventQueue$3.run(EventQueue.java:692)
at java.security.AccessController.doPrivileged(Native Method)
at java.security.ProtectionDomain$1.doIntersectionPrivilege(ProtectionDomain.java:76)
at java.awt.EventQueue.dispatchEvent(EventQueue.java:703)
at java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:242)
at java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:161)
at java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:150)
at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:146)
at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:138)
at java.awt.EventDispatchThread.run(EventDispatchThread.java:91)

Exemple d’information dans le flux de sortie si mon exception redéfinit fillInStackTrace() pour ne rien faire :

Exception in thread "AWT-EventQueue-0" trading.update.exception.TechnicalException: cas non prévu

Est ce que le jeu en vaut la chandelle ?

En prenant un modèle de conception ou une exception est levée dès que le use case détecte une situation d’exception (traitement de rejet), on ajoute grossièrement 5 micro secondes dans le traitement du scénario d’exception.

Je vais prendre l’exemple d’un UC moyennement complexe et des chiffres moyens pour comprendre l’impact en terme de perf de l’utilisation d’une exception de validation créée puis levée dans un scénario du UC d’exception.
Notre UC moyen d’illustration pourrait être l’enregistrement d’une commande sur un site web.
Cet exemple d’UC présente quelques règles d’invalidation (adresse invalide ou moyen de paiement non renseigné par exemple).
Admettons que si la création de la commande réussie, on est sur un temps d’exécution moyen de bout en bout côté serveur(de la réception de la requête par le serveur d’application jusqu’à l’écriture de la réponse dans la page du navigateur du client) de 800 ms.
Si la création de la commande échoue, le traitement sera moins long car on ne créera pas la commande en base de données, on ne sollicitera pas non plus le service d’autorisation de paiement, etc…
On va simplifier (les chiffres exacts ne sont pas importants, seul l’ordre de grandeur l’est) en supposant que la création de la commande dans le cas d’un échec (sans passer par une exception java pour valider les règles fonctionnelles) s’effectue en 400ms.
Si je traite le cas d’échec de ce UC avec la création, puis la levée d’une exception , en partant du principe qu’une exception créée puis lancée coûte 5 micro secondes, on passe à un temps d’exécution de bout en bout moyen de 400 ms à 400,005 ms.

Valider les règles fonctionnelles par booléen ou par levée d’exception est une question de choix de conception.
Sur ce sujet, comme dans d’autres, il y a plusieurs manières de faire et donc des partisans des deux côtés.
Par contre, au vu du coût effectif de l’utilisation d’une exception, et du fait que pas plus d’une seule exception peut être levée au sein d’un même flow d’un UC, cela n’a assurément aucun d’impact sur les perfs.
Et en extrapolant, en imaginant une conception extrêmement mal ficelée : le pire du pire, créant et levant en moyenne 100 exceptions au sein du même flow du UC, cela ne se ferait probablement pas sentir au niveau des perfs . En reprenant l’exemple de toute à l’heure, on passerait d’une exécution de 400 à 400,5 ms.

Je pense que ce type de problématique ( se soucier d’1 à 5 micro secondes) pour l’exécution d’un UC, concerne davantage les applications et les UC ou chaque micro voire nano seconde a son importante pour l’utilisateur.
Par exemple, un UC de passage d’ordre sur un marché où des utilisateurs sont en concurrence pour voir leur ordre exécuté en premier.
Ou encore une application pour de l’embarqué avec des capacités de traitements très limitées où les 5 microsecondes peuvent rapidement se transformer en 5 millisecondes, voire davantage.

Enfin, avec un traitement par exception Java ou non, le cas de rejet sera toujours moins gourmand en ressource que le cas d’acceptation (et donc libérera le thread plus rapidement) car il finira prématurément. Donc gagner presque rien sur ce cas-là n’est pas forcément la meilleure cible d’optimisation. Les traitements long en terme de temps d’exécution et les traitements très récurrents même un peu moins longs sont bien plus intéressants à étudier.

Conclusion
Je reste sur cette logique :
– Si il n’est pas souhaité (pas d’utilité), souhaitable (ex pour la sécurité lors d’une ouverture d’une session) ou possible (règles imbriquées) de remonter l’ensemble des violations de règle, j’utilise des exceptions pour valider les règles fonctionnelles. Cela offre une flexibilité en terme de traitement, de lisibilité du code (méthode d’assertion pour traiter les cas de rejets) et permet d’avoir un code plus léger et comportant moins d’instruction et et de traitements conditionnels (les exceptions et les traitements associés peuvent être gérés depuis un point central).
– Si il est souhaitable et possible (règles non imbriquées) de remonter un ensemble de violations de règles plutôt que de s’arrêter lors de la détection de la première violation, j’utilise un objet enregistrant séquentiellement les violations de règles.
Martin Fowler (comme d’autres) en parle ici : http://martinfowler.com/eaaDev/Notification.html

Ce contenu a été publié dans performance en JAVA, Uncategorized. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *