La performance en Java a la réputation d'être quelque chose proche de la magie noire. C'est en partie dû à la sophistication de la plateforme, ce qui rend difficile le raisonnement sur de nombreux cas. Cependant, il y a historiquement une tendance à ce que les techniques d'amélioration des performances en Java consistent plus en du folklore populaire plutôt qu'une application de raisonnement empirique et statistique. Dans cet article, j'espère répondre à certains des plus flagrants contes de fées.
1. Java est lent
De toutes les erreurs les plus dépassées, c'est probablement la plus flagrante.
Bien sûr, dans les années 90 et au tout début des années 2000, Java pouvait être lent à certains moments.
Cependant nous avons eu plus de 10 ans d'améliorations de la machine virtuelle et du JIT. Depuis, la performance globale de Java est furieusement rapide.
Dans six benchmarks différents sur les performances web, les frameworks Java occupent 22 des 24 premières positions.
L'usage par la JVM de profiling pour uniquement optimiser les chemins de code les plus utilisés, mais en les optimisant de manière intensive, a porté ses fruits. Le code Java compilé avec JIT est maintenant aussi rapide que le C++ dans un grand (et grandissant) nombre de cas.
Malgré cela, la perception de Java comme étant une plateforme lente persiste, peut-être dû à un préjugé historique venant de personnes ayant eu des expériences avec les premières versions de la plateforme Java.
Nous suggérons de rester objectif et d'évaluer les résultats de tests de performance récents avant de tirer des conclusions.
2. Une ligne de java peut vouloir dire n'importe quoi quand elle est prise en isolation
Considérez la courte ligne de code suivante:
MyObject obj = new MyObject();
Pour un développeur Java, il semble évident que ce code doit allouer un objet et exécuter le constructeur approprié.
À partir de cela on peut commencer à raisonner sur les limites de ses performances. Nous savons qu'il y a une charge de travail limité qui doit être fait, nous pouvons donc essayer de calculer l'impact sur les performances basé sur nos suppositions.
Il y a un préjugé logique qui peut nous piéger en nous faisant croire que nous savons, a priori, que chaque bout de code devra être exécuté.
En réalité, à la fois javac et le compilateur JIT peuvent optimiser le code mort. Dans le cas du compilateur JIT, le code peut même être optimisé de manière spéculative en se basant sur les données de profiling. Dans ces cas, ces lignes de codes ne seront jamais exécutées et donc il n’y aura aucun impact sur les performances.
Par ailleurs, dans certaines JVMs, tel que JRockit, le compilateur JIT peut même décomposer les opérations des objets de telle manière que des allocations peuvent être évitées même si le code n'est pas complètement mort.
La morale de cette histoire est que le contexte est significatif lorsque l'on traite les performances Java, et des optimisations prématurées peuvent produire des résultats contre-intuitifs. Pour de meilleurs résultats, ne tentez pas de faire des optimisations prématurées. À la place, compilez toujours votre code et utilisez des techniques d'optimisation de performance pour identifier et corriger les performances sur les points chauds.
3. Un microbenchmark ne signifie que ce que vous pensez qu'il fait
Comme nous avons vu ci-dessus, raisonner sur une petite portion de code est moins précis que d'analyser les performances globales de l'application.
Pourtant les développeurs adorent écrire des microbenchmarks. Le plaisir viscéral que certaines personnes tirent de bricoler avec certains aspects bas niveau de la plate-forme semble être sans fin.
Richard Feynman a dit une fois: "Le premier principe est que vous ne devez pas vous duper vous-même, mais que vous êtes la personne la plus facile à duper". Cela n'est nulle part plus vrai que lors de l'écriture de microbenchmarks Java.
Écrire de bons microbenchmarks est profondément difficile. La plateforme Java est tellement sophistiquée et complexe, et beaucoup de microbenchmarks ne réussissent à mesurer que les effets de bords ou les effets inattendus de la plateforme.
Par exemple, un microbenchmark écrit naïvement va fréquemment finir par mesurer le système de timing ou peut être le garbage collector plutôt que les effets qu'il essaye de capturer.
Seuls les développeurs ou les équipes qui ont un vrai besoin spécifique doivent écrire des microbenchmarks. Ces benchmarks devraient être publié dans leur totalité (incluant le code source), et doivent être reproductibles, et soumis à un examen minutieux par d'autres personnes.
De nombreuses optimisations de la plate-forme Java impliquent que les statistiques d'exécutions individuelles comptent. Un seul benchmark doit être exécuté de nombreuses fois puis les résultats agrégés afin d'avoir une réponse fiable.
Si vous avez le sentiment que vous devez écrire des microbenchmarks, alors une bonne idée pour commencer est de lire le papier "Statistically Rigorous Java Performance Evaluation" de Georges, Buytaert, Eeckhout. Sans un traitement rigoureux des statistiques, il est très facile d'être induit en erreur.
Il y a des outils bien faits et une communauté autour d'eux (par exemple, Caliper de Google), si vous devez absolument écrire des microbenchmarks, alors ne le faites pas dans votre coin, vous avez besoin des points de vue et de l'expérience des autres.
4. Les algorithmes lents sont la cause la plus commune des problèmes de performance
Une erreur très classique parmi les développeurs (et les humains en général) est de supposer que la partie du système qu'ils contrôlent est la partie la plus importante.
En performance Java, cela se manifeste par des développeurs Java croyant que la qualité de l'algorithme est une cause déterminante dans les problèmes de performance. Les développeurs pensent à du code, donc ils ont une tendance naturelle à penser à leurs algorithmes.
En pratique, lorsque l'on examine une série de problèmes de performances venant du monde réel, la conception d'algorithmes a été le problème principal dans moins de 10% des cas.
À la place, le garbage collector, les accès base de données, et les erreurs de configurations avaient tous plus de chance d'être la source du ralentissement de l'application plutôt que les algorithmes.
La majorité des applications traite une quantité relativement faible de données, de sorte que même d'importants soucis dans les algorithmes ne produisent pas de problèmes de performance critiques. Pour en être certains, supposons que les algorithmes soient sous-optimisés, toutefois la quantité d'inefficacité qu'ils ajoutent est faible comparé aux autres effets, beaucoup plus importants, apportés par toutes les autres parties de la stack de l'application.
Donc, mon meilleur conseil est d'utiliser des données empiriques de production pour découvrir les causes réelles des problèmes de performance. Mesurez, ne devinez pas !
5. Un cache résout tous les problèmes
"Chaque problème en informatique peut être résolu en ajoutant une autre couche d'indirection"
Cette phrase de programmeur, attribué à David Wheeler (et grâce à internet, à au moins 2 autres chercheurs en informatique), est étonnement commune, spécialement pour les développeurs web.
Souvent, cette erreur survient en raison d'une analyse erronée lorsque l'on est confronté à une architecture existante mal comprise.
Plutôt que de traiter avec un système existant intimidant, un développeur va souvent choisir de coller un cache devant afin de le masquer et espérer que ça aille mieux. Bien sûr, cette approche ne fait que compliquer l'architecture globale et aggrave la situation pour le prochain développeur qui va chercher à comprendre le statu quo de la production.
Les grandes architectures tentaculaires sont écrites ligne par ligne et un sous-système à la fois. Cependant, dans de nombreux cas plus simples, les architectures retravaillées sont plus performantes, et sont presque tout le temps plus faciles à comprendre.
Donc lorsque vous êtes en train de déterminer si un cache est réellement nécessaire, prévoyez de collecter des statistiques basiques (taux de miss, taux de hit, etc.) pour prouver que le cache ajoute de la valeur.
6. Toutes les app sont concernées par le Stop-The-World
Un fait de la plateforme Java est que tous les threads de l'application doivent être périodiquement arrêtés pour permettre au Garbage Collection de s'exécuter. Ceci est des fois montré du doigt comme un sérieux point faible, même en l'absence de véritable preuve.
Des études empiriques ont montré que les humains ne peuvent pas percevoir normalement les changements dans des données numériques (p. ex. changement de prix) survenant plus fréquemment que 200ms.
En conséquence, pour les applications qui ont des humains comme utilisateurs principaux, une règle utile est que les pauses de 200ms ou moins du Stop-The-World (STW) ne sont généralement pas un problème. Certaines applications (p. ex. streaming vidéo) ont besoin d'un GC plus court que ça, mais beaucoup d'applications graphiques n'en ont pas besoin.
Il y a une minorité d'applications (tel que le trading basse-latence, ou le contrôle de systèmes mécaniques) pour qui une pause de 200ms est inacceptable. À moins que votre application soit dans cette minorité, il est peu probable que vos utilisateurs perçoivent un quelconque impact du garbage collector.
Il est également à noter que, dans tout système où il y a plus de threads applicatifs que de cœurs physiques, le scheduler du système d'exploitation va devoir intervenir pour partager le temps d'accès aux CPUs. Stop-The-World semble effrayant, mais en pratique, chaque application (dans la JVM ou non) doit gérer les accès limités aux rares ressources de calculs.
Sans mesure, il n'est pas clair que l'approche de la JVM a un impact supplémentaire significatif sur les performances de l'application.
Pour résumer, déterminez si les temps de pause ne nuisent pas à votre application en activant les logs GC. Analysez les logs (que ce soit à la main, avec des scripts ou des outils) pour déterminer la durée de pauses. Puis décidez s'ils posent vraiment un problème pour votre application. Plus important, posez-vous une question: est-ce qu'il y a des utilisateurs qui se plaignent actuellement ?
7. Un pool d'objets fait à la main est approprié pour un large éventail d'applications
Une réponse classique au sentiment que les pauses du Stop-The-World sont mauvaises pour l'application est d'inventer ses propres techniques de gestion de la mémoire dans la heap Java. Souvent, cela se résume à la mise en œuvre d'une approche pool d'objets (ou même de compter les références) et exiger que tous le code des objets métiers y participe.
Cette technique est presque toujours erronée. Elle a souvent ses racines dans un passé lointain, où l'allocation de l'objet était chère et la mutabilité était jugée sans conséquence. Le monde est très différent maintenant.
Le matériel moderne est incroyablement efficace à l'allocation, la bande passante de la mémoire est au moins de 2 à 3 Go sur des postes de travail récents ou le matériel de serveur. C'est un nombre élevé, et en dehors des cas d'utilisation spéciaux, il n'est pas si facile que ça de faire des applications qui saturent la bande passante.
Les pools d'objets sont généralement difficiles à mettre en œuvre correctement (surtout quand il y a plusieurs threads) et ont plusieurs besoins qui en font un mauvais choix dans un usage normal:
- Tous les développeurs qui touchent au code doivent être au courant du pool et doivent l'utiliser correctement
- La frontière entre le code "dans-le-pool" et "pas-dans-le-pool" doit être connue et documentée
- Toute cette complexité additionnelle doit être maintenue, et revue régulièrement
- Si tout cela échoue, le risque de corruption silencieuse (semblable à la réutilisation d'un pointeur en C) est réintroduit
En résumé, les pools d'objets doivent être utilisés seulement lorsque les pauses GC ne sont pas acceptables, et que des essais intelligents de réglage et de refactoring ont été faits sans réussir à réduire les pauses à un niveau acceptable.
8. CMS est toujours un meilleur choix de GC que Parallel Old
Par défaut, le JDK d'Oracle va utiliser Parallel Old, un GC de type stop-the-world pour recycler la old generation.
Un choix alternatif est Concurrent-Mark-Sweep (CMS). Il permet au thread de l'application de continuer de s'exécuter pendant la majorité du temps du cycle du GC, mais cela à un prix, et doit être utilisé avec pas mal de précautions.
Autoriser les threads de l'application à s'exécuter en parallèle aux threads du GC va invariablement avoir pour résultat la mutation du graphe des objets dans un sens qui devrait affecter les liveness des objets. Ceci doit ensuite être nettoyé, et donc le CMS a en fait 2 phases STW.
Ceci a plusieurs conséquences:
- Tous les threads de l'application doivent être à un point stable et interrompu 2 fois par full GC;
- Pendant que le GC est exécuté, la rapidité de l'application est réduite (habituellement de 50%)
- Le nombre global de cycles CPU dans lesquels la JVM va passer à recycler la mémoire avec CMS est considérablement plus haut qu'avec Parallel Old.
En fonction des circonstances de l'application, ces inconvénients peuvent être acceptables ou non. Mais il n'y a rien de magique. Le CMS est une magnifique pièce d'ingénierie, mais ce n'est pas la panacée.
Donc avant de conclure que CMS est la bonne stratégie de GC, vous devez en premier déterminer que les pauses STW du Parallel Old sont inacceptables et ne peuvent être réglées. Et finalement (et je ne peux assez insister sur ce point), soyez certains que toutes les métriques sont obtenues sur un système équivalent à la production.
9. Augmenter la taille de la heap va résoudre les problèmes
Lorsqu'une application est en difficulté et que le GC est suspecté, de nombreux groupes d'applications vont répondre par juste augmenter la taille de la heap. Sous certaines circonstances, cela peut produire des résultats rapidement, et permettre de gagner du temps pour proposer une résolution. Cependant, sans une complète compréhension des causes du problème de performance, cette stratégie peut dégrader les choses.
Considérez une application mal codée qui produit trop d'objets intermédiaires (avec un cycle de vie de typiquement 2 ou 3 secondes). Si le taux d'allocation est suffisamment important, le garbage collector peut se produire si rapidement que ces objets intermédiaires sont promus dans la tenured (old) generation. Une fois dans la tenured, les objets intermédiaires meurent presque immédiatement, mais ne seront pas collectés avant le prochain full GC.
Si on augmente la taille de la heap de l'application, tout ce que l'on fait réellement c'est ajouter de l'espace pour des objets avec un temps de vie relativement court. Ceci peut augmenter la durée des pauses Stop-The-World sans aucun bénéfice pour l'application.
Comprendre la dynamique d'allocation des objets et de leur cycle de vie avant de changer la taille de la heap ou de modifier des paramètres de tuning est indispensable. Agir sans mesurer peut aggraver les choses. Les informations venant du garbage collector sur la répartition des objets dans la mémoire est spécialement importante ici.
Conclusion
En ce qui concerne les performances Java, l'intuition est souvent fausse. Nous avons besoin de données empiriques et d'outils pour nous aider à visualiser et comprendre le comportement de la plateforme.
Le Garbage Collector fournit peut-être le meilleur exemple pour cela. Le système du GC a un potentiel incroyable d'optimisation ainsi que de production des données pour aider à l'optimiser, mais pour les applications en production, il est très difficile de tirer des conclusions sans les outils adéquats.
Par défaut, on devrait exécuter les processus Java (en développement ou en production) avec au minimum les flags suivants:
- -verbose:gc (afficher les logs GC)
- -Xloggc: (pour des logs GC complets)
- -XX:+PrintGCDetails (pour plus de détails en sortie)
- -XX:+PrintTenuringDistribution (affiche les seuils de tenuring supposée par la JVM)
et ensuite utiliser un outil pour analyser ces logs, que ce soit des scripts écrits à la main et des générateurs de graphes, ou des outils graphiques tel que GCViewer (open-source) ou jClarity Censum.
À propos de l'auteur
Ben Evans est le CEO de jClarity, une startup qui créée des outils de performance pour aider les équipes de développements d'ops. C'est un des organisateurs de LJC (London JUG) et un membre de JCP Executive Comittee, qui aide à la définition de standards pour l'écosystème Java. C'est un Java Champion; JavaOne Rockstar; coauteur de “The Well-Grounded Java Developer” et un speaker régulier sur la plateforme Java, les performances, la concurrence et les sujets apparentés.