Dans l’article sur les fuites mémoire, deux histogrammes de jcmd montraient quelle classe grossit. Mais pas qui la retient. Pour ça, il faut un heap dump : une photo de tout le heap, avec les objets et les liens entre eux.

On reprend la même fuite : UnboundedCache.java, un cache HashMap statique qui reçoit une nouvelle clé à chaque requête. On l’analyse avec Eclipse Memory Analyzer (MAT), un outil open source que le guide d’Oracle cite pour analyser les heap dumps. Toutes les commandes sont dans le dossier heap-dump du dépôt java-gc-lab.

Étape 1 : prendre le heap dump

javac -d out leaks/UnboundedCache.java
java -Xmx256m -cp out UnboundedCache
jcmd <pid> GC.heap_dump /tmp/cache.hprof

Après 20 secondes, le fichier fait 22 Mo. Sur une vraie application, il peut faire plusieurs Go : -gz=1 l’écrit compressé. On peut aussi laisser la JVM l’écrire à la première OutOfMemoryError, avec -XX:+HeapDumpOnOutOfMemoryError (voir l’article sur les OutOfMemoryError).

Deux précautions :

  • Le dump contient les valeurs des objets. MAT affiche même le texte des String. Sur une application réelle, il peut contenir des mots de passe ou des données personnelles : on ne le partage pas, et on le supprime après usage.
  • Compiler avant de lancer. Avec java UnboundedCache.java (sans javac), la classe est chargée en mémoire par le lanceur de fichiers source, et MAT désigne le thread main au lieu de la classe qui fuit. Lancée avec -cp, comme une application normale, la classe est bien désignée.

Étape 2 : Leak Suspects

Le rapport « Leak Suspects » est le premier à ouvrir. MAT y désigne les objets qui retiennent le plus de mémoire :

Le rapport Leak Suspects d’Eclipse MAT : un camembert où le suspect n°1 occupe 17,1 Mo sur 18,2 Mo, et le texte « The class UnboundedCache … occupies 17,919,200 (93.87%) bytes »

Ce qu’il faut lire : la classe UnboundedCache retient 93,87 % du heap, et la mémoire s’accumule dans un seul tableau, java.util.HashMap$Node[] : le tableau interne d’une HashMap. Le coupable est trouvé du premier coup.

Étape 3 : shallow heap et retained heap

MAT affiche deux tailles pour chaque objet :

  • le shallow heap : la mémoire de l’objet lui-même ;
  • le retained heap : la mémoire que le GC libérerait si cet objet était libéré, c’est-à-dire tout ce qu’il garde en vie.

Pour une fuite, c’est le retained heap qui compte. La classe UnboundedCache en est le meilleur exemple : son shallow heap est de 8 octets, son retained heap de 17,9 Mo.

Étape 4 : le dominator tree

Pour MAT, un objet x domine un objet y si tout chemin depuis une racine du GC vers y passe par x. Le dominator tree range les objets selon cette règle, du plus gros retained heap au plus petit :

Class Name                                    Shallow Heap   Retained Heap   Percentage
class UnboundedCache @ 0x124213cd0                       8      17,919,200       93.87%
java.lang.Object[2708] @ 0x133e00000 JNI Global     10,848         105,528        0.55%
java.lang.Object[8187] @ 0x133e71f40 JNI Global     32,768          48,424        0.25%

La première ligne écrase toutes les autres : c’est là qu’il faut creuser.

Étape 5 : le chemin vers les racines GC

Reste à savoir pourquoi un objet Report reste en vie. « Path to GC Roots » remonte de l’objet jusqu’à une racine du GC, c’est-à-dire un objet accessible depuis l’extérieur du heap, comme une classe chargée (« System Class »). On exclut les références faibles et douces : elles n’empêchent pas le GC de libérer un objet.

Le chemin vers les racines GC dans Eclipse MAT : un objet UnboundedCache$Report, retenu par un HashMap$Node, le tableau HashMap$Node[4096], la HashMap, puis le champ statique CACHE de la classe UnboundedCache, et enfin une racine de type System Class

On lit le chemin de bas en haut : la racine garde la classe UnboundedCache, son champ statique CACHE garde la HashMap, qui garde son tableau, qui garde chaque Report. C’est exactement la chaîne du schéma de l’article sur les fuites mémoire, mais cette fois trouvée dans un vrai dump.

La correction, et la courbe du heap

La correction : donner une taille maximale au cache. Dans BoundedCache.java, une LinkedHashMap retire la plus ancienne entrée au-delà de 500 avec removeEldestEntry. Le heap après chaque GC monte pendant que le cache se remplit, puis ne bouge plus :

java -Xmx256m -Xlog:gc heap-dump/BoundedCache.java
[0.348s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 48M->3M(256M) 1.559ms
[16.656s][info][gc] GC(19) Pause Young (Normal) (G1 Evacuation Pause) 156M->9M(256M) 0.828ms
[35.667s][info][gc] GC(41) Pause Young (Normal) (G1 Evacuation Pause) 156M->9M(256M) 0.984ms

La lecture de ces lignes est expliquée dans l’article sur les logs du GC.

MAT sans fenêtre

MAT a aussi un mode batch, pratique sur un serveur sans écran : il analyse le dump et écrit les rapports en HTML. Les captures de cet article viennent de ce mode :

ParseHeapDump.sh /tmp/cache.hprof org.eclipse.mat.api:suspects
ParseHeapDump.sh /tmp/cache.hprof -command=dominator_tree org.eclipse.mat.api:query

Le premier écrit cache_Leak_Suspects.zip, le second cache_Query.zip. Les commandes complètes, avec le chemin vers les racines GC, sont dans le README du dossier heap-dump. Pensez à supprimer le dump et les fichiers d’index que MAT écrit à côté.

Testé avec Java 25 (Temurin 25.0.4) et Eclipse MAT 1.17, sur macOS ; la correction aussi avec Java 11, 17 et 21.

Sources : l’aide d’Eclipse MAT (Shallow vs. Retained Heap, Dominator Tree, Garbage Collection Roots, Batch mode), le guide de dépannage d’Oracle sur les fuites mémoire et la documentation de jcmd (JDK 25).

Pour aller plus loin