Intermédiaire

Java : Entre compilation statique et dynamique, mon cœur balance

javaLe langage Java est le premier langage de programmation et, selon certaines sources, nous sommes 9 millions de développeurs Java dans le monde à l’utiliser quotidiennement.  Mais  combien d’entre nous connaissent les différents modes de compilation ? Les liens étroits qu’ils ont tissés avec les optimisations effectuées par la JVM ?

La majorité des développeurs savent que le JDK assure deux types de compilations :

  • La première est de nature statique : Elle est assurée par l’outil « javac». Son rôle est de transformer le code java en « bytecode » ou code intermédiaire interprétable, donc en code exécutable sur tous types de plateformes disposant d’une JVM.
  • La seconde est de nature dynamique : Elle est assurée dans un premier niveau par l’interpréteur, puis par un composant appelé JIT, pour des niveaux de compilation plus avancés. Le « bytecode» généré par la compilation statique est ensuite transformé, en fonction d’un certain nombre de règles, en code machine optimisé. Les optimisations effectuées dépendent d’un certain nombre de règles et obéissent à des stratégies bien définies. Elles dépendent également des statistiques d’exécution. Autrement dit, les instructions fréquemment utilisées et qualifiées de « Hot », sont remplacées par du code compilé à la volée lors des appels suivants. Le code compilé ou recompilé peut subir une ou plusieurs optimisations. Plus amples sont les statistiques, plus agressives seront les optimisations appliquées.

Mais y a-t-il réellement uniquement deux compilations, ou est-ce la vulgarisation Java qui est à l’origine de cette idée reçue ?

Avant d’aller plus loin commençons par le début et plongeons dans les fondations  de l’écosystème Java.

Ecosystème Java : Vue globale

Le « bytecode » est un code interprété. Il est généré par le compilateur « javac » et n’est pas compris par les processeurs comme c’est le cas du code machine. La transformation en code machine est possible grâce à une seconde compilation qui est de la responsabilité de la JVM. Cette compilation est spécifique à une plateforme donnée, car basée sur des librairies dépendantes de celle-ci.

javac_01

Figure 1 : Java Ecosystème : Vue globale

La figure 1 permet d’identifier et de présenter les éléments engagés depuis la compilation jusqu’à l’exécution, en passant par les décisions d’optimisation. Il me semble important de connaitre les éléments mis en scène, pour mieux en comprendre le rôle dans les mécanismes de compilation et/ou d’optimisation.

Dans ces premières versions, la JVM a été, à juste titre, décriée pour sa lenteur. Mais depuis, bien des améliorations ont été intégrées :

  • Algorithme d’allocation/récupération de mémoire plus rapide et plus efficace (Parallèle GC, CMS, G1)
  • Evolution du modèle de la gestion de mémoire (mémoire partagée et lock, TLAB, modèle générationnel) et compression des objets (-XX:+UseCompressedOops)
  • Mise à contribution du modèle Multithread pour mieux exploiter les capacités des machines multiprocesseurs
  • Compilation dynamique et gestion d’une mémoire cache pour le code machine
  • Profilage (Profiling) et collecte des statistiques d’exécution pour de meilleures optimisations
  • Multiplication des stratégies et techniques d’optimisation et leurs combinaisons avec la compilation dynamique.

Heureusement, la majorité de ces capacités sont configurables et peuvent être adaptées aux applications java pour répondre à différents besoins. Une phase de réglage (Tuning) est souvent nécessaire, car la configuration par défaut n’est pas toujours optimale. Mais une fois ces capacités bien configurées, elles permettent en général d’actionner des leviers permettant d’aboutir à des optimisations plus au moins agressives.

En effet, au sein de la JVM, en plus de l’interpréteur, deux compilateurs C1 et C2 coexistent. Le C1 est utilisé lorsqu’une faible empreinte mémoire et un démarrage rapide sont requis.  Le C2 est plutôt utilisé pour les applications destinées à fonctionner durant de longues périodes, avec un besoin important en terme de rapidité d’exécution. L’un et l’autre sont obtenus respectivement en activant les options –client et –server au lancement de la JVM, et aboutissent, pour un même code java, à un code machine différent en raison de la différence des techniques utilisées. Mais, depuis les derniers releases JDK 7, les applications java peuvent bénéficier du meilleur des deux compilateurs. La technique porte le nom de « Tired compilation » et permet à la fois :

  • De s’assurer d’un lancement rapide et d’une faible consommation mémoire en faisant intervenir le compilateur C1 au démarrage d’une application.
  • De réaliser des optimisations agressives en cours d’exécution en s’appuyant plus sur le compilateur C2.

