La JVM ha diversi tipi di implementazioni del GC:
- Serial
- Parallel
- G1
- ZGC
- Shenandoah
Ognuno di essi ha meccanismi interni e opzioni di tuning propri. Diamo loro una breve introduzione.
Serial GC
Il Serial GC è uno dei primi GC della JVM. Utilizza un singolo thread per eseguire tutto il lavoro di pulizia. La sua caratteristica fondamentale è che, durante l’esecuzione del processo di Garbage Collection, la JVM deve mettere in pausa tutti i thread dell’applicazione. Questo evento è noto come “Stop-The-World” (STW). Poiché c’è un solo thread dedicato al GC, le pause STW possono essere relativamente lunghe se l’heap (l’area di memoria) è di grandi dimensioni. D’altra parte, la sua natura a thread singolo lo rende incredibilmente leggero in termini di overhead computazionale. Non dovendo gestire la sincronizzazione o la comunicazione tra thread multipli, il Serial GC consuma pochissime risorse della CPU (e RAM) per le sue operazioni interne. Le criticità principali sono:
- Pause STW Lunghe (Latenza elevata): Se l’Heap supera il Gigabyte, il tempo necessario per marcare e compattare la memoria su un singolo thread diventa percepibile all’utente (si parla di decine o centinaia di millisecondi, a volte secondi).
- Spreco di Risorse Hardware: Su server moderni con 16, 32 o 64 core, il Serial GC ne utilizzerà solo uno per la pulizia, lasciando gli altri inattivi mentre l’intera applicazione è bloccata.
Con l’avvento del G1GC (il default da Java 9) e dei low-latency GC (ZGC e Shenandoah), si potrebbe pensare che il Serial GC sia morto. In realtà, ha trovato una “seconda giovinezza” nel cloud computing e nei microservizi. E’ consigliabile abilitare il Serial GC su:
- Microservizi Dockerizzati molto piccoli: Se il container ha limiti stringenti (es. 1 o 2 vCPU e meno di 1–2 GB di RAM), G1GC sprecherebbe troppa memoria e cicli di CPU per la sua gestione interna. Il Serial GC qui brilla per leggerezza.
- Applicazioni Client: Applicazioni desktop Java minori in cui una pausa occasionale di qualche decimo di secondo non compromette l’esperienza utente.
- Applicazioni Batch: Script o job eseguiti in background dove la latenza non è importante, ma conta completare il lavoro con il minimo spreco di risorse.
Parallel GC
Dopo il Serial GC è stato introdotto il Parallel Garbage Collector. Esso utilizza più thread per eseguire la garbage collection, al contrario del Serial che usa un unico thread. Per il resto, entrambi i collector sono di tipo Stop-The-World (STW), il che significa che l’applicazione si ferma completamente durante la pulizia. Un’altra differenza fondamentale con il Serial GC è che usa un solo core CPU lavora mentre nel Parallel GC più core CPU lavorano insieme. Tutto ciò si traduce in pause più brevi per il Parallel.
Concurrent GC
Con il tempo sono stati introdotti nuovi tipi di GC: i Concurrent GC (G1, ZGC, Shenandoah). Essi si contraddistinguono dal funzionamento diverso rispetto ai STW GC (Serial / Parallel). In particolare, un collector concorrente esegue la maggior parte del lavoro di scansione e pulizia mentre l’applicazione sta ancora girando, invece di fermarla del tutto.
Quando Passare Al Concurrent?
Concurrent GC (G1 o ZGC) se:
- Le pause superiori a 200ms causano timeout o lamentele degli utenti.
- Heap molto grandi (sopra gli 8GB-16GB), dove una pausa “Parallel” sarebbe catastrofica.
Resta sul Parallel GC se:
- L’obiettivo è finire un calcolo nel minor tempo possibile e non interessa se l’app non risponde per 2 secondi ogni ora.
- Risorse CPU limitate e non si vuole che il GC “interferisca” con l’esecuzione dell’app.
Passiamo ora all’analisi del G1, al momento il default per le JVM moderne.
G1 GC
Il G1 (Garbage First) Collector è diventato il default da Java 9. A differenza del Parallel GC, che divide lo heap in tre aree contigue giganti (Eden, Survivor, Old), il G1 frammenta l’intero Heap in centinaia o migliaia di regioni di dimensioni identiche (da 1MB a 32MB ciascuna).
- Ogni regione può essere dinamicamente assegnata alla Young Generation o alla Old Generation.
- Esistono regioni speciali chiamate Humongous, progettate per ospitare oggetti che superano il 50% della dimensione di una regione standard, evitando che finiscano direttamente nella Old Gen creando frammentazione immediata.
A differenza del vecchio Parallel Collector che puliva l’intera Old Generation ogni volta (causando lunghe pause), G1 è incrementale. Si concentra sulle regioni che offrono il massimo rendimento (più spazio recuperato con il minimo sforzo), riuscendo a mantenere le pause prevedibili e brevi.
Il Ciclo di Vita del G1
Il G1 alterna due fasi principali, operando come un collector “incrementale”:
- Young GC (Stop-The-World): Quando le regioni allocate come Eden si riempiono, G1 esegue una pausa STW. Essendo l’heap diviso in regioni, G1 può decidere di pulire solo un sottoinsieme di esse per rientrare in un target di pausa predefinito. Gli oggetti vivi vengono copiati nelle regioni Survivor o promossi nella Old Gen.
- Mixed GC: Questa è la fase che lo distingue. Invece di pulire l’intera Old Gen in una volta sola (che causerebbe una pausa enorme come nel Parallel), lo fa in diversi cicli più piccoli e veloci. G1 inizia a pulire sia le regioni della Young Gen sia alcune regioni della Old Gen. Anche in questo caso c’è una STW ma molto più breve rispetto al Parallel
In realtà c’è anche una terza fase che è meglio evitare: Full GC.
Se l’applicazione produce oggetti più velocemente di quanto G1 riesca a pulire, o se non c’è abbastanza spazio per muovere gli oggetti (Heap Fragmentation), scatta una Full GC. È un evento pesante, a thread singolo o parallelo ma molto lento, che compatta l’intero Heap. L’obiettivo della configurazione di G1 è proprio quello di evitare che si arrivi a questo punto.
ZGC
ZGC (Z Garbage Collector) rappresenta una delle innovazioni più significative introdotte recentemente nell’ecosistema Java (disponibile come “production-ready” da Java 15). È un garbage collector scalabile e a bassissima latenza, progettato per gestire heap che vanno da pochi megabyte a svariati terabyte, mantenendo i tempi di pausa inferiori al millisecondo.
L’obiettivo primario di ZGC non è il throughput (ovvero la massima quantità di lavoro svolto in un’unità di tempo), ma la prevedibilità della latenza. Detto in altre parole, ZGC garantisce pause quasi inesistenti, ma l’utilizzo continuo di risorse della CPU da parte del GC può rendere lenta complessivamente dell’applicazione.
I vantaggi principali sono:
- Pause ultra-brevi: I tempi di stop-the-world (STW) non dipendono dalla dimensione dell’heap, ma dalla dimensione del “root set” (solitamente thread stacks).
- Scalabilità: Gestisce heap fino a 16 TB.
- Concorrenza: Quasi tutto il lavoro di GC (marcatura, compattazione, gestione dei riferimenti) avviene mentre i thread dell’applicazione sono in esecuzione.
- Estremamente “self-tuning”
Gli svantaggi principali sono:
- Throughput inferiore: privilegia la latenza, non il throughput e di conseguenza si ha più CPU spesa per il GC e meno per l’applicazione da eseguire.
- Overhead CPU elevato: ZGC è sempre attivo in background. Su macchine con pochi core può essere un problema.
- Maggior uso di memoria
- Non ideale per heap piccoli
Anche se G1 è il GC predefinito dalla versione 9 ed è un ottimo “tuttofare”, ci sono scenari specifici in cui ZGC è nettamente superiore.
- Il limite dei 200ms (SLA di Latenza): G1 è progettato per tentare di rispettare un obiettivo di pausa (200ms è la latenza di default di G1), ma non lo garantisce. Sotto carichi pesanti o con heap molto grandi, le pause possono comunque schizzare a diverse centinaia di millisecondi o secondi. ZGC invece garantisce pause sotto il millisecondo (spesso nell’ordine dei microsecondi), indipendentemente dalla dimensione dell’heap.
- Dimensione dell’Heap (Scalabilità): G1 inizia a soffrire quando l’heap supera i 32–64 GB, causando pause più lunghe. ZGC è nato per gestire heap colossali, da pochi gigabyte fino a 16 Terabyte.
Nonostante i vantaggi, ZGC è da evitare se:
- Pochissima CPU: Poiché ZGC lavora costantemente in background, se il server è già al 90% di carico CPU, il GC finirà per rallentare pesantemente l’esecuzione del codice.
- Il Throughput è tutto: Se un processo che deve solo “macinare dati” e finire il prima possibile, e non importa se ogni tanto si ferma per mezzo secondo, G1 (o meglio ancora Parallel) finirà il lavoro in meno tempo totale.
SHENANDOAH
Shenandoah è un garbage collector (GC) a bassa pausa introdotto originariamente da Red Hat e disponibile in OpenJDK. Nella maggior parte dei GC tradizionali (come G1 o Parallel), la durata della pausa aumenta proporzionalmente alla quantità di dati “vivi” nell’heap. Se si ha 200 GB di dati, il tempo necessario per spostarli o scansionarli può causare lag di diversi secondi, rendendo l’applicazione lenta o non reattiva. Shenandoah risolve questo problema eseguendo quasi tutto il lavoro in parallelo all’applicazione.
Vantaggi:
- Pause Prevedibili: Le pause sono solitamente nell’ordine dei millisecondi, sia che l’heap sia di 10 GB o di 500 GB.
- Scalabilità: Ideale per applicazioni moderne che richiedono molta RAM (microservizi pesanti, database in-memory).
Svantaggi:
- Overhead CPU: Poiché il GC lavora costantemente insieme all’app, consuma cicli di CPU che altrimenti sarebbero dedicati al codice business.
- Throughput ridotto: Rispetto a un GC “Parallel”, l’applicazione potrebbe processare meno richieste totali al secondo, anche se è molto più reattiva.
Shenandoah vs ZGC
Spesso viene confrontato con ZGC (lo standard Oracle per la bassa latenza). Entrambi sono eccellenti, ma Shenandoah è spesso preferito in ambienti dove non si usa la JVM Oracle ufficiale (come le distribuzioni basate su OpenJDK di Red Hat o Amazon Corretto).
