Il y a 10 ans -
Temps de lecture 16 minutes
Comprendre le fonctionnement de la JVM – Article 2/2
Dans le premier article de cette série, nous avons vu comment la JVM optimise notre code. 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 créés deviennent très rapidement inaccessibles".
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.
Les espaces mémoire utilisables
Dans cette optique, la mémoire utilisable de la JVM est fragmentée en deux zones : la young generation qui contient les nouveaux objets et la old generation qui contient les objets ayant une longue durée de vie.
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. Au delà, il sera déplacé dans la old generation.
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 (âge: 3) passe dans le Survivor 1.
Les collections
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 que celles-ci durent le moins de temps possible.
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 tous les threads de l’application.
L’un des objectifs d’une JVM correctement paramétrée est d’avoir le moins de Full GC 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 travail du GC dans l’Eden.
Les logs
Pour pouvoir tuner une JVM, il est nécessaire d’avoir les logs du garbage collector. Ces derniers contiennent énormément d’informations et permettent de prendre des décisions éclairées et de mesurer les progrès effectués.
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 les options -XX:+PrintGCDetails et -XX:+PrintTenuringDistribution. Ces deux options donnent des détails sur la manière dont la mémoire est utilisée et permettent d’identifier très facilement les problèmes de dimensionnement de la heap.
La lecture des logs peut-être très fastidieuse sans les outils appropriés. Il est possible d’utiliser HPJmeter (gratuit, www.hp.com/go/hpjmeter), GCViewer (gratuit et open-source, https://github.com/chewiebug/GCViewer) ou JClarity Censum (payant, www.jclarity.com/products/censum/).
Les types de collections
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 être compactée pour que l’espace libéré forme un bloc contigu.
L’avantage de cette stratégie est donc la vitesse d’exécution des deux premières phases et la moindre consommation mémoire. L’inconvénient est que la phase de défragmentation est quant à elle très coûteuse en temps.
Le choix d’un Garbage Collector
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 fréquemment utilisés : ParallelGC (young) + ParallelOldGC (old) et ParNew (young) + CMS (old).
Les GC Parallel et ParallelOld
Initialement, le garbage collector de la JVM n’utilisait qu’un seul thread, ce qui rendait son exécution particulièrement longue sur les heap de plusieurs giga-octets. Les GC "Parallel" permettent de régler ce problème en créant un nombre de threads proportionnel au nombre de cores disponibles sur le serveur.
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). On les active, par exemple, sur les traitements batchs.
Ces garbage collectors offrent le meilleur ratio entre overhead et mémoire libérée.
Dans la young generation, on peut activer le GC Parallel via l’option -XX:+UseParallelGC. Ce 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.
Dans la old generation, on peut activer le GC Parallel via l’option -XX:+UseParallelOldGC. 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 pauses augmentent considérablement lorsque la taille de la heap se compte en dizaines de Go.
Il est possible de spécifier le nombre de threads utilisables par les GC parallèles via l’option ParallelGCThreads (par exemple, pour 8 threads, -XX:ParallelGCThreads=8).
Les GC ParNew et CMS
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 impliquent un overhead plus important.
Ces deux garbage collectors offrent le meilleur ratio entre temps de pause et mémoire libérée.
Dans la young generation, on peut activer le GC ParNew via l’option -XX:+UseParNewGC. Ce dernier est relativement semblable au ParallelGC (stratégie Mark & Evacuate, temps d’exécution proportionnel au nombre d’objets vivants) à la différence près qu’il a un overhead 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 l’ordre de la milliseconde dans une JVM correctement tunée.
Dans la old generation, on peut activer le GC CMS via l’option -XX:+UseConcMarkSweepGC. Comme pour ParallelOldGC, il a un temps d’exécution proportionnel à la taille de la heap. En revanche, l’exécution de CMS ne bloque pas les threads de la JVM, elle se fait pendant l’exécution de l’application et partage donc le temps CPU avec cette dernière.
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 et la JVM déclenche un Full GC parallèle (bloquant) pour compacter la heap.
Grâce à la communication entre ParNew et CMS, il est possible de déclencher un cycle de CMS lorsque l’utilisation de la young generation dépasse un certain seuil, donné par l’option InitiatingHeapOccupancyPercent en pourcentage (valeur par défaut : 45%).
Le GC Garbage First
Un nouveau garbage collector baptisé G1 (Garbage First) a été introduit en version béta dans la JVM 1.6 et est considéré stable depuis la JVM 1.7.
La force de G1 tient dans les objectifs qui peuvent lui être définis. Par exemple, si l’on souhaite qu’aucune pause ne dépasse 300ms, il suffit de spécifier l’option -XX:MaxGCPauseMillis=300 pour que les cycles de G1 s’arrêtent une fois ce délai dépassé.
Attention toutefois à ne pas spécifier une limite trop basse. Si la durée maximale est trop faible, G1 se déclenchera plus souvent pour pouvoir libérer de la mémoire et cela aura donc un effet contre-productif. Si malgré des déclenchements plus fréquents, la heap est complètement remplie, la JVM déclenche un Full GC parallèle qui est bloquant pour nettoyer toute la heap.
Objectifs de tuning et indicateurs
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 indicateurs les plus souvent utilisés sont :
-
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, le garbage collector ou le code de l’application.
-
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 pause que l’application a subi
-
Fréquence de lancement des GC. Lorsque cet indicateur est élevé, il faut analyser la quantité de mémoire libérée à chaque GC pour déterminer un éventuel problème de dimensionnement de la heap
-
Nombre de Full GC. Les full GC bloquent l’application, il faut donc faire en sorte d’avoir un nombre de Full GC proche de 0.
Piège 1 : Ne pas activer les logs GC
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 d’un ou deux tweets).
Le vrai problème que cause ce piège est que sans les logs du GC, il est impossible de connaître les informations qui permettront de tuner efficacement une JVM, comme par exemple l’utilisation des différents espaces, ou la vitesse de création des objets.
Piège 2 : Fixer au démarrage la taille de la heap
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 démarrage et de ne jamais restituer cette mémoire à l’OS. La justification habituelle de ce pattern est que cela permet de s’affranchir d’appels systèmes malloc et free, qui sont coûteux, et donc d’améliorer les performances de la JVM.
Cependant, ce paramétrage est rarement une bonne idée.
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 améliorer les performances de l’application en diminuant les pauses du GC, or si la taille de la heap n’est pas modifiable, cette stratégie ne peut pas être implémentée.
Il faut également savoir que l’augmentation et la diminution de la taille de la heap ne se font 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 diminution des performances globales.
Piège 3 : libérer la mémoire avec System.gc()
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 à System.gc() n’entraîne pas nécessairement le démarrage du Garbage Collector. Il ne faut 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 il ne s’agit que d’un détail d’implémentation.
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 generation, éventuellement permanent generation).
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 au lieu d’un seul.
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 appels à System.gc() rendent cet objectif inatteignable.
Conclusion
La JVM, dans son paramétrage par défaut, tente de s’adapter le plus possible au profil de consommation mémoire de l’application. Lorsque l’on souhaite affiner le comportement de la 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 est impératif d’avoir les logs d’exécution du GC et de solides connaissances sur le fonctionnement des GC.
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 à expérimenter pour bien comprendre le fonctionnement interne de la JVM.
Commentaire
9 réponses pour " Comprendre le fonctionnement de la JVM – Article 2/2 "
Published by Laurent M. , Il y a 10 ans
Article très intéressant, comme le premier.
Il serait intéressant d’avoir un cas d’utilisation avec un outil d’analyse des logs et sur le tunning appliqué pour atteindre moins de 5% de full GC.
Concernant le paramètre -XX:+DisableExplicitGC, il me semble, d’expérience, qu’il fonctionne également sur les JDK traditionnelles.
Published by twixer , Il y a 10 ans
Merci pour cet article très intéressant qui démystifie ce fameux GC !
Published by Fabian , Il y a 10 ans
+1 pour les 2 articles! J’ai appris plein de choses. En tant qu’utilisateur Java, il est toujours instructif de savoir ce qui se cache sous le capot et je pense que très peu de gens le savent!
Published by tda , Il y a 10 ans
+1 également pour les 2 articles très instructifs !
Cependant, quelqu’un pourrait m’éclairer sur la notion d' »overhead » ?
« Ces garbage collectors offrent le meilleur ratio entre overhead et mémoire libérée. »
Merci !
Published by Pierre Laporte , Il y a 10 ans
L’overhead est le temps durant lequel le GC bloque l’application mais ne libère pas de mémoire.
Lorsque CMS est utilisé, le GC dans la young generation (ParNew ou DefNew) doit informer CMS de la vitesse à laquelle la young generation se remplit et à laquelle les objets vont arriver dans la old generation. Cela permet à CMS de ne se déclencher que lorsque c’est nécessaire. Ces tâches (estimation de l’évolution mémoire, communication entre GCs) prend du temps durant lequel le GC ne libère pas de la mémoire à proprement parler, mais bloque quand même l’application.
Avec les GC Parallels, ces tâches n’existent pas. L’overhead est donc beaucoup plus faible, et le ratio mémoire libéré/overhead est meilleur.
Published by Clément H , Il y a 10 ans
Comme mentionné par les précédents intervenants, 2 articles fort instructifs et surtout d’une clarté exemplaire. Je ne manque pas d’en faire la promotion.
J’attends avec impatience de vous lire de nouveau sur le sujet.
Encore bravo et merci pour ces billets!
Published by Jean-Philippe BEMPEL , Il y a 10 ans
La justification pour le ms=mx est surtout que, comme tu l’indiques, le resize se fait lors d’un FullGC.
Donc si on suit le leitmotiv « on essaye d’obtenir le moins de Full GC possible » on ne va pas mettre un ms trop faible et au final il va se rapprocher du mx. Dans le cas contraire on va déclencher trop de FullGC car on a taillé le ms trop short.
Il est donc finalement plus simple et plus déterministe de mettre ms=mx (surtout en mode server).
« Plus la heap est volumineuse, plus les temps d’exécution du garbage collector vont être importants. »
En fait c’est surtout la mémoire consommée/retenue qui augmente le temps de FullGC. Tu peux très bien avoir un ms=mx=32GB, et un tesmp de FullGC inférieur à la seconde.
Published by Pierre Laporte , Il y a 10 ans
Effectivement, le réglage ms!=mx va provoquer des Full GC pendant le temps de chauffe de la JVM, mais seulement jusqu’à atteindre le point d’équilibre, après quoi le sizing de la mémoire est stable.
Ceci étant, comme tu le fais justement remarquer, fixer ces deux paramètres permet d’avoir un comportement plus déterministe.
Merci pour ce retour !