La différence entre les deux compilateurs est assez importante. Le C1 utilise peu ou pas les métadonnées d’exécution et, à ce titre, il consomme peu de mémoire et de CPU. Au contraire, le C2 a besoin d’analyser de grandes quantités d’informations issues des statistiques d’exécution, avant d’appliquer des optimisations agressives. Ceci lui vaut une certaine consommation en mémoire et CPU. Selon la documentation officielle, la combinaison des deux compilateurs, dans plusieurs cas de figure, offre de meilleures performances que le mode client ou server pris exclusivement, en raison d’un profilage plus performant des données d’exécution.

La figure ci-dessous, montre que cette capacité est activée par défaut dans le JDK 8. Pour en bénéficier en JDK 7, surtout les dernières versions, il faut l’activer explicitement par le biais du flag -XX:+TieredCompilation.

javac_022

Figure 2 : Jconsole – JVM Summary

Compilation et/ou optimisation ?

Jusque-là nous avons recensé quatre compilateurs : Javac, C0, C1 et C2. Ceci peut suggérer la présence de 4 types de compilation basées chacune sur un ensemble de règles bien définies. Cependant, les choses ne sont pas aussi simples. Dans ce qui suit, nous apprendrons qu’il existe 5 niveaux, rien que pour la compilation dynamique, que certains de ces niveaux sont interconnectés. Nous verrons même les frontières s’estomper entre compilation et optimisation.

Ci-dessous les cinq niveaux de compilation dynamique :

  • Compilation niveau : Elle est réalisée par l’interpréteur de « bytecode ». Ce dernier est simplement transformé en code machine compréhensible et exécutable par le processeur. Toutes les méthodes passent par ce niveau.
  • Compilation niveau 1, 2 et 3 : sont assurés par le compilateur C1. Le niveau 1 occulte toutes les informations issues des statistiques d’exécution. On parle de « full optimization  with no profiling ». Inversement, le niveau 3 s’appuie sur les informations recueillies par le profiler de la JVM et qui constituent les métadonnées des exécutions. Le niveau 2, est un niveau intermédiaire qui utilise deux compteurs. Le premier, appelé « invocation counters » permet de comptabiliser le nombre d’invocations d’une méthode. Le second, appelé « backedge counters » indique le nombre de fois que le pointeur d’exécution est revenu au début d’une boucle.
  • Compilation niveau : est assuré par le compilateur C2.

Toutes les opérations liées à la compilation dynamique se déroulent en arrière-plan. Plus elles sont poussées, plus elles occasionnent un coût en termes de consommation mémoire et de CPU, mais ce coût reste très négligeable par rapport aux gains apportés. Selon une publication d’Eva Andreasson, le gain est un code jusqu’à 10 fois plus performant que celui produit par une compilation C0.

«[…] the overhead is very small for the execution performance improvement gained — five or 10 times better performance than what you would get from pure interpretation (meaning, executing the bytecode as-is, without modification) ».

Java, nous le savons, est un langage dynamique qui permet de varier le comportement au moment de l’exécution. Autrement dit, les différents chemins d’exécution(s) ne peuvent être connus à l’avance et les différents niveaux de compilation existent pour permettent à la JVM de s’adapter à ce contexte d’exécution variant, et d’assurer un fonctionnement optimal. Plus le compilateur JIT (C1 et C2) dispose d’informations sur le contexte d’exécution, plus les optimisations seront adéquates et plus efficaces.

La JVM est devenue assez intelligente pour adapter, à la volée, les stratégies et les règles d’optimisation au contexte d’exécution. C’est ce qui lui vaut d’être qualifiée de JVM « HotSpot“. En monitorant toutes les exécutions, elle marque le code très sollicité. Elle le rend éligible à une compilation niveau 2, 3 ou 4. Effectivement, en mode « TiredCompilation », les frontières entre niveaux de compilation et entre niveaux d’optimisation peuvent bouger presque tout le temps. Elles sont fonctions des données du profilage et des tailles des files de compilation. La compilation niveau 2 est, le plus souvent, utilisée en premier, car 30% plus rapide que celle de niveau 3, elle-même plus rapide que la compilation niveau 4. Mais lorsque suffisamment de données d’exécutions sont disponibles (profilage niveau 0 et 3), pour atteindre de meilleures performances, le « bytecode »  est compilé directement en niveau 3 ou 4.  Les compilations de niveau 4 sont les plus poussées. Elles deviennent privilégiées dès lors que la file de compilation C2 n’est pas trop chargée et que les statistiques d’exécution récoltées après une compilation niveau 3 sont suffisantes.

Le code machine obtenu est stocké en cache pour être réutiliser et gagner encore plus en performances. Dans cette optique, il est analysé et marqué non pertinent (no entrant) quand il le faut. On dit qu’il est dés-optimisé. Les instructions équivalentes repassent alors par la compilation niveau 0,  pour ensuite être recompilées en niveau 2, 3 ou 4.

