Serial, Parallel, Concurrent, CMS, G1, Young Gen, New Gen, Old Gen, Perm Gen, Eden, Tenured, Survivor Spaces, Safepoints, et les centaines de paramètres de démarrage de la JVM. Est-ce que tout cela vous laisse dubitatif quand vous essayez de régler le Garbage Collector afin d'obtenir les volumes et latences requis de votre application Java ? Si c'est le cas, alors pas de panique, vous n'êtes pas seul. La documentation décrivant le garbage collector ressemble au manuel d'un avion. Chaque bouton et chaque levier est détaillé et expliqué mais vous ne trouvez nulle part un guide sur comment voler. Cet article va tenter d'expliquer les compromis à faire lorsque l'on choisit et que l'on règle les algorithmes de garbage collector pour une charge donnée.
Le focus se fera sur les garbage collectors d'Oracle Hotspot JVM et de l'OpenJDK, car ce sont les plus couramment utilisés. Vers la fin, d'autres JVM commerciales seront discutées pour illustrer des alternatives.
Les Compromis
Les sages nous le disent continuellement, "on n'obtient rien sans rien". Quand nous obtenons quelque chose nous devons généralement abandonner autre chose en retour. En ce qui concerne le Garbage Collector, nous jouons avec 3 variables majeures qui lui donnent des cibles :
- le Débit : la quantité de travail fournie par une application comme ratio du temps passé en GC. Ciblez un débit avec
-XX:GCTimeRatio=99
; 99 est le défaut équivalent à un temps de GC de 1%. - la Latence : le temps pris par les systèmes en réponse à des événements, qui est impacté par des pauses introduites par le garbage collector. Ciblez une latence des pauses GC avec
-‑XX:MaxGCPauseMillis=<n>
. - la Mémoire : la quantité de mémoire que nos systèmes utilisent pour stocker leur état, qui est souvent copié et déplacé lorsqu'il est traité. L'ensemble des objets actifs référencés par l'application à un instant donné est connu sous le nom de Live Set. La taille maximum du tas
-Xml<n>
est un paramètre d'ajustement pour déterminer la taille du tas disponible à une application.
Note : souvent Hotspot ne peut atteindre ces cibles et va silencieusement continuer sans produire d'alerte, en ayant raté sa cible d'une grande marge.
La latence est une distribution entre plusieurs événements. Il peut être acceptable d'avoir une latence moyenne plus importante pour réduire les latences des pires scénarios, ou pour la rendre moins fréquente. Nous ne devrions pas interpréter le terme "temps réel" comme désignant la latence la plus basse possible : il désigne plutôt le fait d'avoir une latence déterministe quel que soit le débit.
Pour certaines applications, le débit est la cible la plus importante. Un exemple serait un batch de longue durée ; cela n'a pas d'importance si un travail de batch est occasionnellement pausé pour quelques secondes pendant que le garbage collector travaille, tant que la tâche dans son ensemble peut être complétée plus tôt.
Pour pratiquement toutes les autres charges de travail, qu'il s'agisse d'un homme face à une application interactive ou d'un système de trading financier, si un système ne répond plus pour plus que quelques secondes ou millisecondes, cela peut être un désastre. Dans les systèmes financiers, il est souvent intéressant de faire le compromis d'un peu de débit pour avoir une latence constante en retour. Nous pourrions aussi avoir des applications qui sont limitées par la quantité de mémoire physique disponible et qui doivent maintenir une empreinte légère, auquel cas nous devons abandonner de la performance à la fois sur le front de la latence et celui du débit.
Les compromis se présentent souvent comme suit :
- Dans une large proportion, le coût du garbage collector, en tant que coût amorti, peut être réduit en fournissant plus de mémoire aux algorithmes de garbage collection.
- Les pauses dues au GC induisant de la latence, dans le pire des cas observés, peuvent être réduites en limitant le Live Set et en gardant une taille du tas petite.
- La fréquence à laquelle les pauses se produisent peut être réduite en gérant la taille du tas et de la génération, et en contrôlant le taux d'allocation d'objets de l'application.
- La fréquence des grosses pauses peut être réduite en exécutant le GC en parallèle de l'application, parfois aux dépends du débit.
Durée de Vie des Objets
Les algorithmes de garbage collection sont souvent optimisés en présupposant que la plupart des objets vivent pour une très courte période, alors que relativement peu vivent très longtemps. Dans la plupart des applications, les objets qui vivent pour une durée significative tendent à constituer un très petit pourcentage des objets alloués au court du temps. Dans la théorie de la gestion de la mémoire par GC, ce comportement observé est souvent appelé "mortalité infantile" ou "hypothèse générationnelle faible" ( Weak Generational Hypothesis ). Par exemple, les Itérateurs de boucles ont principalement une durée de vie courte, alors que les String
statiques sont immortelles.
L'expérimentation a montré que les garbage collectors générationnels peuvent habituellement supporter un débit largement supérieur à celui des collecteurs non générationnels, et ils sont donc utilisés de manière presque universelle dans les JVM de serveurs. En séparant les générations d'objets, nous savons qu'une région d'objets nouvellement alloués risque de contenir peu d'objets vivants. De ce fait, un collecteur qui cueille les quelques objets vivants dans cette nouvelle région et les copie dans une autre région pour les objets plus vieux peut être très efficace. Les garbage collectors d'Hotspot enregistrent l'âge d'un objet en termes de cycles de GC auxquels il a survécu.
Note : si votre application génère systématiquement beaucoup d'objets restant vivants pour une assez longue durée, attendez-vous à ce que votre application passe beaucoup de temps à faire tourner le garbage collector, et à passer une part significative de votre temps à régler les garbage collectors d'Hotspot. Cela est dû à une réduction de la rentabilité du GC qui intervient quand le "filtre" générationnel est moins efficace, et les collectes des générations vivant plus longtemps plus fréquentes. Les vieilles générations sont moins rares, et en conséquence le rendement des algorithmes de collecte de générations anciennes tend à être bien plus bas. Les collecteurs générationnels tendent à opérer en deux cycles de collecte distincs : les collectes Mineures, où les objets à courte durée de vie sont collectés, et les collectes Majeures, moins fréquentes, où les régions plus vieilles sont collectées.
Les Événements Stop-The-World
Les pauses dont souffrent les applications durant les opérations du garbage collector sont dues à ce qu'on appelle des événements "Stop-The-World". Pour que les garbage collectors puissent opérer, pour des raisons pratiques d'ingénierie, il est nécessaire de périodiquement stopper l'application s'exécutant de telle façon que la mémoire puisse être gérée. En fonction de l'algorithme, différents garbage collectors vont "stopper le monde" à des points spécifiques de l'exécution pour des durées variables. Pour amener une application à l'arrêt complet, il est nécesssaire de mettre en pause tous les threads en cours d'exécution. Les garbage collectors font cela en signalant aux threads qu'ils doivent se stopper quand ils arrivent à un "point sûr" ou safepoint, qui est un point de l'exécution du programme auquel toutes les racines de GC sont connues et tous les contenus d'objets du tas sont cohérents. En fonction de ce que fait un thread il peut s'écouler du temps avant d'atteindre un point sûr. Les vérifications de sûreté sont normalement faites au moment des retours de méthodes et fins d'itérations de boucles, mais peuvent être optimisés en les retirant à certains endroits, les rendant dynamiquement plus rares. Par exemple, si un thread est en train de copier un large tableau, de cloner un gros objet, ou d'exécuter une boucle comptée monotone avec une limite finie, il pourrait s'écouler des millisecondes avant qu'un safepoint soit atteint. La durée pour atteindre un point sûr ( Time-to-safepoint ) est une considération importante dans les applications à faible latence. Cette durée peut faire surface en activant l'option ‑XX:+PrintGCApplicationStoppedTime
en plus des autres options du GC.
Note : pour les applications avec un grand nombre de threads, quand un événement stop-the-world se produit, le système va subir une pression significative de planification des threads alors que ceux-ci reprennent leur exécution à leur libération des points sûrs. Pour cette raison, les algorithmes dépendant moins de ce genre d'événements peuvent potentiellement être plus efficaces.
Organisation du tas dans Hotspot
Pour comprendre comment les différents garbage collectors opèrent il est préférable d'explorer comment le tas de Java est organisé pour supporter les garbage collectors générationnels.
L'Eden est la région où la plupart des objets sont initialement alloués. Les espaces dits survivor (survivants) sont un dépôt temporaire pour des objets ayant survécu à une collecte sur l'Eden. L'usage de cet espace sera décrit lorsque les collectes mineures seront discutées. L'Eden et les espaces survivor sont collectivement connus comme la "jeune" ou "nouvelle" génération ( young generation, new generation ).
Les objets vivant suffisamment longtemps sont finalement promus dans l'espace "titulaire" (tenured space).
La génération "permanente" (perm generation) est l'endroit où le runtime stocke les objets qu'il "sait" être dans les faits immortels, tels que les Classes et les Strings statiques. Malheureusement l'usage commun de chargement de classes en continu dans beaucoup d'applications rend la supposition motivant l'existence de la perm generation (les classes sont immortelles) fausse. En Java 7 les String internées ont été déplacées de la permgen vers la tenured, et à partir de Java 8 la perm generation n'existera plus, et ne sera donc pas discutée dans cet article. La plupart des autres garbage collectors commerciaux n'utilisent pas un espace permanent séparé et tendent à traiter tous les objets à longue durée de vie comme "titulaires" ( tenured ).
Note : les espaces virtuels permettent aux garbage collector d'ajuster la taille des régions pour atteindre les cibles de débit et de latence. Les garbage collectors gardent des statistiques pour chaque phase de collecte et ajustent en fonction les tailles des régions afin d'essayer d'atteindre ces cibles.
Allocation d'Objets
Pour éviter la contention, chaque thread se voit assigner un Buffer d'Allocation Locale au Thread (ou Thread Local Allocation Buffer, TLAB) depuis lequel il alloue les objets. L'usage de TLABs permet aux allocations d'objets de monter en charge avec le nombre de threads en évitant une contention sur une unique ressource mémoire. L'allocation objet via un TLAB est une opération très peu coûteuse ; il s'agit simplement d'en faire sortir un pointeur correct pour la taille de l'objet, ce qui représente environ 10 instructions sur la plupart des plates-formes. L'allocation de mémoire pour le tas en Java est encore moins coûteuse que l'utilisation de malloc dans le runtime C.
Note : bien que l'allocation individuelle d'objets soit très peu coûteuse, le rythme auquel les collectes mineures doivent se faire est directement proportionnel au rythme d'allocation d'objets.
Quand un TLAB est épuisé, un thread en demande simplement un nouveau depuis l'espace Eden. Quand Eden est rempli, une collecte mineure commence.
Les grands objets (-XX:PretenureSizeThreshold=n
) pourraient ne pas être accommodés dans la jeune génération et donc devoir être alloués dans la vieille génération, par ex. un grand tableau. Si le seuil est réglé sous la taille du TLAB alors les objets pouvant passer dans le TLAB ne seront pas créés dans la vieille génération. Le nouveau garbage collector G1 gère les gros objets différemment et sera discuté plus tard dans sa propre section.
Collectes Mineures
Une collecte mineure est déclenchée quand l'Eden est plein. Il s'agit d'une copie de tous les objets vivants vers la nouvelle génération, soit dans un espace survivant ou l'espace titulaire selon ce qui est approprié. Copier vers l'espace titulaire est connu sous le nom de promotion (ou encore tenuring). La promotion est faite pour les objets qui sont suffisamment vieux (– XX:MaxTenuringThreshold
), ou quand l'espace survivant déborde.
Les objets vivants sont les objets qui sont atteignables par l'application ; tout autre objet ne peut être atteint et peut donc être considéré comme mort. Dans une collecte mineure la copie des objets vivants est faite en suivant premièrement ce qu'on appelle les Racines de GC ( GC Roots ) et en copiant itérativement tout élément atteignable vers l'espace survivant. Les Racines de GC incluent normalement des références en provenance de l'application et de champs statiques de la JVM, et de structures de pile ( stack frames ) de threads, tout cela pointant effectivement vers le graphe d'objets atteignables de l'application.
Dans la collecte générationnelle, les Racines de GC pour les graphes d'objets atteignables de la nouvelle génération incluent aussi toute référence depuis la vieille génération vers la nouvelle génération. Ces références doivent aussi être prises en compte pour être certain que tous les objets dans la nouvelle génération survivent à la collecte mineure. L'identification de ces références inter-générationnelles se fait à l'aide d'une "table de cartes" ( card table ). La table de cartes de Hotspot est un tableau d'octets dans lequel chaque octet est utilisé pour traquer l'existence potentielle de références inter-générationnelles dans une région de 512 octets correspondante de la vieille génération. Alors que les références sont stockées dans le tas, un code "barrière de stockage" ( store barrier ) marquera les cartes pour indiquer qu'une référence potentielle de la vieille génération vers la nouvelle génération peut exister dans la région de 512 octets associée du tas. Au moment de la collecte, la table de cartes est utilisée pour repérer de telles références inter-générationnelles, qui représentent dans les faits des Racines de GC additionnelles dans la nouvelle génération. C'est pourquoi un coût fixe significatif des collectes mineures est directement proportionnel à la taille de la vieille génération.
Il y a deux espaces survivants dans la nouvelle génération d'Hotspot, qui alternent les rôles "espace-vers" et "espace-depuis" (to-space, from-space). AU début d'une collecte mineure, l'espace survivant "espace-vers" est toujours vide, et agit comme zone de copie cible de la collecte. L'espace cible de la collecte mineure précédente fait partie de l'"espace-depuis", qui inclut aussi Eden, où les objets vivants devant être copiés peuvent être trouvés.
Le coût d'une collecte de GC mineure est habituellement dominé par le coût de copie des objets vers les espaces survivants et titulaires. Les objets qui ne survivent pas à une collecte mineure sont libres d'être traités. Le travail effectué durant une collecte mineure est directement proportionnel au nombre d'objets vivants trouvés, et pas à la taille de la nouvelle génération. Le temps total pris par une collecte mineure peut presque être divisé par deux chaque fois que la taille de l'Eden est doublée. La mémoire peut donc être échangée contre du débit. Un doublement de l'espace d'Eden résultera en une augmentation de temps de collecte par cycle de collecte, mais cela est relativement faible si à la fois le nombre d'objets étant promus et la taille de la vieille gérénation sont constants.
Note : dans Hotspot les collectes mineures sont des événements stop-the-world. Cela devient rapidement un problème majeur car nos tas deviennent de plus en plus gros avec plus d'objets vivants. Nous voyons déjà apparaître le besoin de collecte parallèle de la jeune génération pour atteindre nos cibles en temps de pauses.
Collectes Majeures
Les collectes majeures s'occupent de la vieille génération afin que les objets puissent être promus depuis la jeune génération. Dans la plupart des applications, la vaste majorité de l'état des programmes finit dans la vieille génération. La plus grande variété d'algorithmes de GC existe pour la vieille génération. Certains vont compacter l'espace entier quand il se remplit, tandis que d'autres vont procéder à une collecte en parallèle de l'application pour essayer de prévenir son remplissage.
Le garbage collector de la vieille génération va essayer de prédire quand il doit collecter de manière à éviter un échec de promotion depuis la jeune génération. Les garbage collectors surveillent un seuil de remplissage pour la vieille génération et commencent la collecte quand ce seuil est dépassé. Si ce seuil est insuffisant pour atteindre les pré-requis de promotion alors il se déclenche un FullGC. Un FullGC implique de promouvoir tous les objets vivants depuis la jeune génération, suivi d'une collecte et d'un compactage de la vieille génération. L'échec de promotion est une opération très coûteuse, vu que l'état et les objets promus pendant ce cycle doivent être déroulés pour que l'événement de FullGC puisse se produire.
Note : pour éviter l'échec de promotion, vous aurez à régler la marge que la vieille génération accorde pour accommoder les promotions (‑XX:PromotedPadding=<n>
).
Note : quand le tas doit grandir, un FullGC est déclenché. Ces FullGCs de redimensionnement du tas peuvent être évités en réglant -Xms
et -Xmx
à la même valeur.
A part un FullGC, un compactage de la vieille génération risque de représenter la pause stop-the-world la plus longue qu'une application connaisse. Le temps pour ce compactage tend à augmenter linéairement avec le nombre d'objets vivants dans l'espace titulaire ou tenured space.
Le rythme auquel l'espace titulaire se remplit peut parfois être réduit en augmentant la taille des espaces survivants et l'âge des objets avant d'être promus à la génération titulaire. Cependant, augmenter la taille des espaces survivants et l'âge des objets dans les collectes mineures (–XX:MaxTenuringThreshold
) avant la promotion peut aussi augmenter le coût et les durées de pause dans les collectes mineures à cause des coûts supplémentaires de copie entre les espaces survivants lors de la collecte.
Serial Collector
Le garbage collector "en série" ( Serial collector, -XX:+UseSerialGC
) est le plus simple des garbage collectors et est une bonne option pour un système à processeur unique. Il a aussi la plus petite empreinte mémoire de tous les collecteurs. Il utilise un thread unique pour à la fois les collectes mineures et majeures. Les objets sont alloués dans l'espace titulaire en utilisant un simple algorithme de déplacement de pointeur. Les collectes majeures sont déclenchées quand l'espace titulaire est plein.
Parallel Collector
Le garbage collector parallèle (Parallel collector) existe sous deux formes. Parallel Collector (‑XX:+UseParallelGC
) qui utilise de multiples threads pour effectuer les collectes mineures de la Jeune génération et un unique thread pour les collectes majeures sur la vieille génération. Le Parallel Old Collector (‑XX:+UseParallelOldGC
), le défaut depuis Java 7u4, utilise plusieurs threads pour les collectes mineures et plusieurs threads pour les collectes majeures. Les objets sont alloués dans l'espace titulaire à l'aide d'un algorithme simple de déplacement de pointeur. Les collectes majeures sont déclenchées quand l'espace titulaire est plein.
Sur des systèmes multi-processeurs le garbage collector Parallel Old Collector va donner le meilleur débit de tous les garbage collectors. Il n'a pas d'impact sur une application en cours d'exécution jusqu'à ce qu'une collecte se déclenche, et va alors collecter en parallèle en utilisant de multiples threads en faisant usage de l'algorithme le plus efficace. Cela rend le Parallel Old Collector très indiqué pour les applications de type batch.
Le coût de collecte des vieilles générations est affecté par le nombre d'objets à conserver plus que par la taille du tas. Ainsi, l'efficacité du Parallel Old Collector peut être augmentée pour réaliser un meilleur débit en fournissant plus de mémoire et en acceptant des pauses de collecte plus longues mais moins nombreuses.
Attendez-vous aux collectes mineures les plus rapides avec cet algorithme, parce que la promotion vers l'espace titulaire est une simple opération de déplacement de pointeur et de copie.
Pour les applications serveurs, le Parallel Old Collector devrait être la première escale. Cependant si les pauses de collecte majeure dépassent ce que votre application peut tolérer, alors vous devrez essayer un garbage collector concurrent qui collecte les objets titulaires en concurrence pendant que l'application s'exécute.
Note : Les pauses devraient être de l'ordre de 1 à 5 secondes par Go de données vivantes sur un matériel moderne quand la vieille génération est compactée.
Concurrent Mark Sweep (CMS) Collector
Le CMS (-XX:+UseConcMarkSweepGC
) s'exécute dans la Vieille génération, collectant les objets titulaires qui ne sont plus atteignables durant une collecte majeure. Il s'exécute en parallèle de l'application, dans le but de garder assez d'espace libre dans la vieille génération de sorte qu'aucun échec de promotion depuis la jeune génération ne se produise.
Les échecs de promotion vont déclencher un FullGC. Le CMS suis un processus en plusieurs étapes :
- Marquage Initial (Initial Mark, stop-the-world) : trouver les Racines de GC.
- Marquage Concurrent (Concurrent Mark) : marquer tous les objets atteignables depuis les Racines de GC.
- Pré-nettoyage Concurrent (Concurrent Pre-clean) : vérifier si des références d'objets ont été mises à jour ou si des objets ont été promus durant la phase de marquage concurrent en refaisant un marquage.
- Re-Marquage (Re-mark, stop-the-world) : capturer les références objets qui ont été mises à jour depuis l'étape de Pré-nettoyage.
- Balayage Concurrent (Concurrent Sweep) : mettre à jour les listes d'espace libre (free-lists) en récupérant la mémoire occupée par les objets morts.
- Réinitialisation Concurrente (Concurrent Reset) : réinitialiser les structures de données pour la prochaine exécution.
Alors que les objets titulaires deviennent inatteignables, l'espace est récupéré par CMS et mis dans des "listes libres". Quand la promotion intervient, on doit chercher dans les listes libres un trou de taille suffisante pour l'objet promus. Cela augmente le coût de promotion et donc augmente le coût des collectes Mineures comparé au Parallel Collector.
Note : CMS n'est pas un garbage collector compacteur, ce qui peut résulter au cours du temps en une fragmentation de la vieille génération. La promotion des objets peut échouer parce qu'un gros objet peut ne pas rentrer dans les trous disponibles dans la vieille génération. Quand cela se produit, un message "échec de promotion" ("promotion failed") est loggé et un FullGC est déclenché pour compacter les objets titulaires vivants. Pour de tels FullGC motivés par un compactage, attendez-vous à des pauses pires que lors des collectes majeures utilisant le Parallel Old Collector, car le CMS utilise un thread unique pour le compactage.
CMS est essentiellement en parallèle avec l'application, ce qui a un certain nombre d'implications. Premièrement du temps CPU est pris par le garbage collector, réduisant ainsi le CPU disponible pour l'application. La quantité de temps requise par le CMS grandit linéairement avec la quantité de promotion d'objets vers l'espace titulaire. Deuxièmement, pour certaines phases du cycle de GC, tous les threads applicatifs doivent être amenés à un safepoint afin de marquer les Racines de GC et d'effectuer un re-marquage parallèle pour prendre en compte les mutations.
Note : si une application voit une mutation significative des objets titulaires, alors la phase de re-marquage peut être elle aussi significative, et prendre aux extrêmes plus longtemps qu'un compactage complet d'avec le Parallel Old collector.
CMS rend les FullGC moins fréquents aux dépends d'un débit réduit, de collectes mineures plus coûteuses, et d'une empreinte plus importante. La réduction en débit peut varier entre 10% et 40% comparé aux garbage collectors parallèles, en fonction du rythme de promotion. CMS requiert aussi une empreinte 20% supérieure pour accommoder des structures de données supplémentaires et les "déchets flottants" pouvant être ratés pendant le marquage concurrent, qui se retrouvent au cycle suivant.
Un haut ratio de promotion et la fragmentation qui en résulte peuvent parfois être réduits en augmentant la taille des espaces des générations à la fois jeune et vieille.
Note : CMS peut souffrir d'"échecs du mode concurrent" ( "concurrent mode failures" ) qui peuvent être vus dans les logs, quand il échoue à collecter à un rythme suffisant pour suivre les promotions. Cela peut être causé par un début de collecte trop tardif, pouvant être adressé par des réglages. Mais cela peut aussi se produire quand le rythme de collecte ne peut suivre le haut rythme de promotion ou le haut rythme de mutation d'objets de certaines applications. Si ceux-ci sont trop hauts alors votre application pourrait nécessiter certains changements afin de réduire la pression de promotion. Ajouter plus de mémoire à un tel système peut parfois empirer la situation, puisque CMS a alors plus de mémoire à scanner.
Garbage First Collector (G1)
G1 (-XX:+UseG1GC
) est un nouveau garbage collector introduit dans Java 6 et maintenant officiellement supporté dans Java 7. C'est un algorithme de collecte partiellement concurrent qui essaye aussi de compacter l'espace titulaire en pauses stop-the-world incrémentales plus courtes pour tenter de minimiser les événements FullGC qui plombent CMS à cause de la fragmentation. G1 est un garbage collector générationnel qui organise le tas différemment des autres en le divisant en régions de tailles fixes mais à usage variables, plutôt qu'en régions contiguës pour un même usage.
G1 choisit l'approche de marquer les régions de manière concurrente pour pister les références entre les régions, et de concentrer la collecte sur les régions ayant le plus d'espace disponible. Ces régions sont alors collectées par incréments de pauses stop-the-world par évacuation des objets vivants vers une région vide, faisant ainsi du compactage. Les objets plus gros que 50% d'une région sont alloués dans des régions "monstres" ( humongous ), qui sont des multiples de la taille de région de base. L'allocation et la collecte d'objets "monstres" peut être très coûteuse sous G1, et on n'a à ce jour (presque) pas vu d'effort d'optimisation d'appliqué.
Le défi avec tout garbage collector compacteur n'est pas le déplacement des objets mais la mise à jour des références vers ces objets. Si un objet est référencé depuis beaucoup de régions, alors mettre à jour ces références peut prendre significativement plus de temps que de déplacer l'objet. G1 piste quels objets d'une région ont des références depuis d'autres régions via les "Remembered Sets" ("Ensembles Mémorisés"). Si ces Remembered Sets deviennent trop grands, G1 peut être significativement ralenti. Quand les objets sont évacués d'une région à une autre, la durée des événements stop-the-world associés tend à être proportionnelle au nombre de régions avec références devant être scannées et potentiellement patchées.
Maintenir le Remembered Sets augmente le coût des collectes mineures, résultant en des pauses plus importantes que celles vues avec Parallel Old ou CMS.
G1 est piloté par des cibles de latence –XX:MaxGCPauseMillis=<n>
, valeur par défaut = 200ms. La cible va influencer la charge de travail effectuée lors de chaque cycle en faisant pour le mieux seulement (best-efforts). Renseigner des cibles en dizaines de millisecondes est très futile, et à la rédaction de cet article le ciblage de dizaines de millisecondes n'a pas fait partie des focus de G1.
G1 est un bon garbage collector générique pour des tas plus gros qui ont une tendance à se fragmenter quand une application peut tolérer des pauses dans l'intervalle de 0,5 à 1 secondes pour des compactages incrémentaux. G1 tend à réduire la fréquence des pires cas de pause vus par CMS à cause de la fragmentation, aux dépends de collectes mineures plus étendues et de compactage incrémentaux de la vieille génération. La plupart des pauses finissent par être contraintes à des régions plutôt qu'à l'ensemble du tas.
Comme CMS, G1 peut aussi échouer à tenir le rythme des promotions, et basculer alors sur un FullGC stop-the-world. Tout comme CMS a un "échec du mode concurrent" ("concurrent mode failure"), G1 peut souffrir d'échec évacuation, visible dans les logs en tant que "to-space overflow". Cela se produit quand il n'y a pas de régions libres dans lesquelles les objets peuvent être évacués, ce qui est similaire à un échec de promotion. Si cela se produit, essayez d'utiliser un tas plus grand et plus de threads de marquage, mais dans certains cas des changements dans l'application pourraient être nécessaires pour réduire le rythme des allocations.
Un problème qui représente un vrai défi pour G1 est de traiter les objets et les régions populaires. Le compactage incrémental stop-the-world fonctionne bien quand les régions ont des objets vivants qui ne sont pas lourdement référencés depuis d'autres régions. Si un objet ou une région est populaire alors le Remembered Set sera grand, et G1 essayera d'éviter la collecte de ces objets. Finalement il ne peut avoir le choix, ce qui résulte en des pauses de longueur moyenne très fréquentes alors que le tas est compacté.
Garbage Collector Concurrents Alternatifs
CMS et G1 sont souvent appelés garbage collectors principalement concurrent. Quand on regarde l'ensemble du travail qu'ils réalisent, il est clair que les traitements sur la jeune génération, la promotion et même la plupart de la vieille génération ne sont pas concurrents du tout. CMS est principalement concurrent de la vieille génération ; G1 est beaucoup plus un garbage collector stop-the-world incrémental. A la fois CMS et G1 ont des événements stop-the-world significatifs et récurrents, et des scénarios "pire des cas" qui les rendent souvent inadaptés aux applications strictement faible latence, telles que les applications de trading financier ou les interfaces utilisateurs réactives.
Des garbage collectors alternatifs sont disponibles tels que Oracle JRockit Real Time, IBM Websphere Real Time, et Azul Zing. JRockit et Websphere ont l'avantage en termes de latence la plupart du temps par rapport à CMS et G1 mais voient souvent des limitations en débit et souffrent tout de même d'événements stop-the-world fréquents. Zing est le seul garbage collector Java connu de son auteur qui puisse être réellement concurrent pour la collecte et le compactage tout en maintenant un haut débit de traitement pour toutes les générations. Zing a bien quelques événements stop-the-world sous la milliseconde mais ils sont liés à des changements de phase dans le cycle de collecte qui ne sont pas en relation avec la taille de l'ensemble des objets vivants.
JRockit RT peut réaliser des temps de pause typiques dans les dizaines de millisecondes pour des taux d'allocation élevés sur des tailles de tas limitées mais doit occasionnellement se rabattre sur des pauses de compactage complètes. Websphere RT peut réaliser des temps de pause en millisecondes à un chiffre via des taux d'allocation et taille d'ensemble d'objets vivants limités. Zing peut réaliser des pauses sous la milliseconde avec un taux d'allocation élevé en étant concurrent lors de toutes les phases, y compris pendant les collectes mineures. Zing est capable de maintenir ce comportement cohérent quelle que soit la taille du tas, autorisant l'utilisateur à appliquer des grandes tailles de tas selon ce qui est nécessaire pour pouvoir suivre le débit de l'application ou les besoins liés à l'état du modèle objet, sans crainte d'augmenter les temps de pause.
Pour tous les garbage collectors concurrents ciblant la latence on doit faire un compromis de débit et augmenter la charge. En fonction de l'efficacité du garbage collector concurrent vous pourriez abandonner un peu de débit, mais il y aura toujours une empreinte significative. Pour les réellement concurrents, avec peu d'événements stop-the-world, des cœurs CPU supplémentaires sont nécessaires pour activer le travail concurrent et maintenir le débit.
Note : tout les garbage collectors concurrents tendent à fonctionner plus efficacement quand suffisamment d'espace est alloué. Comme règle d'or pour commencer, vous devriez avoir un budget pour un tas d'au moins deux ou trois fois la taille de l'ensemble des objets vivants (live set) pour un fonctionnement efficace. Cependant, les pré-requis en espace pour maintenir un fonctionnement concurrent augmentent avec le débit de l'application, et les allocations et taux de promotion associés. Donc pour des applications à plus haut débit un ratio taille du tas sur ensemble vivant plus haut peut être justifié. Étant donné l'espace mémoire gigantesque disponible sur les systèmes d'aujourd'hui, l'empreinte mémoire est rarement un problème côté serveur.
Surveillance et Réglage du Garbage Collector
Pour comprendre comment votre application et votre garbage collector se comportent, démarrez votre JVM avec au moins les paramètres suivants :
-verbose:gc
-Xloggc:<filename>
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationConcurrentTime
-XX:+PrintGCApplicationStoppedTime
Chargez alors les logs dans un outil tel que Chewiebug pour analyse.
Pour voir la nature dynamique du GC, lancez JVisualVM et installez le plugin Visual GC. Cela vous permettra de voir le GC en action dans votre application comme ci-dessous.
Pour comprendre les besoins en GC de votre application, vous avez besoin de tests de charge représentatifs que vous pouvez exécuter de manière répétée. Au fur et à mesure que vous commencez à comprendre comment chaque garbage collector fonctionne, exécutez vos tests de charge avec des configurations différentes comme expériences jusqu'à ce que vous atteigniez vos cibles de débit et de latence. Il est important de mesurer la latence du point de vue des utilisateurs finaux. Cela peut être réalisé en capturant le temps de réponse de chaque requête de test dans un histogramme, et vous pouvez en lire plus ici. Si vous avez des pics de latence qui sont hors de la fourchette acceptable, alors essayez de les corréler avec les logs de GC pour déterminer si le GC est le problème. Il est possible que d'autres problèmes puissent causer des pics de latence. Un autre outil pratique à considérer est jHiccup, qui peut être utilisé pour pister les pauses dans la JVM et au travers de tout un système.
Si les pics de latence sont dus au GC alors investissez dans un réglage de CMS ou G1 pour voir si vos cibles de latence peuvent être atteintes. Parfois ça peut ne pas être possible à cause de hauts taux d'allocation et de promotion combinés avec des pré-requis de latence vraiment bas. Le réglage fin de GC peut devenir un exercice de haut vol qui requiert souvent des changements applicatifs pour réduire l'allocation d'objets ou la durée de vie des objets. Si c'est le cas alors il faut envisager un compromis commercial entre temps et ressources dépensés au réglage du GC et aux changements dans l'application sous la forme de l'achat de l'une des JVM commerciales de compactage concurrent comme JRockit Real Time ou Azul.
A Propos de l'Auteur
Martin Thompson est un spécialiste de la haute performance et de la basse latence, avec une expérience forgée pendant plus de deux décennies à travailler sur des systèmes transactionnels à grande échelle et des systèmes Big-Data. Il croit en la Sympathie Mécanique ( Mechanical Sympathy ), i.e. appliquer une compréhension du fonctionnement du matériel à la création de logiciel comme élément fondamental à la délivrance de solutions haute-performance élégantes. Le framework Disruptor est juste un exemple de ce que sa sympathie mécanique a créé. Martin a été co-fondateur et Directeur Technique de LMAX. Il blogue ici, et peut être croisé en train de donner des formations sur la performance et la concurrence, ou à hacker du code pour améliorer les systèmes.