Il y a 10 ans -
Temps de lecture 14 minutes
Comprendre le fonctionnement de la JVM – Article 1/2
Origines
Depuis 1996, Java et la JVM ont envahi nos équipements pour devenir des éléments incontournables de notre quotidien. Avant de s’intéresser aux détails et aux forces de la JVM, il est important de comprendre la relation entre le langage Java et cette dernière.
Au démarrage, Java se voulait un langage multi-plateformes, principalement guidé par le principe WORA : "Write Once, Run Anywhere". La JVM ne comportait qu’un nombre limité d’optimisations par rapport à aujourd’hui, et Java était critiqué à raison pour sa lenteur. C’est en 2000 que le langage a bénéficié d’un second souffle avec l’intégration de Hotspot dans la JVM 1.3.
La technologie Hotspot, quant à elle, est motivée par une toute autre problématique : concevoir une technologie qui maintienne l’aspect multi-plateformes de Java tout en profitant des optimisations de chaque machine (type de CPU, architecture matérielle).
Java est le langage dans lequel nous développons nos applications, Hotspot est le support par lequel nos applications sont de plus en plus optimisées à chaque nouvelle version de la JVM.
Concepts majeurs
Chaque application Java est constituée de fichiers source (.java) qui sont compilés en bytecode. Ce bytecode est ensuite utilisé par la JVM et transformé en code natif.
Grace à cette séparation entre le langage et la plateforme d’exécution, nous tirons aujourd’hui de nombreux bénéfices : les applications Java sont de plus en plus optimisées sans avoir à être recompilées, d’anciens langages ont été portés sur la JVM pour pouvoir profiter de ces mêmes optimisations (Jython, JRuby) et de nouveaux langages ont émergé pour combler les lacunes de Java (Clojure, Scala).
La JVM s’occupe de la gestion de la mémoire en segmentant la heap en plusieurs sections : la "Young Generation" est l’espace dans lequel tous les objets sont crées; la "Old Generation", quant à elle, contient les objets ayant une longue durée de vie. Il existe deux autres zones mémoire que sont la "Permanent Generation" qui contient la définition des classes de l’application et le "Code Cache" qui est l’espace dans lequel la JVM stocke et manipule le code optimisé.
Tout au long de l’exécution d’une application, la JVM maintient des compteurs qui lui permettent de détecter le code souvent exécuté. C’est sur ce code que les optimisations vont être le plus utiles et c’est donc sur celui-ci que la JVM travaille (Just-In-Time Optimisation).
Le cycle d’exécution d’un fragment de code commence donc toujours en mode interprété. Lorsqu’il est détecté par le profiler, il est ensuite passé à l’optimiseur avec des données complémentaires sur son exécution (nombre d’appels, code appelant, …). Une fois que l’optimiseur a appliqué des transformations sur ce code, il stocke le résultat dans le "Code Cache".
Le compilateur javac optimise-t-il le code ?
Pour éviter toute optimisation inutile, le compilateur javac n’effectue presque aucune opération sur le code source Java. Prenons l’exemple d’un "Hello world !" dans lequel on a ajouté du code mort.
public final class NoOptimisation { private final static void empty() {} private final static void dead() { String bar = "Bar"; } public static void main(String... args) throws Exception { for(int i=0; i<10_000_000; i++) empty(); System.out.println("Hello world"); } }
Si l’on compile cette classe et que l’on analyse le bytecode produit, on constate que la méthode empty() a été compilée en l’état, qu’elle est toujours appelée depuis la méthode main(), et que la méthode dead() et la variable bar sont également présentes dans le bytecode.
$ javap -c -p NoOptimisation [...] private static void empty(); Code: 0: return [...] private static void dead(); Code: 0: ldc #2 // String Bar 2: astore_0 3: return [...] public static void main(java.lang.String...); Code: [...] 3: ldc #6 // int 10000000 5: if_icmpge 17 8: invokestatic #4 // Method empty:()V [...]
Ce comportement est normal : la quasi totalité des optimisations sur le code sont effectuées par Hotspot, durant l’exécution de l’application, si et seulement si le code est souvent utilisé. Dans le cas présent, la méthode dead() ne sera jamais supprimée car elle n’est jamais utilisée, sa suppression serait donc une perte de temps. La boucle d’appels de la méthode empty() sera quant à elle bien supprimée après un certain nombre d’itérations.
Temps de chauffe
Au démarrage d’une application, l’intégralité du bytecode est interprété par la JVM. La JVM entre alors dans une phase de chauffe, durant laquelle elle optimise les fragments de code fréquemment utilisés jusqu’à ce qu’elle atteigne un point d’équilibre permettant à l’application de fonctionner à vitesse maximale.
Pour bien se rendre compte de ce temps de chauffe, on peut exécuter la classe NoOptimisation en mode interprété uniquement (-Xint), en mode mixte (-Xmixed, le mode par défaut) et en mode compilé (-Xcomp) uniquement en comparant les temps d’exécution.
$ time java -client -Xint NoOptimisation Hello world real 0m0.201s user 0m0.190s sys 0m0.013s $ time java -client -Xmixed NoOptimisation Hello world real 0m0.073s user 0m0.063s sys 0m0.017s $ time java -client -Xcomp NoOptimisation Hello world real 0m1.647s user 0m1.643s sys 0m0.033s
On voit que le mode interprété est plus long que le mode mixte, ce qui est logique.
Cependant, on remarque aussi que le mode compilé est considérablement plus long que le mode mixte. Ceci est normal : dans ce mode, la moindre méthode invoquée se retrouve compilée, même si elle n’est appelée qu’une seule fois, ce qui est inutile.
Le mode mixte de la JVM est donc le mode qui permet d’obtenir les meilleures performances le plus rapidement possible.
Compilation du code
La JVM peut le compiler les fragments de code "hot" en code natif. Cette compilation se fait alors en utilisant tout le jeu d’instructions du processeur sur lequel l’application s’exécute.
Dans ce cas de figure, le code compilé est stocké dans le Code Cache et tous les pointeurs vers ce code sont réécrits pour référencer la version optimisée. S’il s’agit d’une compilation d’un bloc de code particulier dans une méthode, par exemple une boucle, on parle alors de "On Stack Replacement (OSR)", la JVM modifie la pile d’instructions de la méthode pour que le prochain GOTO mène sur le code compilé et non plus sur le code interprété.
On peut voir la JVM à l’oeuvre en activant les logs de compilation (-XX:+PrintCompilation).
$ java -client -XX:+PrintCompilation NoOptimisation 61 1 NoOptimisation::empty (1 bytes) 61 1 % NoOptimisation::main @ 2 (26 bytes) 71 1 % NoOptimisation::main @ -2 (26 bytes) made not entrant [...]
Le format utilisé est le suivant : d’abord, un timestamp indique à quel moment (timestamp) la compilation a eu lieu. Ici, les compilations ont eu lieu à 61ms et 71ms.
Ensuite, un nombre entier indique l’id de compilation associé à l’optimisation. Il existe deux compteurs distincts, le premier pour les compilations de méthodes et le second pour les compilations OSR, c’est pourquoi les deux premières lignes portent le même id.
Le champ suivant est une série de caractères donnant des indications sur la nature de la compilation.
b La compilation a bloqué l'exécution du programme; * La compilation a donné lieu à un wrapper en code natif; % La compilation a donné lieu à un OSR; ! La méthode compilée peut lever des exceptions; s La méthode compilée est synchronisée; n La méthode est déclarée comme du code natif.
On peut donc lire que la méthode empty() a été compilée à 61ms et qu’elle représente un seul octet de code. On voit également qu’un bloc de la méthode main a été compilé et remplacé à la volée (OSR) à 61ms.
Enfin, la troisième ligne nous indique que la JVM a détecté à 71ms qu’une de ses optimisations a produit un code incorrect. Elle a désoptimisé ce code et est revenue en mode interprété. Le bloc optimisé a été marqué comme not-entrant, ce qui veut dire qu’il ne peut plus être exécuté. Comme il s’agit d’un retour en arrière, la JVM utilise l’id de compilation de l’optimisation annulée. Ceci explique pourquoi la troisième ligne a l’id 1 : elle concerne l’optimisation #1 faite à la ligne 2.
Fusion et remodelage de code
L’une des optimisations effectuées par la JVM consiste à remplacer l’appel d’une méthode par le corps de cette méthode (inlining). Dans notre cas, l’appel de la méthode empty() peut être fusionné dans la méthode main(). Et c’est exactement ce que fait la JVM. On peut s’en rendre compte en affichant les logs d’inlining (-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining).
$ java -client -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining NoOptimisation @ 8 NoOptimisation::empty (1 bytes) inline (hot) [...]
La méthode empty de la classe NoOptimisation a été fusionnée car la JVM a détecté qu’elle était souvent appelée.
La taille des méthodes est un facteur très impactant pour la fusion de code. Si une méthode dépasse la taille autorisée (-XX:MaxInlineSize=), elle ne sera pas fusionnée dans d’autres blocs de code. À l’opposé, si une méthode est considérée comme triviales, très courtes (-XX:MaxTrivialSize=), elle sera presque immédiatement fusionnée dans le code appelant. Typiquement, les getters et setters sont toujours très rapidement fusionnés.
La fusion de code par la JVM permet de rendre de nouvelles optimisations possibles. Reprenons l’exemple de code présenté par Brian Goetz dans la conférence "Towards A Universal VM" (Devoxx 2009).
public interface Holder<T> { T get(); } public class MyHolder<T> implements Holder<T> { private final T content; public MyHolder(T someContent) { content = someContent; } @Override public T get() { return content; } } public String getFrom(Holder<String> h) { if(h == null) throw new IllegalArgumentException("h cannot be null"); else return h.get(); } public String doSomething() { MyHolder<String> holder = new MyHolder<>("Hello World"); return getFrom(holder); }
Analysons comment la méthode doSomething() va être optimisée.
Dans un premier temps, la JVM va fusionner le code de la méthode getFrom() dans la méthode doSomething() car la méthode getFrom() est courte. La méthode doSomething va donc devenir :
public String doSomething() { MyHolder<String> holder = new MyHolder<>("Hello World"); if(holder == null) throw new IllegalArgumentException("h cannot be null"); else return holder.get(); }
Grace à cette fusion, on constate que la branche if(holder == null) ne pourra jamais être exécutée car la variable holder est toujours initialisée. Cette branche est devenue du code mort, la JVM va donc la supprimer et aboutir au code suivant :
public String doSomething() { MyHolder<String> holder = new MyHolder<>("Hello World"); return holder.get(); }
La méthode getFrom() référençait l’interface Holder, ce qui obligeait la JVM à effectuer l’invocation de la méthode get() à travers l’interface. À présent la JVM sait qu’elle a affaire à un type concret et peut donc remplacer le code de la méthode get() par son contenu, ce qui donne le pseudo-code suivant :
public String doSomething() { MyHolder<String> holder = new MyHolder<>("Hello World"); return holder.content; }
Enfin, à travers l’analyse avancée du bytecode (Escape Analysis), la JVM va remarquer que la référence someContent est conservée en l’état dans la variable content, que cette dernière est une référence finale et qu’elle n’est jamais modifiée. Elle va donc pouvoir remplacer l’appel au constructeur de MyHolder et arriver au code suivant :
public String doSomething() { return "Hello world"; }
Suppression des locks inutiles
La JVM procède aussi à un nettoyage du code synchronisé. Lorsqu’un fragment de code implique une section critique (bloc synchronized) mais qu’il n’est utilisé que par un seul thread, la Hotspot peut modifier la nature du verrou utilisé, voire le supprimer complètement.
public class LockRemoval { private static void run(int times) { StringBuffer accumulator = new StringBuffer(); for(int i=0; i<times; i++) accumulator.append('.'); System.out.println("Added " + accumulator.length()); } public static void main(String[] args) { int loopCount = Integer.parseInt(args[0]); int times = Integer.parseInt(args[1]); for(int i=0; i<times; i++) run(loopCount); } }
Dans cet exemple se cachent de nombreux locks. En effet, la classe StringBuffer est synchronisée pour pouvoir être partagée entre plusieurs threads. Dans notre cas, c’est inutile et il aurait été préférable d’utiliser la classe StringBuilder qui n’est pas synchronisée.
Si l’on exécute le programme avec l’élimination de locks (-XX:+EliminateLocks), on constate que la JVM a bien supprimé les entrées en section critiques et leurs sorties pour optimiser le programme.
$ java -XX:+EliminateLocks -XX:+PrintEliminateLocks LockRemoval 10000000 8 ++++ Eliminated: 623 Unlock ++++ Eliminated: 612 Unlock ++++ Eliminated: 483 Lock ++++ Eliminated: 234 Unlock ++++ Eliminated: 217 Lock [...]
Note : le paramètre -XX:+PrintEliminateLocks n’est disponible que sur une version de développement d’OpenJDK.
Les deux modes de la JVM
La JVM dispose de deux modes de fonctionnement adaptés à des applications bien différentes.
Le premier mode est le mode "client", aussi nommé "C1". Il est utilisé pour les applications desktop, ayant une faible durée de vie (comptée en heures). Dans ce mode, la JVM démarre plus rapidement mais n’effectue que peu d’optimisations sur le code et a pour objectif de ne pas utiliser beaucoup de ressources matérielles.
Le mode client est utilisé pour les applications desktop (NetBeans, IntelliJ, SQL Developer, …).
Le deuxième mode est le mode "server", aussi appelé "C2" ou "opto". Il est utilisé sur les serveurs, pour lesquels la durée de vie est bien plus longue (comptée en jours). Dans ce mode, la JVM démarre plus lentement compte tenu des optimisations effectuées pour atteindre le point d’équilibre. La limitation sur les ressources matérielles du mode client n’existe plus ici : toutes les optimisations possibles sont activées.
Le mode serveur est activé sur tous les serveurs d’applications (Tomcat, Glassfish, JBoss).
Pour aller plus loin : quelques pièges à éviter
Piège 1 : vouloir tout compiler
On pourrait avoir envie de compiler toute une application, puisque, par définition, le code natif est plus rapide que le code interprété. Cependant, c’est une mauvaise idée.
Que ce soit par le paramètre -Xcomp ou le paramètre -XX:CompileThreshold=1, toutes les méthodes seront compilées avant d’être exécutées et même fusionnées. Problème : lorsque le Code Cache est plein, la JVM peut tenter, en dernier recours, d’éliminer du code natif qui n’est plus utile (-XX:+UseCodeCacheFlushing). Si toutefois cette tentative se solde par un échec, la JVM cesse d’optimiser le code. Les threads de compilation sont arrêtés et ne peuvent pas être redémarrés. La JVM fonctionne alors sur ce qu’elle a pu optimiser jusque là.
Piège 2 : forcer des paramètres de la JVM sans certitude
Lorsque l’on fait un tuning de la JVM, il faut s’assurer que ce tuning améliore réellement la situation et pouvoir expliquer ses choix. Il faut donc sans cesse mesurer les performances de son application pour vérifier qu’à chaque nouvelle version, le tuning apporte toujours un gain de performances.
Appliquer un ensemble de paramètres à une JVM sans avoir prouvé leur utilité revient à appliquer des solutions à un problème qui n’est pas diagnostiqué.
Piège 3 : optimiser prématurément son code
On l’a vu, la JVM modifie profondément le code qu’elle exécute. On tombe parfois sur des méthodes rendues illisibles pour des raisons de performance. Or à chaque nouvelle mise à jour, la JVM se dote d’améliorations qui rendent ces astuces obsolètes, voire nuisibles.
Un bytecode optimisé ne ressemble pas au source Java dont il est issu. Il vaut mieux écrire un code maintenable, éviter les optimisations prématurées et laisser la JVM s’occuper de rendre le code plus véloce.
Conclusion
Lorsque l’on paramètre une JVM, il faut toujours appliquer la règle "Diagnostiquer avant de soigner", c’est à dire prouver l’existence d’un problème et prouver qu’il sera résolu par un paramètrage manuel avant d’appliquer ce dernier. Il faut également vérifier après chaque mise à jour de l’application ou de la JVM que ce paramétrage profite toujours au projet. Au démarrage d’un projet, il est préférable de laisser les réglages par défaut tant que ces derniers n’ont pas montré leurs limites.
La JVM est une plateforme très évoluée qui ne prend des décisions que lorsqu’elles sont justifiées par un contexte d’exécution. À chaque mise à jour, elle devient de plus en plus performante et c’est ce qui en fait une plateforme incontournable aujourd’hui.
Commentaire
8 réponses pour " Comprendre le fonctionnement de la JVM – Article 1/2 "
Published by brice , Il y a 10 ans
Bonjour, y a une légère erreur : » En effet, la classe StringBuilder est synchronisée », en fait c’est StringBuffer. Le code est bon par contre!
Bel article.
Published by Walid CHERGUI , Il y a 10 ans
je pense que c’est plutôt l’inverse StringBuilder n’est pas synchronisée , StringBuffer si ,sinon très bon article , j’attend le 2/2 ;).
Published by Vladislav Pernin , Il y a 10 ans
Un petit mot sur le mode tiered compilation pour compléter client et server ?
Published by Pierre Laporte , Il y a 10 ans
@Brice et Walid : Effectivement, j’ai écrit « StringBuilder » à la place de « StringBuffer ». Je vais corriger ça
@Vladislav : À la base, cet article est paru dans le magazine « Programmez ! » et donc la place était limitée. J’ai préféré ne pas évoquer -XX:+TieredCompilation vu que le mode n’est pas encore totalement au point.
Published by Vladislav Pernin , Il y a 10 ans
De mémoire, tiered compilation est par défaut à partir du JDK 1.7
Published by Pierre Laporte , Il y a 10 ans
Je ne crois pas, en tout cas, je n’ai pas ce comportement avec la 1.7.0_21 d’OpenJDK ni avec la 1.7.0_11 du JDK d’Oracle.
$ java -XX:+PrintFlagsFinal -version | grep TieredCompilation
bool TieredCompilation = false {pd product}
java version « 1.7.0_21 »
OpenJDK Runtime Environment (IcedTea 2.3.9) (ArchLinux build 7.u21_2.3.9-4-x86_64)
OpenJDK 64-Bit Server VM (build 23.7-b01, mixed mode)
Published by Vladislav Pernin , Il y a 10 ans
T’as raison, pareil pour moi !
la documentation est mensongère :
http://docs.oracle.com/javase/7/docs/technotes/guides/vm/performance-enhancements-7.html#tieredcompilation
« Tiered compilation is now the default mode for the server VM. Both 32 and 64 bit modes are supported »
En fait, l’option a du être activée sur les releases mineures du début (ex: 7u4) puis désactivée suite à des remontées d’utilisateurs rencontrant des problèmes.
Des bugs … fermés en parlent http://bugs.sun.com/view_bug.do?bug_id=7159766
A suivre …
Published by Christophe Domas , Il y a 10 ans
La phase de warmup d’une appli avant d’obtenir de bonnes performances grâce à la compilation à la volée est de par sa nature un peu longue (le temps de récolter les stats), surtout pour des softs qui font tous les jours la même chose (ceux qui connaissent le film « le jour de la marmotte » sauront de quoi je parle).
Il existe une JEP pour cacher le code natif produit lors d’un précédent run (JEP 145: Cache Compiled Code – http://openjdk.java.net/jeps/145).
Est-ce que quelqu’un sait si s’il y a des avancées sur ce sujet? S’il y a une roadmap définie? un jdk targeté?