Algoritmi GC in Java
Confronta i principali garbage collector Java — Serial, Parallel, G1, ZGC e Shenandoah — e i loro compromessi.
La JVM ti libera dalla deallocazione manuale della memoria: un garbage collector (GC) gira in background, individua gli oggetti che il programma non può più raggiungere e ne recupera lo spazio. Ma "il garbage collector" non è una cosa sola. La JVM HotSpot include diversi collector, ognuno dei quali stabilisce un compromesso diverso tra throughput (quanta CPU va alla tua applicazione invece che al GC), latenza (per quanto tempo l'applicazione si ferma mentre il GC lavora) e footprint (quanto overhead di memoria richiede il collector stesso).
Scegliere bene è importante. Un job batch che elabora dati tutta la notte vuole il massimo throughput e non si preoccupa delle pause; un sistema di trading o un web server vuole le pause più brevi possibili anche se il throughput totale cala un po'. Questo capitolo confronta i collector in produzione e mostra cosa hanno tutti in comune: un oggetto vive esattamente finché è raggiungibile.
Come i collector decidono cosa mantenere
Ogni collector HotSpot risponde alla stessa domanda — quali oggetti sono ancora in uso — nello stesso modo: traccia la raggiungibilità a partire da un insieme di GC root (variabili locali nello stack, campi statici, thread attivi). Qualsiasi oggetto raggiungibile da una root è vivo; tutto il resto è spazzatura. Lo scope e l'età sono irrilevanti; conta solo la raggiungibilità. Per il quadro più ampio di come questo si inserisce nel runtime, vedi Java Garbage Collection e Stack vs Heap.
public class Reachability {
public static void main(String[] args) {
String a = new String("kept"); // reachable via local variable 'a'
String b = new String("dropped"); // reachable via 'b'...
b = null; // ...until now: "dropped" is unreachable
System.out.println(a); // 'a' is still a GC root reference
}
}Nel momento in cui viene eseguito b = null, l'oggetto "dropped" non ha alcun percorso da nessuna root e diventa eleggibile per la raccolta. Il collector può recuperarlo immediatamente, molto più tardi, o — se il programma termina prima — mai. Non chiami mai free; smetti semplicemente di fare riferimento all'oggetto.
Layout generazionale dell'heap
La maggior parte degli oggetti Java muore giovane. I collector sfruttano questo con un heap generazionale: i nuovi oggetti atterrano nella young generation (Eden più due spazi survivor), e gli oggetti che sopravvivono a diverse raccolte vengono promossi alla old generation. Raccogliere frequentemente la piccola young generation, ricca di spazzatura, è economico; la grande old generation viene raccolta molto meno spesso.
| Regione | Cosa ci vive | Con quale frequenza viene raccolta |
|---|---|---|
| Eden | Oggetti appena allocati | Ad ogni minor GC |
| Survivor (S0/S1) | Oggetti sopravvissuti a un minor GC | Ad ogni minor GC |
| Old (tenured) | Oggetti longevi, promossi | Major / full GC |
| Metaspace | Metadati delle classi (off-heap) | Al caricamento delle classi |
Un minor GC pulisce la young generation ed è veloce; un major o full GC tocca la old generation ed è la fonte delle pause lunghe che tutti temono.
Confronto tra i collector
HotSpot ti permette di scegliere un collector con un singolo flag, e ognuno è ottimizzato per un obiettivo diverso. Raramente si cambia l'algoritmo nel codice — lo si imposta dalla riga di comando.
java -XX:+UseSerialGC MyApp # single-threaded, tiny heaps
java -XX:+UseParallelGC MyApp # throughput-oriented, multi-threaded
java -XX:+UseG1GC MyApp # balanced, the default since Java 9
java -XX:+UseZGC MyApp # sub-millisecond pauses, huge heaps
java -XX:+UseShenandoahGC MyApp # low pause, concurrent compactionLa tabella seguente è il modello mentale da tenere a mente:
| Collector | Punto di forza | Comportamento delle pause | Utilizzo tipico |
|---|---|---|---|
| Serial | Il più semplice, footprint ridotto | Stop-the-world, singolo thread | Heap piccoli, container, CLI |
| Parallel | Throughput più elevato | Stop-the-world, molti thread | Batch / elaborazione dati |
| G1 | Bilanciato, prevedibile | Prevalentemente concorrente, target di pausa | Default per uso generale |
| ZGC | Latenza molto bassa | Sub-millisecondo, concorrente | Heap da multi-GB a TB |
| Shenandoah | Latenza molto bassa | Pause indipendenti dalla dimensione dell'heap | Servizi responsive |
G1 ("Garbage-First") è il default da Java 9 in poi. Divide l'heap in regioni di uguale dimensione e raccoglie prima le regioni con più spazzatura, puntando a un obiettivo di tempo di pausa impostato con -XX:MaxGCPauseMillis=200.
Concorrente vs stop-the-world
L'asse cruciale è quando i thread dell'applicazione devono fermarsi. I collector stop-the-world (STW) (Serial, Parallel) mettono in pausa tutti i thread dell'applicazione mentre lavorano — semplice e ad alto throughput, ma la pausa cresce con l'heap. I collector concorrenti (ZGC, Shenandoah e la maggior parte di G1) svolgono la maggior parte del loro lavoro mentre i tuoi thread continuano a girare, quindi le pause rimangono brevi anche quando gli heap raggiungono gigabyte o terabyte.
# See exactly what the collector is doing and how long it pauses
java -Xlog:gc -XX:+UseG1GC MyApp
# Sample output line:
# [0.412s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 24M->5M(64M) 1.832msVale la pena decodificare quella riga di log: 24M->5M(64M) significa che l'heap utilizzato è sceso da 24 MB a 5 MB su un totale di 64 MB, e l'applicazione si è fermata per 1.832ms. Leggere l'output di -Xlog:gc è la singola abilità di tuning del GC più utile — misura prima di cambiare qualsiasi flag.
Osservare la raccolta dal codice
Non puoi invocare direttamente un algoritmo specifico da Java, ma puoi osservare la raccolta in corso. Una WeakReference ti permette di mantenere un puntatore che non tiene in vita il suo target, così puoi chiedere "questo oggetto è già stato raccolto?" La classe Runtime riporta l'utilizzo dell'heap, e System.gc() è un suggerimento — mai un comando — per eseguire una raccolta ora.
import java.lang.ref.WeakReference;
WeakReference<byte[]> ref = new WeakReference<>(new byte[1024]);
System.out.println("Before GC: " + (ref.get() != null)); // true
System.gc();
System.out.println("After GC: " + (ref.get() != null)); // usually falseL'esempio eseguibile qui sotto mette insieme questi elementi: alloca un'ondata di spazzatura, mantiene un survivor raggiungibile, osserva un oggetto irraggiungibile attraverso una weak reference e misura l'heap prima e dopo una raccolta.
Cosa ricavare dall'esecuzione:
- Il passo 2 stampa
true: l'oggetto osservato è ancora fortemente raggiungibile attraverso la variabilegarbage, quindi laWeakReferencepuò leggerlo. - Il passo 3 riporta l'heap utilizzato dopo 300.000 allocazioni di breve durata. Il numero esatto varia da un'esecuzione all'altra — un minor GC potrebbe aver già spazzato gran parte di quell'ondata a metà loop — ma crearla è esattamente il tipo di attività nella young generation che ogni collector generazionale è costruito per gestire in modo economico.
- Il passo 4 stampa
true, confermando che l'oggetto osservato è stato recuperato una volta chegarbage = nulllo ha reso irraggiungibile eSystem.gc()ha avviato una raccolta — prova che la perdita di raggiungibilità, non l'uscita dallo scope, è ciò che libera la memoria. - Il passo 5 stampa
true: ilsurvivor, ancora referenziato da una variabile locale attiva (una GC root), attraversa la raccolta intatto. - Il passo 6 mostra l'heap utilizzato che torna vicino al valore di riferimento, dimostrando che il collector restituisce lo spazio recuperato per il riutilizzo invece di essere perso dal programma.
Per ulteriori informazioni sui tipi di reference usati qui — strong, soft, weak e phantom — vedi Java References.