Diagnostiquer une fuite mémoire avec un heap dump
Une application JVM qui fuit ne plante pas tout de suite. Elle ralentit d’abord. Le GC tourne de plus en plus souvent. Les pauses s’allongent. Le CPU monte sans raison claire. Puis un jour, java.lang.OutOfMemoryError: Java heap space tombe dans les logs et le process meurt. Entre le début et la fin, la mémoire a grimpé sans jamais vraiment redescendre. C’est le symptôme d’une fuite.
Sur la JVM, le mot « fuite » a un sens précis. Le ramasse-miettes libère tout objet qui n’est plus atteignable depuis une racine. Une fuite n’est donc pas un objet que le GC aurait oublié de collecter. C’est un objet que le code garde référencé alors qu’il ne sert plus. Le GC fait correctement son travail, mais il n’a pas le droit de collecter, parce qu’une référence pointe encore vers l’objet. Pour trouver cette référence, il faut le contenu complet de la heap : tous les objets présents et qui les retient. C’est ce que contient un heap dump.
Un heap dump est le contenu de la heap à un instant donné : chaque objet, sa classe, sa taille et ses références vers d’autres objets. Un thread dump donne l’état des threads ; un heap dump donne l’état de la mémoire. Avec le bon outil, il indique en quelques minutes quelle structure a grossi et quelle chaîne de références l’empêche d’être libérée.
Reconnaître une fuite avant même de dumper
Avant de capturer quoi que ce soit, regardez la bonne courbe. L’occupation de la heap monte et descend en permanence : elle grimpe entre deux GC, puis retombe à chaque collecte. Ce qui compte n’est pas le pic. C’est le niveau bas après un GC majeur (un full GC). Sur une application en bonne santé, ce niveau bas reste stable dans le temps.
Si ce niveau bas remonte d’un cran à chaque full GC, de la mémoire vivante s’accumule. C’est une fuite, pas seulement de la charge. La différence est nette : une application chargée monte haut mais redescend bas ; une application qui fuit ne redescend jamais complètement.
On voit cette tendance dans n’importe quel outil qui suit la heap : JConsole, VisualVM, ou une métrique jvm_memory_used_bytes sur un dashboard. Le bon réflexe est de ne pas regarder le sommet de la courbe, mais le creux juste après chaque full GC, et de voir s’il dérive vers le haut sur plusieurs heures.
Capturer le dump
Il y a plusieurs façons de faire, selon que la JVM est encore en vie ou non.
Au moment du crash, automatiquement. C’est l’option à activer partout, en prod comme ailleurs. La JVM écrit un dump juste avant de lever l’OutOfMemoryError, donc au moment exact où la fuite est la plus visible :
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/dumps
Aucun coût en fonctionnement normal, et c’est le seul moyen fiable d’obtenir un dump pris à l’instant du problème, pas plusieurs heures après.
Sur une JVM vivante, à la demande. jcmd est l’outil que l’équipe OpenJDK met en avant comme point d’entrée des diagnostics. Il déclenche un dump à partir du PID :
jcmd 9850 GC.heap_dump /var/dumps/heap.hprof
L’outil plus ancien, jmap, fonctionne toujours et fait la même chose :
jmap -dump:live,format=b,file=/var/dumps/heap.hprof 9850
Le mot-clé live est important. Il force un full GC avant le dump et n’écrit que les objets atteignables. C’est ce qu’on veut pour une fuite. Sans lui, le dump contient aussi des objets déjà morts mais pas encore collectés, qui brouillent l’analyse.
Deux points à connaître avant de lancer cette commande en prod. D’abord, prendre un dump impose un stop-the-world le temps de l’écriture, qui peut durer plusieurs secondes sur une grosse heap. Ensuite, le fichier produit fait à peu près la taille de la heap occupée : une heap de 8 Go donne un fichier de plusieurs gigaoctets. Prévoyez la place sur le disque, et un volume monté si vous êtes en conteneur.
Un premier tri rapide : l’histogramme
Avant d’ouvrir un fichier de plusieurs gigaoctets, un histogramme donne souvent déjà la direction. Il compte les objets atteignables par classe, sans écrire de dump complet :
jmap -histo:live 9850 | head -20
num #instances #bytes class name
----------------------------------------------
1: 5012440 160398080 byte[]
2: 4821880 154300160 java.util.HashMap$Node
3: 4810210 115445040 java.lang.String
4: 1203470 48138800 com.example.Session
Plus d’un million d’objets Session atteignables sur un service qui compte quelques centaines d’utilisateurs, voilà déjà une piste sérieuse. L’histogramme indique quoi s’accumule. Il n’indique pas qui le retient. Pour ça, il faut le dump complet et un outil d’analyse.
La grille de lecture : shallow heap et retained heap
L’analyse d’un heap dump repose sur une distinction.
La shallow heap est la taille de l’objet seul : ses propres champs, sans compter ce qu’il référence. Une HashMap a une shallow heap de quelques dizaines d’octets, quel que soit son contenu.
La retained heap est la mémoire qui serait libérée si on collectait cet objet : l’objet lui-même plus tout ce qu’il maintient atteignable à lui seul. La retained heap d’une HashMap pleine couvre tous ses nœuds, toutes ses clés et toutes ses valeurs, tant qu’aucun autre chemin ne les retient.
C’est la retained heap qui désigne le coupable. Un objet à shallow heap minuscule mais à retained heap énorme est le point à partir duquel, si on le collectait, toute cette mémoire reviendrait. Trier les objets par retained heap décroissante place les suspects en haut de la liste.
Le dominator tree
L’outil qui matérialise cette idée est le dominator tree. Un objet X domine un objet Y si tout chemin depuis une GC root vers Y passe par X. Autrement dit, si X est collecté, Y devient inatteignable et part au prochain GC. La retained heap d’un objet est la somme de ce qu’il domine.
Eclipse MAT (Memory Analyzer Tool) construit ce dominator tree et le trie par retained heap. En haut figurent les quelques objets qui, à eux seuls, retiennent la plus grosse part de la heap. Sur une fuite, le résultat est net : un objet, souvent une collection ou un cache, dont la retained heap pèse 60, 80, parfois 90 % de toute la heap. Le reste de l’enquête consiste à comprendre pourquoi il grossit et pourquoi il n’est pas libéré.
Remonter au coupable : le chemin vers les GC roots
Savoir quel objet pèse lourd ne suffit pas. Il faut savoir pourquoi le GC ne le collecte pas. Un objet survit tant qu’il existe une chaîne de références continue depuis une GC root. Une GC root est une variable locale sur la pile d’un thread, un champ statique, une classe chargée, un thread vivant, ou une référence JNI. Tant que cette chaîne existe, l’objet et tout ce qu’il domine restent en mémoire.
La fonction décisive de MAT est le chemin vers les GC roots (path to GC roots ; sur une classe entière, merge shortest paths to GC roots). Elle part de l’objet volumineux et remonte jusqu’à la racine qui le retient, en affichant la chaîne exacte des champs traversés. C’est là que la fuite apparaît : la chaîne montre que tous ces objets Session sont retenus par un champ static Map sessions qui n’est jamais vidé.
Un détail qui fait gagner du temps : excluez les références faibles et molles (weak et soft) de la recherche. Un objet retenu seulement par une référence faible (WeakReference) ou molle (SoftReference) ne maintient pas une fuite : le GC peut le récupérer, dès la prochaine collecte pour une weak, et quand la mémoire devient rare pour une soft. MAT propose cette exclusion en un clic, et elle écarte beaucoup de faux positifs, puisque certains caches reposent justement sur ce type de références.
Un cas concret, de bout en bout
Reprenons l’exemple des Session, parce qu’il revient souvent. Le scénario type : un service web qui fonctionne en test, puis qui meurt en prod au bout de quelques jours, toujours avec un OutOfMemoryError sur la heap.
Première étape, la courbe. Sur le dashboard, le niveau bas de la heap après full GC monte lentement mais régulièrement, jour après jour. La fuite est confirmée. Deuxième étape, l’histogramme avec jmap -histo:live. En tête, plus d’un million de Session, et des HashMap$Node par millions. On tient le quoi.
Troisième étape, on déclenche un dump avec jcmd ... GC.heap_dump, on rapatrie le .hprof sur un poste d’analyse, et on l’ouvre dans MAT. Le dominator tree montre un seul objet en haut : une HashMap qui retient à elle seule 85 % de la heap. Quatrième étape, clic droit, path to GC roots. La chaîne s’affiche : la map est un champ static d’une classe SessionRegistry. Chaque session est enregistrée à la connexion et jamais retirée à la déconnexion. La map ne fait que grossir.
Le correctif tient en deux lignes : retirer la session à la fermeture, et poser une expiration de sécurité. Sans le heap dump, le problème aurait pu occuper plusieurs jours. Avec lui, une demi-heure entre la capture et le coupable.
Les fuites qu’on retrouve tout le temps
Au bout de quelques diagnostics, ce sont les mêmes coupables qui reviennent.
- Une collection statique qu’on ne vide jamais. Un
static Mapoustatic Listqui sert de cache maison, sans limite de taille ni politique d’éviction. Il grossit à chaque requête et rien ne le réduit. C’est la fuite la plus fréquente, et de loin. - Un cache sans borne. Le même problème, en version assumée : un cache qu’on a oublié de plafonner. La parade est connue : une
LinkedHashMapen mode LRU, ou une vraie librairie comme Caffeine ou Guava, avec une taille maximale et une expiration. - Un
ThreadLocalqu’on ne nettoie pas. Sur un pool de threads, les threads sont réutilisés indéfiniment. Une valeur posée dans unThreadLocalet jamais retirée avecremove()reste rattachée au thread pour toute sa durée de vie. Multipliée par la taille du pool, elle s’accumule. - Un
ClassLoaderretenu. Le cas classique des serveurs d’applications redéployés à chaud. Une seule référence statique, un thread, ou unThreadLocalqui pointe vers une classe de l’ancienne version empêche de décharger tout son ClassLoader, donc toutes ses classes. Le signe dans le dump : plusieurs instances du même ClassLoader, ou des classes en double. Chaque redéploiement ajoute une couche, jusqu’à l’OOM, souvent côté Metaspace plutôt que heap. - Des écouteurs jamais désinscrits. On enregistre un listener, un callback ou un observer sur un objet à longue durée de vie, et on oublie de le retirer. L’émetteur garde une référence vers l’abonné, qui ne meurt donc jamais.
Les outils
Eclipse MAT est la référence pour l’analyse. Il lit un fichier .hprof, construit le dominator tree, fournit le chemin vers les GC roots, et génère un rapport automatique, le Leak Suspects, qui pointe d’emblée les objets à retained heap anormale. Pour les cas difficiles, son langage de requête OQL permet d’interroger le dump comme une base de données, par exemple « toutes les HashMap de plus de 100 000 entrées ».
VisualVM ouvre aussi les .hprof et suffit pour une analyse rapide ; il est pratique pour combiner suivi en direct et dump. Un point à anticiper, valable pour les deux : MAT comme VisualVM indexent le dump en mémoire pour l’explorer. Ouvrir un dump de 8 Go demande une machine d’analyse bien dotée. Faites-le sur un poste dédié, pas sur le serveur de prod déjà sous pression.
Quelques pièges
- Capturer trop tard. Un dump pris après que l’orchestrateur a tué le conteneur (OOMKill) n’existe pas. D’où l’intérêt de
-XX:+HeapDumpOnOutOfMemoryError, qui écrit le fichier avant la mort du process. - Oublier le
live. Sans le modelive, le dump contient des objets déjà morts mais pas encore collectés. Ils gonflent l’analyse et créent de faux suspects. Pour une fuite, on veut le full GC d’abord. - Confondre heap et Metaspace.
OutOfMemoryError: Metaspacen’est pas une fuite de heap. Ce sont des métadonnées de classes qui s’accumulent, typiquement le signe d’un ClassLoader retenu. Le dump aide, mais la lecture diffère. - En conteneur,
jcmdetjmapdoivent tourner dans le même namespace PID que la JVM, et le fichier doit être écrit sur un volume monté pour survivre à la mort du conteneur. Le PID vu depuis l’hôte n’est pas celui vu dans le conteneur.
Pour aller plus loin
Le heap dump répond à la question « qui retient la mémoire et pourquoi le GC ne la libère pas ». Quand la question devient « pourquoi les pauses GC s’allongent » ou « est-ce vraiment une fuite ou un GC mal réglé », il faut regarder le réglage du ramasse-miettes : voir Régler le GC de la JVM. Et quand l’application est figée au lieu d’être pleine, c’est du côté des threads qu’il faut chercher : Lire un thread dump avec jstack.