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(sansjavac), la classe est chargée en mémoire par le lanceur de fichiers source, et MAT désigne le threadmainau 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 :

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](/blog/heap-dump-java-eclipse-mat/images/mat-path-to-gc-roots_hu_d172816d91821ee1.webp)
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).