Professional Documents
Culture Documents
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,
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
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
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.
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
[...]
Code:
0: return
[...]
Code:
2: astore_0
3: return
[...]
Code:
[...]
5: if_icmpge 17
[...]
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()
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
NoOptimisation en mode interprété uniquement (-Xint), en mode mixte (-Xmixed, le mode par
Hello world
real 0m0.201s
user 0m0.190s
sys 0m0.013s
Hello world
real 0m0.073s
user 0m0.063s
sys 0m0.017s
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
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é.
61 1 NoOptimisation::empty (1 bytes)
[...]
Le format utilisé est le suivant : d’abord, un timestamp indique à quel moment (timestamp) la
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;
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é à
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’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
[...]
La méthode empty de la classe NoOptimisation a été fusionnée car la JVM a détecté qu’elle
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 (-
Reprenons l’exemple de code présenté par Brian Goetz dans la conférence "Towards A
T get();
content = someContent;
return content;
if(h == null)
else
return h.get();
Dans un premier temps, la JVM va fusionner le code de la méthode getFrom() dans la méthode
devenir :
if(holder == 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
return holder.get();
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
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
implique une section critique (bloc synchronized) mais qu’il n’est utilisé que par un seul thread,
accumulator.append('.');
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.
que la JVM a bien supprimé les entrées en section critiques et leurs sorties pour optimiser le
programme.
[...]
développement d’OpenJDK.
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
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 :
Le mode serveur est activé sur tous les serveurs d’applications (Tomcat, Glassfish, JBoss).
est plus rapide que le code interprété. Cependant, c’est une mauvaise idée.
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
é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à.
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 à
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
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
démarrage d’un projet, il est préférable de laisser les réglages par défaut tant que ces derniers
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
Ici, intéressons nous à la manière dont la mémoire est gérée et aux différents Garbage
Collectors.
L’hypothèse générationnelle
Toute la gestion de la mémoire opérée par la JVM se base sur une hypothèse générationnelle,
résumée par la phrase "la plupart des objets meurent jeunes" ou encore "La plupart des objets
L’idée derrière cette hypothèse est que les applications créent de nombreux objets pour leur
fonctionnement, mais que seule une faible partie d’entre eux a une longue durée de vie. Ainsi,
les différentes étapes d’un traitement génèrent beaucoup d’objets transitoires mais une seule
donnée finale.
Le graphique suivant résume la quantité d’objets créés par une application en fonction de leur
âge.
Note : un objet est considéré comme toujours en vie s’il existe un autre objet vivant qui le
référence.
generation qui contient les nouveaux objets et la old generation qui contient les objets ayant
La young generation est elle-même fragmentée en trois espaces distincts : l’Eden dans lequel
tous les objets sont crées et deux zones dites "Survivor" qui servent de sas d’entrée dans la
old generation.
Le cycle de vie de tous les objets commence dans l’Eden. Au premier passage du garbage
collector, si l’objet est toujours vivant il est déplacé dans la zone "Survivor 1". À chaque
prochain passage du GC, il sera déplacé dans l’autre zone "Survivor" jusqu’à un certain seuil.
Prenons l’exemple d’une application avec 2 types d’objets A et B avec un seuil générationnel
de 4. Tous les objets sont crées dans l’Eden et ont un âge de 1. Au premier passage du GC,
seul B (âge : 2) est toujours utilisé, il est donc déplacé dans le Survivor 0 tandis que A1 est
collecté. Au second GC, un nouvel objet A2 (âge 1) est crée, B (âge : 3) est toujours utilisé et
est déplacé dans Survivor 1. Au troisième passage du GC, A2 (âge : 2) et B (âge : 4) sont
toujours vivants et donc déplacés dans Survivor 0. Enfin, au cinquième GC, l’age de B (âge :
5) dépasse le seuil générationnel, B est donc transféré dans la old generation tandis que A2
Deux types de collections ont été implémentés d’après l’hypothèse générationnelle : les
collections mineures qui récupèrent la mémoire dans la young generation et les collections
majeures qui récupèrent la mémoire dans la old generation. On confond parfois collection
majeure et Full GC, ce dernier terme indique une collection dans la young et la old generation.
Par application de l’hypothèse, les collections mineures sont celles qui arriveront le plus
souvent. Les espaces mémoire et le garbage collector sont donc spécialement optimisés pour
Les collections majeures sont bien plus coûteuses, car elles doivent scanner l’intégralité de la
mémoire pour déterminer si les objets de la old generation sont toujours vivants.
Les pauses du GC
Les algorithmes de GC imposent parfois une pause de l’application (Stop The World). Dans
ce cas de figure, le garbage collector fige tour à tour chacun des threads de l’application
lorsqu’ils atteignent un point d’arrêt. Cette première étape peut déjà prendre un certain temps,
en effet, le GC ne peut pas figer tous les threads en une seule fois tant que ces derniers ne
sont pas dans un état cohérent. Ensuite, le GC élimine les objets non utilisés et redémarre
possible, cet objectif est atteint lorsque l’application passe moins de 5% de son temps figée à
cause du GC.
La old generation n’existe que pour optimiser le travail du Garbage Collector et diminuer les
pauses éventuelles de l’application. Lorsque l’âge d’un objet a dépassé un certain seuil, on
peut affirmer que cet objet constitue l’exception de l’hypothèse générationnelle : sa durée de
vie sera bien plus longue que la majorité des objets de l’application. Il n’est alors plus utile de
le scanner pour déterminer s’il est un objet à faible durée de vie, cela ne ferait que ralentir le
Les logs
Pour pouvoir tuner une JVM, il est nécessaire d’avoir les logs du garbage collector. Ces
Les logs peuvent être activés par l’option -Xloggc:logs/gc.log qui crée un fichier gc.log dans le
dossier logs/. Il est également recommandé d’ajouter des informations complémentaires avec
des détails sur la manière dont la mémoire est utilisée et permettent d’identifier très facilement
La lecture des logs peut-être très fastidieuse sans les outils appropriés. Il est possible d’utiliser
(payant, www.jclarity.com/products/censum/).
Il existe deux stratégies pour le nettoyage de la mémoire : Mark & Evacuate et Mark, Sweep
& Compact.
Dans la stratégie Mark & Evacuate, le GC travaille en deux phases : dans un premier temps,
tous les objets vivants de l’application sont marqués. Ces mêmes objets sont ensuite recopiés
dans une nouvelle zone mémoire et les pointeurs sont réécrits pour référencer les nouvelles
adresses.
Le principal avantage de cette stratégie est que la mémoire n’est pas fragmentée. En effet, la
zone nettoyée peut être considérée comme vide à l’issue de la copie puisque les objets vivants
ont été "déplacés". L’inconvénient de cette stratégie est qu’elle est plus coûteuse en mémoire
à cause de la copie des objets. Cette copie nécessite du temps et au moins autant d’espace
que d’objets vivants, elle est donc réservée à des espaces à taille raisonnable. Son principal
avantage est que son temps d’exécution est proportionnel au nombre d’objets vivants.
C’est cette stratégie qui est utilisée dans la young generation et qui fait le passage des objets
depuis l’Eden vers un Survivor, puis d’un Survivor à l’autre et enfin d’un Survivor vers la old
generation.
Dans la stratégie Mark, Sweep & Compact, en revanche, ces inconvénients n’existent pas. Le
GC travaille en trois temps : dans le premier, il marque les objets vivants. Les objets morts
sont ensuite supprimés, ce qui a pour effet de fragmenter la mémoire. La mémoire devra alors
L’avantage de cette stratégie est donc la vitesse d’exécution des deux premières phases et la
Plusieurs garbage collectors ont été développés pour permettre la récupération de la mémoire
dans différents contextes. On distingue les GC qui travaillent dans la young generation de
ceux qui travaillent dans la old generation. Dans cet article, intéressons nous aux GC les plus
(old).
exécution particulièrement longue sur les heap de plusieurs giga-octets. Les GC "Parallel"
Les GC "Parallel" font partie de la catégorie "Throughput". Leur objectif est de récupérer le
plus de mémoire possible en un minimum de temps. Ils sont utilisés dans des applications
sans contrainte "temps-réel" car les pauses peuvent être assez longues (plusieurs secondes).
Ces garbage collectors offrent le meilleur ratio entre overhead et mémoire libérée.
dernier suit la stratégie Mark & Evacuate et a donc un temps d’exécution proportionnel au
nombre d’objets vivants dans la young generation. Dans une JVM correctement tunée, avec
une heap de quelques Go, les temps de pause se comptent en dizaines de millisecondes.
Celui-ci suit la stratégie Mark, Sweep & Compact et a donc un temps d’exécution plus long car
proportionnel à la taille de la heap. Dans une JVM correctement tunée, pour une heap de
quelques Go, les pauses du ParallelOldGC peuvent prendre plusieurs secondes. Ces temps
de Go.
Il est possible de spécifier le nombre de threads utilisables par les GC parallèles via l’option
Les garbage collectors parallels sont particulièrement inadaptés aux problématiques temps
réel. Par exemple, sur des sites à très fort traffic comme Twitter, des pauses de plusieurs
secondes ne seraient pas acceptables. Deux garbage collectors ont donc été ajoutés : CMS
(Concurrent Mark & Sweep) et ParNew. Ils permettent de minimiser les temps de pause, mais
dernier est relativement semblable au ParallelGC (stratégie Mark & Evacuate, temps
plus important que ce dernier. La principale caractéristique de ParNew est qu’il est compatible
avec CMS et qu’il peut lui envoyer des informations sur l’utilisation de la young generation,
chose que le ParallelGC ne peut pas faire. Les pauses de ParNew sont bloquantes et sont de
revanche, l’exécution de CMS ne bloque pas les threads de la JVM, elle se fait pendant
CMS suit la stratégie Mark, Sweep & Compact mais n’effectue la compaction qu’en dernier
recours, c’est à dire lorsque la heap est tellement fragmentée qu’un objet de la young
generation ne peut pas être déplacé dans la old generation car il n’existe pas de bloc contigu
suffisamment grand pour l’y acueillir. Dans ce cas de figure, on parle alors de promotion failure
CMS lorsque l’utilisation de la young generation dépasse un certain seuil, donné par l’option
Le GC Garbage First
Un nouveau garbage collector baptisé G1 (Garbage First) a été introduit en version béta dans
La force de G1 tient dans les objectifs qui peuvent lui être définis. Par exemple, si l’on souhaite
G1 se déclenchera plus souvent pour pouvoir libérer de la mémoire et cela aura donc un effet
remplie, la JVM déclenche un Full GC parallèle qui est bloquant pour nettoyer toute la heap.
Lorsque l’on souhaite affiner le comportement de la JVM, il est important de définir des
métriques qui vont permettre de mesurer l’état d’une application en fonction du temps. Les
Temps passé dans le GC sur toute la durée de l’application en pourcentage. Lorsque cet
indicateur est supérieur à 5%, cela signifie que des actions peuvent être menées sur la heap,
Temps passé dans le GC par minute (minimum, moyenne et maximum). Cet indicateur
fonctionne sur le même principe que le précédent mais indique en plus la durée maximale de
Fréquence de lancement des GC. Lorsque cet indicateur est élevé, il faut analyser la quantité
de la heap
Nombre de Full GC. Les full GC bloquent l’application, il faut donc faire en sorte d’avoir un
Il est assez fréquent de voir des applications sur lesquelles les logs GC ne sont pas activés
par peur que cela allonge le temps de collection. Ces craintes sont généralement toujours
infondées car la quantité d’information écrite par le GC dans son log est très faible (de l’ordre
connaître les informations qui permettront de tuner efficacement une JVM, comme par
L’un des paramétrages fréquemment constatés dans une JVM est le pattern Xms=Xmx (ou sa
variante ms=mx). Il permet d’allouer toute la mémoire dont la JVM aura besoin à son
pattern est que cela permet de s’affranchir d’appels systèmes malloc et free, qui sont coûteux,
Plus la heap est volumineuse, plus les temps d’exécution du garbage collector vont être
importants. La réduction de la taille de la heap peut être une stratégie utilisée par la JVM pour
heap n’est pas modifiable, cette stratégie ne peut pas être implémentée.
que lors de full GC, c’est à dire que lorsque l’application est déjà en pause. Or une JVM
correctement tunée passe moins de 5% de son temps dans des full GC. En résumé, ce pattern
tente d’accélérer une portion marginale de l’exécution d’une application, tout en entraînant une
On tombe parfois sur des appels à System.gc() ou Runtime.gc() dans certaines bases de code.
L’idée est alors de forcer le démarrage du garbage collector plutôt que de le subir.
Cette approche a plusieurs inconvénients. Tout d’abord, d’après la spécification Java, un appel
donc pas nécessairement partir du postulat "Un appel à System.gc() lance le garbage
collector". Dans OpenJDK, cette méthode déclenche effectivement le garbage collector, mais
Mais le véritable problème est que c’est un full GC qui est déclenché, ce qui provoquera une
pause dans l’application pour aller collecter des objets dans toute la heap (young et old
Si le traitement précédant l’appel à System.gc() a produit des objets à faible durée de vie, alors
ces derniers ont probablement déjà été collectés par un young GC, ce qui veut dire que nous
allons collecter toute la heap pour récupérer des objets déjà éliminés. Dans le cas contraire,
rien ne nous garantit qu’un Full GC n’a pas déjà été exécuté, on aboutit alors à deux full GC
Il est possible de désactiver totalement la méthode System.gc() sur toute une JVM par le
paramètre -XX:+DisableExplicitGC, mais ceci ne fonctionne que sur OpenJDK et n’est qu’une
solution de contournement.
Lorsque l’on paramètre une JVM, on essaye d’obtenir le moins de Full GC possible, et les
Conclusion
La JVM, dans son paramétrage par défaut, tente de s’adapter le plus possible au profil de
JVM, il faut s’assurer de comprendre la situation courante et d’avoir les informations requises
pour pouvoir justifier qu’un paramétrage différent donnera de meilleurs résultats. Pour cela, il
Dans cet article, nous avons pu voir que la gestion de la mémoire est un sujet très complexe
et passionnant. Les ressources sont nombreuses sur le sujet et il ne faut pas hésiter à