La JVM « HotSpot » jongle donc avec les niveaux de compilation pour offrir les meilleures performances. Toutes ces compilations/optimisations sont réalisées en parallèle de l’exécution de l’application, de sorte à n’induire presque aucun impact sur les performances globales sinon celui de les améliorer.  Tirée de la littérature Java, La figure ci-dessous, montre les différentes étapes que le « bytecode » peut être amené à suivre durant son cycle de vie :

javac_03

Figure 3 : Cycle de vie du « bytecode »

La consultation des traces de compilation, activable grâce à l’option -XX:+PrintCompilation (-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining pour plus de détail) permet de suivre ces transitions et d’en comprendre les raisons. Un outil existe et permet d’analyser ces « logs » de compilation. Il permet de prendre conscience des techniques d’optimisation utilisées et parfois de se les approprier pour écrire du code plus simple et plus performant. Il s’agit de « JITWatch ».

Le chapitre suivant présente et explique certaines de ces techniques. A titre d’exemple, certaines méthodes ne seront jamais compilées. Elles empêcheront donc une partie du code d’être compilé et optimisé. Nous rencontrons encore malheureusement des méthodes d’une centaine de lignes et plus, que dis-je, des fois des programmes entiers qui tiennent en une seule classe : Connection à la base de données, traçabilité, traitement métier, « parsing », formatage des données en sortie, des boucles et des tests imbriqués à plusieurs niveaux. Tout est emmêlé, au point que même intelligente, la JVM ne peut presque rien y optimiser.

Quelques tactiques et techniques d’optimisation

La JVM est capable de faire des optimisations en s’appuyant sur des techniques et stratégies de performances. Ces techniques sont organisées en plusieurs niveaux, les uns au-dessus des autres, de sorte que l’optimisateur puisse procéder à la technique du niveau supérieur une fois une technique donnée appliquée. Certaines de ces techniques sont agressives et s’appuient sur une collecte assez poussée des statistiques d’exécution. C’est le cas du « Dead code Elimination ». Comme son nom l’indique, le compilateur doit, de façon sûre et sécurisée, détecter des lignes de code qui ne seront jamais exécutées avant de les supprimer. C’est le cas, par exemple, de certains tests imbriqués. D’autres techniques, moins agressives et plus ou moins connues, gagnent à être explicitées. Ceci a participé à changer ma façon d’écrire du code, et très probablement, changera la vôtre.

javac_04

Figure 4: Exemple de code Java

L’exemple ci-dessus est une construction pour illustrer une partie de l’analyse qu’effectue la JVM avant d’optimiser un code java.

Nous le savons, la classe « StringBuffer » est une classe « threadSafe » dédiée à une utilisation en accès concurrent. Dans notre exemple, elle est utilisée en tant que variable locale. Elle ne sera donc jamais accédée par un thread concurrent. En effet, chaque thread crée ses propres variables au sein de la « stack Java », dans un cadre d’exécution ou « frame » qui lui est propre. En se basant sur des analyses similaires ou plus complexes, la JVM va pouvoir procéder à une ou plusieurs optimisations dont certaines sont détaillées ci-dessous :

Lock elision : Nous savons que l’acquisition et la libération de verrous sont des opérations coûteuses et souvent à l’origine de contention et perte de performance, que les verrous sont requis à chaque fois que l’on fait appel à des instructions (méthode, bloc ou objet) synchronisées (Synchronized). Le code ci-dessus montre quatre appels de méthodes qui nécessitent, chacun, l’acquisition et la libération d’un verrou, ce qui revient très cher à l’exécution. Dans notre exemple, ceci est inutile, car l’objet « sBuffer » n’a pas besoin d’être synchronisé. L’optimisation qui consiste à supprimer les appels au moniteur de verrou à chaque fois que ceci est possible porte le nom de « Lock elision ». Une technique assez similaire consiste à fusionner plusieurs blocs « Synchronized » en un seul, quand c’est possible. Il s’agit d’une autre optimisation qui porte le nom de « Lock fusion »

Escape Analyse : Cette technique, activée par défaut depuis le JDK6 u 23, permet au compilateur C2 de procéder à une optimisation assez agressive qui sollicite moins le processus d’allocation de mémoire et la pose de verrou (lock) associé et, du même coup, évite de faire travailler le « garbage collector » GC plus que de raison. L’analyse de l’échappement peut concerner différents cas :

  • Objets stockés dans des champs « static»
  • Objets qui sont des attributs d’un autre objet lui-même sujet à l’échappement
  • Objets crées, retournés ou non à la fin de l’exécution d’une méthode
  • Objets créés à l’intérieur d’une boucle

