Fuites mémoire Java : causes courantes et détection
Votre application Java démarre rapidement mais après quelques heures ou jours, elle ralentit jusqu'à l'arrêt, lance une OutOfMemoryError ou est tuée par le conteneur. Vous la redémarrez, et le cycle se répète. C'est la signature classique d'une fuite mémoire Java : des objets qui ne sont plus nécessaires mais restent accessibles, empêchant le garbage collector de les récupérer. Contrairement au C/C++, le GC de Java gère automatiquement la plupart de la mémoire, mais des fuites surviennent quand des références sont conservées involontairement. Cet article explique les causes les plus courantes et vous donne un workflow pratique pour les détecter et les corriger.
Qu'est-ce qu'une fuite mémoire Java ?
Une fuite mémoire se produit lorsque des objets ne sont plus utilisés par l'application mais sont toujours référencés, empêchant le garbage collection. Avec le temps, l'utilisation du heap augmente, le GC s'exécute plus fréquemment, et finalement la JVM lance OutOfMemoryError: Java heap space. Les fuites peuvent être petites et lentes (quelques Ko par requête) ou grandes et rapides (mise en cache de jeux de résultats entiers).
Causes courantes des fuites mémoire Java
1. Collections statiques qui grandissent indéfiniment
Les champs statiques vivent pendant toute la durée de vie de la JVM. Une Map statique utilisée comme cache sans éviction est une fuite classique. Par exemple :
public class UserCache {
private static final Map<Long, User> CACHE = new HashMap<>();
public static void put(Long id, User user) {
CACHE.put(id, user); // jamais supprimé
}
}
Chaque utilisateur ajouté reste pour toujours. Correctif : utilisez un cache borné comme Caffeine ou Guava avec des limites de taille et une expiration.
2. Ressources non fermées
Les flux, connexions et lecteurs doivent être fermés. Si vous oubliez, les ressources natives et leurs wrappers Java fuient. Utilisez toujours try-with-resources :
try (InputStream in = new FileInputStream(file)) {
// utiliser in
} // fermé automatiquement
Cela s'applique aussi aux connexions de base de données, clients HTTP et pools de threads.
3. Listeners et callbacks non supprimés
Quand vous enregistrez un listener (par ex. addListener) mais ne le supprimez jamais, l'émetteur conserve une référence vers le listener, qui peut lui-même référencer tout votre graphe d'objets. C'est courant dans les interfaces graphiques, les bus d'événements et les contextes à longue durée de vie.
4. Variables ThreadLocal
Les valeurs ThreadLocal sont stockées dans le ThreadLocalMap du thread. Dans les pools de threads, les threads sont réutilisés, donc si vous n'appelez pas remove(), la valeur reste attachée au thread et fuit. Nettoyez toujours :
try {
threadLocal.set(context);
// travail
} finally {
threadLocal.remove();
}
5. Classes internes conservant des références externes
Les classes internes non statiques (y compris les classes anonymes) détiennent une référence implicite vers l'instance externe. Si une instance de classe interne survit à l'objet externe (par ex. stockée dans une liste statique), l'objet externe ne peut pas être collecté. Rendez les classes internes statiques quand elles n'ont pas besoin de l'instance externe.
6. equals() et hashCode() incorrects
Si vous utilisez des objets comme clés dans une HashMap mais redéfinissez equals() sans hashCode() (ou inversement), les recherches échouent et les entrées s'accumulent. Redéfinissez toujours les deux de manière cohérente.
7. String interning et sous-chaînes
Avant Java 7, String.substring() partageait le tableau de caractères original, donc une petite sous-chaîne pouvait retenir un grand tableau. Le Java moderne copie, mais interner de nombreuses chaînes uniques (par ex. String.intern()) peut encore remplir le pool de chaînes.
Comment détecter les fuites mémoire Java
La détection suit un processus systématique. Voici une approche étape par étape :
- Surveillez l'utilisation du heap dans le temps. Utilisez JVisualVM, JConsole ou votre APM. Une application saine montre un motif en dents de scie : le heap grandit, le GC récupère, le heap diminue. Une fuite montre une ligne de base qui monte après chaque GC.
- Activez les logs GC. Ajoutez
-Xlog:gc*:file=gc.log:time,uptime,level,tags(Java 9+) ou-XX:+PrintGCDetails(Java 8). Analysez les logs avec des outils comme GCeasy pour voir si l'old gen continue de croître. - Capturez un heap dump. Quand le heap est élevé, exécutez
jmap -dump:live,format=b,file=heap.hprof <pid>ou utilisezjcmd <pid> GC.heap_dump heap.hprof. Vous pouvez aussi déclencher un dump sur OutOfMemoryError avec-XX:+HeapDumpOnOutOfMemoryError. - Analysez le heap dump. Ouvrez-le dans Eclipse MAT ou VisualVM. Cherchez le dominator tree pour trouver les objets qui retiennent le plus de mémoire. Vérifiez les collections suspectes avec de grandes tailles retenues.
- Prenez un second dump et comparez. Si les mêmes objets augmentent entre les dumps, vous avez trouvé la fuite.
- Utilisez un profiler pour l'analyse en direct. Des outils comme YourKit, JProfiler ou async-profiler peuvent suivre les sites d'allocation et les chaînes de références sans arrêter l'application.
Voici une comparaison rapide des outils courants :
| Outil | Type | Idéal pour |
|---|---|---|
| Eclipse MAT | Analyseur de heap dump | Trouver les dominators et suspects de fuite |
| VisualVM | Monitoring + heap dump | Vérifications rapides en direct et analyse de base |
| JProfiler / YourKit | Profiler | Suivi des allocations et chaînes de références |
| async-profiler | Profiler à faible surcoût | Profilage en production avec flame graphs |
Corriger et prévenir les fuites
Une fois la fuite identifiée, corrigez-la en supprimant la référence involontaire. Correctifs courants :
- Remplacez les caches non bornés par des caches bornés (Caffeine, Ehcache).
- Utilisez try-with-resources pour toutes les ressources Closeable.
- Désenregistrez les listeners dans un bloc finally ou utilisez des références faibles.
- Appelez toujours
ThreadLocal.remove(). - Rendez les classes internes statiques quand c'est possible.
- Redéfinissez equals() et hashCode() ensemble.
Mieux vaut prévenir que guérir. Ajoutez la surveillance mémoire à votre pipeline CI/CD, exécutez des tests de charge avec vérification du heap, et définissez -Xmx de manière appropriée. Utilisez des outils d'analyse statique comme SpotBugs pour détecter les motifs courants.
FAQ
Quelle est la différence entre une fuite mémoire et un pic mémoire ?
Un pic mémoire est une augmentation temporaire de l'utilisation du heap que le GC récupère. Une fuite est une augmentation régulière dans le temps car les objets restent accessibles et ne peuvent pas être collectés.
Une fuite mémoire Java peut-elle causer OutOfMemoryError même si le heap est grand ?
Oui. Si le taux de fuite dépasse la capacité du GC à récupérer, le heap finira par se remplir quelle que soit sa taille. Augmenter le heap ne fait que retarder la défaillance.
Comment trouver une fuite mémoire en production sans interruption ?
Utilisez des outils à faible surcoût comme async-profiler ou JFR (Java Flight Recorder) pour enregistrer les allocations et les statistiques du heap. Vous pouvez aussi déclencher un heap dump avec jcmd pendant que l'application tourne, bien que cela puisse provoquer une brève pause.
Besoin d'analyser des logs GC ou d'autres logs texte ? Essayez notre Nginx Log Analyzer pour parser et visualiser rapidement les motifs de logs.