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

La chaîne de références d'une fuite : une racine GC, le champ static AUDIT de la classe StaticCollection, référence une ArrayList, qui référence des objets Order ; ils restent en vie. Un objet temporaire que plus rien ne référence est libéré par le GC.Ce qui retient un objettout objet atteignable depuis une racine reste en vieRacine GCstatic AUDITArrayListla liste du champOrderOrderOrderobjetsOrderinutiles, mais atteignablesrestent en vie : la fuiteobjet temporaireplus référencé→ libéré par le GC

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.

CasCe qui retient l’objetCorrection
Collection statiqueun champ staticretirer les éléments, ou ne pas les garder là
Cache sans limitela map du cacheune taille maximale ou une durée de vie
Listenersla liste des listeners du bus d’événementsretirer le listener
ThreadLocal dans un poolle thread du pool, qui ne s’arrête jamaisremove() à la fin de la tâche
Class loadersun objet de l’ancienne versionlâcher toutes les références à l’ancienne version
Ressources pas ferméesrien… mais le fichier reste ouverttry-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 static qui contiennent des collections, les collections sans taille maximale, les register sans unregister, les ThreadLocal sans remove(), les flux ouverts hors d’un try-with-resources.
  • Donner une limite à tout ce qui grossit : cache, file d’attente, buffer.
  • Les références faibles : une WeakHashMap retire 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.

Pour aller plus loin