Régler le GC de la JVM
Le tuning du ramasse-miettes a mauvaise réputation, et souvent pour de bonnes raisons. On voit régulièrement des lignes de commande chargées d’une dizaine de flags -XX: copiés d’un forum, dont la moitié ne sert plus à rien depuis trois versions de la JVM. La vérité, c’est que le GC moderne se règle d’abord en répondant à une question simple : qu’est-ce qu’on cherche à optimiser ?
Il y a trois objectifs, et ils s’opposent partiellement : le débit (le pourcentage de temps passé dans votre code plutôt que dans le GC), la latence (la durée des pauses) et l’empreinte mémoire. On ne peut pas pousser les trois à fond en même temps. Tant qu’on n’a pas tranché, aucun flag n’a de sens.
Commencer par mesurer
Avant de toucher quoi que ce soit, activez les logs GC. Depuis JDK 9 tout passe par le logging unifié, et les anciens flags comme -XX:+PrintGCDetails sont dépréciés :
-Xlog:gc*:file=gc.log:time,uptime,level,tags
Ce fichier vous dit tout ce qu’il faut : la fréquence des collectes, la durée des pauses, la taille de la heap avant et après chaque cycle, et si des Full GC se déclenchent. Un outil comme GCeasy ou JDK Mission Control en sort des graphes lisibles en quelques secondes. Sans ces données, vous réglez à l’aveugle.
Les deux chiffres à regarder en premier : le pourcentage de temps passé en GC (au-delà de quelques pour-cent, il y a un souci) et le P99 des pauses comparé à votre budget de latence.
Choisir le bon collecteur
Depuis JDK 9, le collecteur par défaut est G1. C’est un bon généraliste : il vise un compromis entre débit et latence, et tient des pauses raisonnables sur des heaps de quelques gigaoctets. Pour la grande majorité des services, le défaut est le bon choix et il n’y a rien à changer.
Les cas où l’on s’en écarte :
- Parallel (
-XX:+UseParallelGC) maximise le débit au prix de pauses plus longues. Pertinent pour du batch ou du calcul où la latence ne compte pas, seulement le travail total abattu. - ZGC (
-XX:+UseZGC) vise des pauses sous la milliseconde, quasi indépendantes de la taille de la heap. C’est le choix pour les grosses heaps (dizaines à centaines de gigaoctets) avec une exigence de latence forte. Depuis JDK 21 la version générationnelle existe, et elle est devenue le défaut de ZGC à partir de JDK 23. - Shenandoah (
-XX:+UseShenandoahGC) joue dans la même catégorie que ZGC, avec des pauses très courtes obtenues différemment.
Le réflexe utile : ne quittez G1 que si les logs montrent un vrai problème de pauses que G1 n’arrive pas à tenir, ou si vous êtes sur une heap énorme. Sinon vous échangez une simplicité éprouvée contre une complexité qui ne vous rapporte rien.
Dimensionner la heap
C’est le réglage qui a le plus d’impact, loin devant les autres. Deux règles :
-Xms4g -Xmx4g # fixer Xms = Xmx
Mettre -Xms égal à -Xmx évite que la JVM passe son temps à redimensionner la heap au démarrage, et garantit que la mémoire est réservée d’emblée. En conteneur, préférez un pourcentage de la RAM disponible plutôt qu’une valeur en dur :
-XX:MaxRAMPercentage=75.0
La JVM est consciente des limites de cgroup depuis JDK 10 (et 8u191), donc elle lit correctement la mémoire allouée au conteneur. Évitez de remplir la heap jusqu’au plafond du conteneur : il faut laisser de la place pour le métaspace, les stacks de threads et les buffers natifs, sinon c’est l’OOM-killer du noyau qui tranche, et lui ne fait pas de quartier.
Une heap trop petite multiplie les collectes et finit par déclencher des Full GC ; une heap trop grande gaspille de la RAM et allonge les pauses qui balaient toute la mémoire vivante. Le bon dimensionnement se lit dans les logs : visez une occupation après GC qui laisse de la marge sans être démesurée.
Les leviers G1 qui comptent
Une fois la heap dimensionnée, les flags utiles sur G1 se comptent sur les doigts d’une main. Le principal :
-XX:MaxGCPauseMillis=200 # cible de pause, 200 ms par défaut
C’est une cible, pas une garantie. G1 ajuste la taille de ses régions collectées pour s’en approcher. La baisser trop agressivement (disons 20 ms) force des collectes plus fréquentes et grignote le débit. Ajustez par petits pas en regardant l’effet réel dans les logs.
Attention aux objets humongous : dans G1, tout objet qui dépasse la moitié d’une région est alloué à part, dans des régions dédiées, que G1 gère mal. Si vos logs en montrent beaucoup, c’est souvent le signe de gros tableaux ou de buffers, et il vaut parfois mieux revoir le code que la taille de région (-XX:G1HeapRegionSize).
La vraie optimisation est dans le code
Voici l’idée la plus importante de cet article : la plupart des problèmes attribués au GC sont en réalité des problèmes d’allocation. Un GC qui tourne trop, c’est presque toujours du code qui fabrique trop d’objets temporaires. Aucun flag ne corrigera une boucle qui crée des millions d’objets éphémères ; au mieux il déplacera le coût.
Plutôt que d’empiler des -XX:, mesurez où la heap est allouée. Le mode alloc d’async-profiler pointe directement vers les chemins de code responsables, et le résultat se lit comme n’importe quel flamegraph. C’est le sujet de deux articles dédiés : Profiler la JVM avec async-profiler et Comment lire un flamegraph.
Réduire le taux d’allocation soulage le GC bien plus sûrement que n’importe quel réglage de collecteur. C’est moins spectaculaire que de coller dix flags sur une ligne de commande, mais ça marche.
En résumé
Choisissez votre objectif, activez les logs, gardez G1 sauf raison mesurée d’en changer, dimensionnez la heap avec soin, et traquez les allocations dans le code avant de toucher aux flags exotiques. Le meilleur tuning GC est souvent celui qu’on n’a pas eu besoin de faire.