Garbage Collection in Java
Come funziona il garbage collection in Java: raggiungibilità, heap generazionale, mark-sweep-compact, tipi di riferimento e collector disponibili.
In Java non si chiama mai free(). La JVM traccia ogni oggetto allocato sull'heap e, quando un oggetto non è più raggiungibile dal programma in esecuzione, il garbage collector (GC) ne recupera la memoria automaticamente. Tu scrivi codice che crea oggetti; il GC pulisce silenziosamente dietro di te. Capire come decide cosa è spazzatura — e dove nell'heap cerca — è la differenza tra codice che scala e codice che si blocca sotto carico.
Questa pagina illustra come il GC decide cosa conservare (raggiungibilità), come è strutturato l'heap per una raccolta efficiente, l'algoritmo mark-sweep-compact, i quattro tipi di riferimento, come scegliere un collector e un esempio eseguibile che rende la raccolta osservabile.
Raggiungibilità e GC root
Il GC non cerca gli oggetti di cui hai "finito". Cerca gli oggetti ancora raggiungibili. Partendo da un insieme di GC root, segue ogni riferimento. Tutto ciò che riesce a raggiungere è vivo; tutto il resto è spazzatura, indipendentemente dal fatto che tu pensi di averne ancora bisogno.
| GC root | Esempio |
|---|---|
| Variabili locali | Un riferimento nello stack di un thread in esecuzione |
| Campi statici | static final Logger LOG = ... |
| Thread attivi | Un oggetto Thread vivo |
| Riferimenti JNI | Oggetti detenuti da codice nativo |
Impostare un riferimento a null (o lasciarlo uscire dallo scope) non elimina nulla — rimuove solo un percorso verso l'oggetto. L'oggetto diventa raccoglibile solo quando non rimane nessun percorso da nessun root.
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 collectionL'heap generazionale
La maggior parte degli oggetti muore giovane — uno scope di richiesta, un temporaneo di ciclo, una stringa intermedia. La JVM sfrutta questa ipotesi generazionale debole dividendo l'heap in regioni e raccogliendo l'area giovane molto più spesso di quella vecchia.
| Regione | Contiene | Raccolta |
|---|---|---|
| Young (Eden + 2 spazi Survivor) | Oggetti appena allocati | Frequentemente, tramite una minor GC veloce |
| Old (Tenured) | Oggetti sopravvissuti a diverse minor GC | Raramente, tramite una major/full GC più lenta |
| Metaspace | Metadati delle classi (non i tuoi oggetti) | Quando i classloader vengono scaricati |
I nuovi oggetti finiscono in Eden. Una minor GC copia i pochi sopravvissuti in uno spazio Survivor; gli oggetti che continuano a sopravvivere vengono eventualmente promossi alla generazione vecchia. Poiché le minor GC scansionano solo la piccola regione giovane, sono economiche — ecco perché l'allocazione di oggetti a breve durata in Java è veloce. (Per come differiscono stack e heap, vedi Stack vs Heap; per il ruolo della JVM che ospita l'heap, vedi Architettura JVM.)
Mark, sweep, compact
Una raccolta si esegue in fasi. Prima marca ogni oggetto raggiungibile percorrendo il grafo dai root. Poi spazza, liberando gli oggetti non marcati. Molti collector aggiungono una fase di compattazione che sposta gli oggetti sopravvissuti uno accanto all'altro così che lo spazio libero sia un blocco contiguo — il che mantiene l'allocazione un semplice incremento di puntatore e previene la frammentazione.
// 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 referencesPuoi suggerire una raccolta con System.gc(), ma è solo un suggerimento — la JVM potrebbe ignorarlo. Non fare mai affidamento su di esso per la correttezza; trattalo come uno strumento diagnostico, non una strategia di gestione della memoria.
Forza dei riferimenti
Non ogni riferimento mantiene un oggetto vivo allo stesso modo. Il package java.lang.ref ti permette di indicare al GC quanto tieni a conservare un oggetto, che è la base delle cache sensibili alla memoria.
| Riferimento | Comportamento del GC |
|---|---|
Forte (ordinario =) | Mai raccolta mentre raggiungibile |
SoftReference | Raccolta solo quando la memoria è scarsa — buona per le cache |
WeakReference | Raccolta al prossimo GC una volta che non rimangono riferimenti forti |
PhantomReference | Usata per pianificare la pulizia dopo la raccolta |
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.I memory leak avvengono lo stesso
Un garbage collector ti libera dai dangling pointer e dai double free, ma non dai leak. Un memory leak Java è un oggetto che non usi più ma che è ancora raggiungibile da un root, quindi il GC deve conservarlo. L'heap si riempie, il GC gira sempre più spesso, e alla fine si raggiunge un OutOfMemoryError.
Le cause classiche sono tutte "ho dimenticato di mollare":
- Una collection
static(cache, lista di listener, mappa) a cui continui ad aggiungere elementi senza mai rimuoverli. I campi statici sono GC root, quindi tutto ciò che raggiungono vive per sempre. - Listener o callback registrati su un oggetto longevo e mai deregistrati.
- Chiavi rimaste in una
HashMapmolto dopo che non sono più necessarie, perché la mappa le referenzia ancora.
La soluzione non è un flag — è rilasciare i riferimenti quando hai finito (rimuovi dalla collection, deregistra il listener) oppure usare una struttura basata su WeakReference come WeakHashMap affinché il GC possa recuperare le voci non appena nulla punta più alla chiave.
finalize() è deprecato e inaffidabile — la JVM potrebbe eseguirlo tardi o non eseguirlo affatto. Per rilasciare file, socket o altre risorse non di memoria in modo deterministico, usa try-with-resources e AutoCloseable, non il garbage collector.Scegliere un collector
La JVM HotSpot include diversi collector con diversi compromessi tra throughput (lavoro totale svolto) e latenza (durata delle pause). Si sceglie con un flag JVM; il predefinito dal Java 9 è G1.
| Collector | Flag | Ottimo per |
|---|---|---|
| G1 (predefinito) | -XX:+UseG1GC | Latenza/throughput bilanciati, heap grandi |
| Parallel | -XX:+UseParallelGC | Job batch che privilegiano il throughput grezzo |
| ZGC | -XX:+UseZGC | Heap molto grandi, pause sotto il millisecondo |
| Serial | -XX:+UseSerialGC | Heap piccoli, singolo core o container |
# 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* MyAppUn esempio pratico
Il programma seguente rende osservabile il comportamento del GC. Mantiene un oggetto con un riferimento forte, ne tiene un altro solo tramite una WeakReference, genera una serie di spazzatura a breve durata, poi richiede una raccolta e riporta cosa è sopravvissuto e come è cambiato l'utilizzo dell'heap.
Cosa trarre dall'esecuzione:
- Il referente debole stampa
trueprima della raccolta efalsedopo, dimostrando che unaWeakReferencenon mantiene vivo il suo oggetto una volta che non rimangono riferimenti forti. - L'array
keptcon riferimento forte stampasurvived: trueanche dopoSystem.gc(), perché è raggiungibile da un GC root e il collector deve preservarlo. - Circa 300 MB di spazzatura viene allocata (
Bytes allocated as garbage: 307200000), eppure l'heap usato sale solo a circa 5 MB — le minor GC recuperano gli array del ciclo a breve durata man mano che vengono creati. Runtime.maxMemory()riporta il limite dell'heap (circa 256 MB qui), impostato da-Xmx, mentretotalMemory() - freeMemory()è la porzione viva usata che rimane intorno a 3–5 MB per tutta la durata.System.gc()è solo un suggerimento, ma su questa JVM viene eseguito: l'heap usato scende di nuovo e il referente debole non raggiungibile viene azzerato invece di restare in memoria.