Ramasse-miettes Java (Garbage Collection)
Comment fonctionne le GC Java : accessibilité, tas générationnel, mark-sweep-compact, types de références et collecteurs.
En Java, vous n'appelez jamais free(). La JVM surveille chaque objet que vous allouez sur le tas et, lorsqu'un objet n'est plus accessible depuis votre programme en cours d'exécution, le ramasse-miettes (GC) récupère automatiquement sa mémoire. Vous écrivez du code qui crée des objets ; le GC nettoie discrètement derrière vous. Comprendre comment il décide ce qui est du déchet — et où il le cherche dans le tas — fait la différence entre un code qui monte en charge et un code qui se bloque sous la pression.
Cette page explique comment le GC décide ce qu'il faut conserver (accessibilité), comment le tas est organisé pour une collecte rapide, l'algorithme mark-sweep-compact, les quatre niveaux de référence, comment choisir un collecteur, et une démonstration exécutable qui rend la collecte observable.
Accessibilité et racines GC
Le GC ne cherche pas les objets dont vous avez « fini de vous servir ». Il cherche les objets qui sont encore accessibles. En partant d'un ensemble de racines GC, il suit chaque référence. Tout ce qu'il peut atteindre est vivant ; tout le reste est du déchet, que vous pensiez encore en avoir besoin ou non.
| Racine GC | Exemple |
|---|---|
| Variables locales | Une référence sur la pile d'un thread en cours d'exécution |
| Champs statiques | static final Logger LOG = ... |
| Threads actifs | Un objet Thread vivant |
| Références JNI | Objets détenus par du code natif |
Affecter null à une référence (ou la laisser sortir de la portée) ne supprime rien — cela retire simplement un chemin vers l'objet. L'objet ne devient collectable que lorsqu'il n'existe plus aucun chemin depuis une racine.
Object a = new Object(); // reachable via local variable 'a'
Object b = a; // now two references point to the same object
a = null; // still reachable through 'b' — not garbage
b = null; // now unreachable — eligible for collectionLe tas générationnel
La plupart des objets meurent jeunes — une portée de requête, une variable temporaire de boucle, une chaîne intermédiaire. La JVM exploite cette hypothèse générationnelle faible en divisant le tas en régions et en collectant la zone jeune beaucoup plus souvent que la vieille.
| Région | Contient | Collectée |
|---|---|---|
| Jeune (Eden + 2 espaces Survivor) | Objets fraîchement alloués | Fréquemment, par un minor GC rapide |
| Ancienne (Tenured) | Objets ayant survécu à plusieurs minor GC | Rarement, par un major/full GC plus lent |
| Metaspace | Métadonnées de classes (pas vos objets) | Lorsque les classloaders sont déchargés |
Les nouveaux objets atterrissent dans Eden. Un minor GC copie les quelques survivants dans un espace Survivor ; les objets qui continuent de survivre sont finalement promus vers l'ancienne génération. Comme les minor GC ne balaient que la petite région jeune, ils sont bon marché — c'est pourquoi l'allocation de courte durée en Java est rapide. (Pour connaître les différences entre la pile et le tas, voir Stack vs Heap ; pour le rôle de la JVM qui héberge le tas, voir Architecture JVM.)
Marquer, balayer, compacter
Une collecte s'exécute en phases. D'abord elle marque chaque objet accessible en parcourant le graphe depuis les racines. Ensuite elle balaie, libérant les objets non marqués. De nombreux collecteurs ajoutent une phase de compactage qui regroupe les objets survivants afin que l'espace libre soit un bloc contigu — ce qui maintient l'allocation à un simple déplacement de pointeur et évite la fragmentation.
// Pseudocode of what the collector does for you:
// 1. mark: visit(roots); for each reachable object, set live = true
// 2. sweep: for each object on the heap, if !live -> reclaim its memory
// 3. compact: move survivors next to each other, update referencesVous pouvez suggérer une collecte avec System.gc(), mais ce n'est qu'un indice — la JVM peut l'ignorer. Ne vous y fiez jamais pour la correction du programme ; traitez-le comme un outil de diagnostic, pas comme une stratégie de gestion de la mémoire.
Niveaux de référence
Toutes les références ne maintiennent pas un objet en vie de la même façon. Le package java.lang.ref vous permet d'indiquer au GC à quel point vous souhaitez qu'un objet soit conservé, ce qui constitue la base des caches sensibles à la mémoire.
| Référence | Comportement du GC |
|---|---|
Forte (affectation ordinaire =) | Jamais collectée tant qu'elle est accessible |
SoftReference | Collectée uniquement lorsque la mémoire est basse — idéale pour les caches |
WeakReference | Collectée au prochain GC dès qu'il n'y a plus de références fortes |
PhantomReference | Utilisée pour planifier un nettoyage après la collecte |
import java.lang.ref.WeakReference;
byte[] data = new byte[1024];
WeakReference<byte[]> ref = new WeakReference<>(data);
data = null; // drop the only strong reference
// After the next GC, ref.get() may return null.Les fuites mémoire se produisent quand même
Un ramasse-miettes vous libère des pointeurs pendants et des doubles libérations, mais pas des fuites. Une fuite mémoire Java est un objet que vous n'utilisez plus mais qui est encore accessible depuis une racine, de sorte que le GC doit le conserver. Le tas se remplit, le GC tourne de plus en plus souvent, et vous finissez par obtenir une OutOfMemoryError.
Les causes classiques sont toutes liées au fait d'avoir « oublié de lâcher » :
- Une collection
static(cache, liste d'écouteurs, map) à laquelle vous continuez d'ajouter des éléments sans jamais en supprimer. Les champs statiques sont des racines GC, donc tout ce qu'ils atteignent vit indéfiniment. - Des écouteurs ou des callbacks enregistrés auprès d'un objet à longue durée de vie et jamais désinscrits.
- Des clés laissées dans une
HashMaplongtemps après qu'elles ne sont plus nécessaires, car la map les référence encore.
La solution n'est pas un flag — c'est de supprimer les références lorsque vous avez terminé (retirer de la collection, désinscrire l'écouteur) ou d'utiliser une structure basée sur WeakReference telle que WeakHashMap pour que le GC puisse récupérer les entrées dès que rien d'autre ne pointe vers la clé.
finalize() est déprécié et peu fiable — la JVM peut l'exécuter tardivement ou pas du tout. Pour libérer des fichiers, des sockets ou d'autres ressources non-mémoire de façon déterministe, utilisez try-with-resources et AutoCloseable, et non le ramasse-miettes.Choisir un collecteur
La JVM HotSpot est livrée avec plusieurs collecteurs offrant différents compromis entre débit (travail total effectué) et latence (durée des pauses). Vous en choisissez un avec un flag JVM ; le collecteur par défaut depuis Java 9 est G1.
| Collecteur | Flag | Idéal pour |
|---|---|---|
| G1 (par défaut) | -XX:+UseG1GC | Latence/débit équilibrés, grands tas |
| Parallel | -XX:+UseParallelGC | Traitements batch privilégiant le débit brut |
| ZGC | -XX:+UseZGC | Très grands tas, pauses sous la milliseconde |
| Serial | -XX:+UseSerialGC | Petits tas, cœur unique ou conteneurs |
# Pick a collector and set the heap size at launch:
java -XX:+UseG1GC -Xms256m -Xmx2g MyApp
# Print what the GC is doing, with timestamps:
java -Xlog:gc* MyAppExemple concret
Le programme ci-dessous rend le comportement du GC observable. Il maintient un objet avec une référence forte, en détient un autre uniquement via une WeakReference, génère une vague de déchets à courte durée de vie, puis demande une collecte et rapporte ce qui a survécu et comment l'utilisation du tas a évolué.
Ce qu'il faut retenir de l'exécution :
- Le référent faible affiche
trueavant la collecte etfalseaprès, prouvant qu'uneWeakReferencene maintient pas son objet en vie dès qu'il n'y a plus de référence forte. - Le tableau
keptfortement référencé affichesurvived: truemême aprèsSystem.gc(), car il est accessible depuis une racine GC et le collecteur doit le préserver. - Environ 300 Mo de déchets sont alloués (
Bytes allocated as garbage: 307200000), mais le tas utilisé ne monte qu'à environ 5 Mo — les minor GC récupèrent les tableaux de boucle à courte durée de vie aussi vite qu'ils sont créés. Runtime.maxMemory()indique le plafond du tas (environ 256 Mo ici), défini par-Xmx, tandis quetotalMemory() - freeMemory()est la portion vivante utilisée qui oscille entre 3 et 5 Mo tout au long.System.gc()n'est qu'un indice, mais sur cette JVM il s'exécute bien : le tas utilisé redescend et le référent faible inaccessible est effacé plutôt que de persister.