Plusieurs situations peuvent exister. Elles sont connues sous les noms de « GlobalEscape », « ArgEscape » et « NoEscape » et sont, dans le cadre des optimisations, exploitées pour éviter l’allocation automatique de la mémoire au niveau du tas ou la « Heap ».

Le code ci-dessous montre la création de deux objets « Vehicule » et « Moteur ». Le second est référencé par le premier, mais les deux objets restent cantonnés dans le corps de la méthode. Ils ne sont donc visibles qu’au sein de la « stack » et de la « frame » d’exécution. On dit qu’ils ne sont pas sujets à l’échappement. Habituellement, les nouveaux objets sont créés dans la heap (young génération ou old génération si l’objet est volumineux), mais dans notre cas, l’« escape analyse » permet à la JVM « HostSpot » de shunter ce processus coûteux et de les créer seulement au niveau de la « Stack ». Ceci se traduit automatiquement par une consommation de mémoire moindre et participe à la réduction de la fragmentation de cette dernière.

Monomorphic dispatch : Souvent, au sein d’une méthode, un seul chemin d’exécution  est observé car prédominant. En cours d’exécution, le polymorphisme dû à l’héritage ou à la présence de différentes implémentations, est soit inexistant, soit pas assez fort pour changer fréquemment le comportement de la méthode. Dans ces conditions, et en s’appuyant sur les statistiques d’exécution, la JVM peut décider de ne pas vérifier quelle implémentation ou surcharge de la méthode il faut invoquer. Ce type de contrôle appelé « virtual method lookup » est alors éliminé permettant un gain non négligeable en termes de performance. Selon Ben Evans, membre du comité exécutif Java SE/EE, le JIT est capable, dans ces conditions, de produire du code machine plus performant que celui produit par C++, car ce dernier ne peut pas éliminer aussi facilement ce coût de vérification.

Inlining : Cette technique est parmi les plus connues et les plus utilisées par la compilation JIT. Elle consiste à remplacer, au sein de la méthode appelante, l’appel à une méthode par les instructions qui la constituent. Ceci permet de gagner du temps sur l’appel de méthode (pas de création de frame supplémentaire dans la « Java stack », pas d’appel supplémentaire vers les « pc register » …). Par défaut, cette capacité est activée et toutes les méthodes de tailles inférieures à 35 bits sont « inlinées ». Ceci peut être reconfiguré via le paramètre JVM -XX:MaxInlineSize = 35. A titre d’exemple, les méthodes accesseurs (getX(), setX()) et les petites méthodes « final » sont tout le temps remplacées (inlined).

De façon générale, la compilation et le remplacement de l’appel d’une méthode, au sein de la JVM HotSpot dépend essentiellement des deux compteurs ci-dessous, et dont la somme constitue le flag « CompileThreshold » :

  • Compteur d’invocation de la méthode connu sous le nom de « method entry»
  • Compteur pour le nombre de fois que le code d’une boucle est exécuté connu sous le nom de « back edge loop».

Selon la documentation Oracle, la valeur par défaut de ce paramètre pour une JVM en mode client est de 1500. Il est de 10 000 pour une JVM fonctionnant en mode server. Certaines littératures proposent de paramétrer ce flag en réduisant sa valeur. Réduire la valeur de ce dernier conduit à une compilation plus précoce et inversement. C’est ce qui est fait discrètement lorsque la  « TiredCompilation » est activée.

En pratique, il est possible que certaines méthodes ne soient jamais compilées même si elles sont exécutées fréquemment sur de longue durée. Dans l’absolu, ceci n’est pas grave, mais ce n’est pas du tout le cas lorsque ces méthodes sont critiques pour un programme. L’explication vient du fait qu’elles sont trop volumineuses et excédent la barrière fixée par le flag XX:FreqInlineSize=325. L’utilisation d’outil comme JITWatch, permet d’aller beaucoup plus loin que le simple contrôle « CheckStyle : MethodLengthCheck ». Elle permet non seulement de détecter ces méthodes, mais d’en faire des candidates pour le « refactoring », aboutissant ainsi à un gain en termes de performances. L’outil permet également de recenser les méthodes qui sont dés-optimisées, car elles englobent différents chemins d’exécution. Elles aussi peuvent devenir candidates au « refactoring ».

Conclusion

Dans cet article, nous avons découvert qu’il y avait plusieurs compilateurs, que les différences principales entre compilation statique et dynamique étaient le déroulement de cette dernière en parallèle à l’exécution normale, et que son but était de passer de la simple génération de code machine à la génération de code machine optimisé et performant.

Nous avons aussi commencé esquisser quelques techniques d’optimisation utilisées par la JVM.

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT

Nombre de vue : 2336

AJOUTER UN COMMENTAIRE