En Java, le garbage collector libère les objets que plus rien ne référence. Une fuite mémoire, c’est donc un objet devenu inutile mais encore référencé : le GC ne peut pas le libérer. Le guide d’Oracle le dit ainsi : l’application garde des références à des objets « sans le vouloir ».
Voici six fuites classiques, chacune reproduite par un petit programme du dépôt public java-gc-lab, avec ce qu’on voit et la correction.
Ce qui retient un objet
Le GC part de « racines » : les champs statiques des classes chargées, les variables locales des threads en cours… Tout objet qu’on peut atteindre depuis une racine reste en vie. Une fuite, c’est un chemin qu’on a oublié de couper.
| Cas | Ce qui retient l’objet | Correction |
|---|---|---|
| Collection statique | un champ static | retirer les éléments, ou ne pas les garder là |
| Cache sans limite | la map du cache | une taille maximale ou une durée de vie |
| Listeners | la liste des listeners du bus d’événements | retirer le listener |
ThreadLocal dans un pool | le thread du pool, qui ne s’arrête jamais | remove() à la fin de la tâche |
| Class loaders | un objet de l’ancienne version | lâcher toutes les références à l’ancienne version |
| Ressources pas fermées | rien… mais le fichier reste ouvert | try-with-resources |
Comment voir une fuite
Premier signe : le heap après chaque GC monte d’un GC à l’autre. C’est expliqué avec de vrais logs dans l’article sur les OutOfMemoryError.
Pour savoir quel objet fuit, on prend deux histogrammes des classes, à quelques secondes ou minutes d’intervalle, sur l’application qui tourne :
jcmd <pid> GC.class_histogram | head
La classe dont le nombre d’instances monte, c’est la fuite. Attention :
cette commande lance d’abord un Full GC, à éviter en pleine charge sur
une production sensible. Chaque programme de java-gc-lab fuit
lentement (environ 30 secondes) et affiche son numéro de processus,
pour laisser le temps de le faire.
Les six cas
1. Une collection statique
private static final List<Order> AUDIT = new ArrayList<>();
// à chaque requête :
AUDIT.add(new Order());
Un champ static vit aussi longtemps que sa classe, donc toute la
liste aussi. L’histogramme, après 5 s puis après 20 s :
48: 368 5888 StaticCollection$Order
19: 1584 25344 StaticCollection$Order
Correction : retirer les éléments quand ils ne servent plus, ou ne pas les garder dans une collection statique.
2. Un cache sans limite
private static final Map<String, Report> CACHE = new HashMap<>();
// une nouvelle clé à chaque requête : le cache ne fait que grossir
CACHE.put(requestId, new Report());
48: 378 6048 UnboundedCache$Report
19: 1592 25472 UnboundedCache$Report
Correction : une taille maximale. Avec le JDK seul, une LinkedHashMap
qui redéfinit removeEldestEntry retire la plus ancienne entrée quand
la taille dépasse un maximum ; la javadoc la présente justement pour un
cache.
3. Des listeners jamais retirés
Screen screen = new Screen();
BUS.register(screen);
// l'écran est fermé, mais jamais retiré du bus
48: 375 6000 Listeners$Screen
19: 1592 25472 Listeners$Screen
Le bus d’événements vit longtemps, et il garde chaque écran. Correction : retirer le listener quand l’écran est fermé.
4. Un ThreadLocal dans un pool de threads
pool.submit(() -> {
BUFFER.get().add(new Record());
// pas de BUFFER.remove()
});
48: 376 6016 ThreadLocalInPool$Record
20: 1585 25360 ThreadLocalInPool$Record
D’après la javadoc, chaque thread garde sa copie d’un ThreadLocal tant
qu’il est en vie. Les threads d’un pool ne s’arrêtent jamais : leurs
buffers grossissent sans fin. Correction : BUFFER.remove() à la fin de
chaque tâche, dans un finally.
5. Des class loaders
ClassLoader loader = new ClassLoader(parent) { };
PLUGINS.add(Proxy.newProxyInstance(loader, interfaces, handler));
Chaque « rechargement » d’un plugin crée un class loader et une nouvelle classe. La liste garde un objet de chaque version, donc aucun ancien class loader ne peut être libéré. Ici, le heap grossit peu : c’est le Metaspace qui grossit.
jcmd <pid> VM.metaspace | grep "Total Usage"
Total Usage - 393 loaders, 3538 classes (1261 shared):
Total Usage - 1515 loaders, 4661 classes (1261 shared):
Avec une limite, cela finit en OutOfMemoryError: Metaspace (voir
l’article sur les OutOfMemoryError).
Correction : lâcher toutes les références à l’ancienne version
(objets, classes, threads, listeners) avant de recharger.
6. Des ressources pas fermées
FileInputStream in = new FileInputStream(file);
in.read();
// pas de close()
Ce cas est différent. Le flux n’est gardé nulle part, donc ce n’est pas
une fuite du heap : dans mes tests, quand un GC passe, le JDK ferme les
fichiers des flux qu’il libère (jcmd <pid> GC.run : d’environ 830
fichiers ouverts à environ 90). Mais ce programme ne crée presque pas
de déchets : aucun GC ne passe, et les fichiers ouverts s’accumulent
(lsof : environ 400 après 5 s, 2 000 après 25 s). Avec une limite de
512 fichiers ouverts (dans Docker) :
java.io.FileNotFoundException: /tmp/java-gc-lab8035917117579278064.txt (Too many open files)
Le heap va très bien, et pourtant l’application tombe. Correction :
try-with-resources. La javadoc d’AutoCloseable le dit : il assure
une libération rapide et évite d’épuiser les ressources.
Éviter les fuites
- En revue de code, chercher les champs
staticqui contiennent des collections, les collections sans taille maximale, lesregistersansunregister, lesThreadLocalsansremove(), les flux ouverts hors d’untry-with-resources. - Donner une limite à tout ce qui grossit : cache, file d’attente, buffer.
- Les références faibles : une
WeakHashMapretire une entrée quand sa clé n’est plus utilisée ailleurs. Piège noté dans la javadoc : si la valeur référence sa propre clé, l’entrée ne part jamais. - Surveiller le heap après GC en production : une fuite lente se voit sur des heures, pas sur une minute.
Testé avec Java 8 (compilation, et le cas des fichiers), 11, 17, 21 et 25 (Temurin
11.0.32, 17.0.20, 21.0.9, 25.0.4), sur macOS ; le cas des fichiers aussi
dans Docker (eclipse-temurin:8 et :25).
Sources : le
guide de dépannage d’Oracle sur les fuites mémoire,
la documentation de jcmd,
et la javadoc JDK 25 de
ThreadLocal,
LinkedHashMap,
WeakHashMap
et AutoCloseable.