Investiguer un blocage JVM avec un heap dump : générer, lire, résoudre

Avant de générer un heap dump, la première question à trancher est souvent ignorée : est-ce vraiment un problème de mémoire ? Un composant qui « se bloque » peut aussi bien être un deadlock entre threads (aucune mémoire excessive, juste des threads qui s'attendent mutuellement) qu'une dérive mémoire réelle. Ce sont deux diagnostics différents, avec deux outils différents.
D'abord, le bon outil pour le bon symptôme
| Symptôme | Outil adapté | Ce qu'il révèle |
|---|---|---|
| Threads bloqués, CPU au repos, aucune progression | Thread dump (jcmd <pid> Thread.print) | L'état de chaque thread et les verrous qu'il attend |
| Mémoire qui croît, GC de plus en plus fréquent, éventuellement OutOfMemoryError | Heap dump | Le contenu du tas, objet par objet, avec leurs références |
| CPU à 100 % sans blocage apparent | Profiling CPU (async-profiler, JFR) | Où le temps CPU est réellement dépensé |
Un heap dump sur un deadlock ne montre rien d'utile : la mémoire n'est pas le problème, les threads le sont. C'est le piège le plus fréquent chez qui découvre ces outils : lancer un heap dump par réflexe sur n'importe quel « ça ne répond plus ».
Générer un heap dump
Trois façons courantes, selon le contexte :
# À la demande, sur un process qui tourne (jcmd est l'outil recommandé aujourd'hui)
jcmd <pid> GC.heap_dump /chemin/heap.hprof
# Ne capturer que les objets vivants (exclut ce que le GC récupérerait de toute façon)
jcmd <pid> GC.heap_dump -all=false /chemin/heap-live.hprof
# Automatiquement au moment d'un OutOfMemoryError, sans intervention manuelle
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/chemin/heap-oom.hprof
Le flag HeapDumpOnOutOfMemoryError mérite d'être activé par défaut en
production : un dump pris après coup, une fois le service redémarré, ne
capture plus l'état qui a causé l'incident. Le seul moment où ce dump a de
la valeur, c'est celui où la JVM constate qu'elle ne peut plus continuer.
Lire le dump : shallow size vs retained size
Ouvrir un dump avec Eclipse Memory Analyzer (MAT) affiche une liste d'objets triable par taille, et c'est exactement là que la mauvaise lecture commence. Deux métriques coexistent, et les confondre mène droit à un faux diagnostic :
| Métrique | Ce qu'elle mesure | Piège si on l'ignore |
|---|---|---|
| Shallow size | La taille de l'objet lui-même, sans ce qu'il référence | Un HashMap vide a une shallow size minuscule, même s'il retient des gigaoctets de données via ses entrées |
| Retained size | La mémoire totale libérée si cet objet devenait inaccessible (l'objet et tout ce qu'il retient exclusivement) | C'est la métrique qui révèle vraiment où est le problème |
Trier par shallow size fait presque toujours remonter des types génériques
(char[], HashMap$Node) sans dire lequel en particulier pose problème.
Trier par retained size, via le Dominator Tree de MAT, fait remonter
l'objet racine qui retient effectivement la mémoire, indépendamment de sa
propre taille.
Un cas concret : le cache qui ne s'arrête jamais de grandir
Le rapport « Leak Suspects » de MAT désigne souvent un seul dominateur
responsable de la majorité du tas. Dans un cas fréquent, ce dominateur est
un HashMap statique utilisé comme cache applicatif, sans limite de taille
ni politique d'expiration. La fonctionnalité « Path to GC Roots » permet de
remonter la chaîne exacte de références qui empêche le garbage collector de
le libérer : typiquement, un champ statique d'un singleton Spring, référencé
depuis le classloader lui-même, donc jamais éligible au GC tant que
l'application tourne.
// Le piège : un cache qui grandit indéfiniment
private static final Map<String, ExpensiveObject> cache = new HashMap<>();
// Une direction de correction : borner explicitement la taille et la durée de vie
private static final Cache<String, ExpensiveObject> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build();
Le HashMap n'est pas fautif en soi : c'est l'absence de politique
d'éviction qui transforme un cache légitime en fuite mémoire lente, invisible
pendant des semaines jusqu'à ce que le volume de clés distinctes dépasse ce
que le tas peut absorber.
Ne pas confondre une fuite avec un pic normal
Un seul heap dump, pris à un instant donné, ne dit rien sur la tendance : une consommation mémoire élevée juste après un pic de trafic peut être parfaitement normale si elle redescend après le passage d'un GC complet. Le signal d'une vraie fuite, ce n'est pas un tas rempli, c'est un plancher mémoire (la consommation juste après un GC complet) qui augmente d'un dump à l'autre, sur plusieurs mesures espacées dans le temps, jamais un seul cliché isolé.
Ce que le heap dump ne remplace pas
Le heap dump identifie où est la mémoire, pas pourquoi le code l'a accumulée dans cette intention. Une fois la chaîne de références trouvée, la correction reste un travail de compréhension du domaine : ce cache devait-il vraiment tout garder, ou l'auteur a-t-il simplement oublié d'ajouter une politique d'expiration en l'écrivant. Le dump donne la localisation exacte du problème ; il ne donne jamais la justification métier de pourquoi la donnée était censée y rester ou pas.