Java Garbage Collector

Java Garbage Collector: un’introduzione ai diversi algoritmi

 

Introduzione

In questo articolo descriviamo una situazione problematica in cui, durante lo sviluppo di un progetto in AIknow, ci siamo imbattuti e che ci ha spinto ad approfondire il funzionamento del Garbage Collector, nonché le differenze tra i diversi algoritmi esistenti per il Garbage Collecting. Infine, vedremo come siamo riusciti a risolvere il problema.

 

Il caso d’uso

Per un nostro cliente che opera nel settore delle telecomunicazioni, abbiamo realizzato un’applicazione web che implementa la funzionalità di dispatcher per tutte le comunicazioni scambiate all’interno di una rete radio. Il backend, sviluppato in Java, deve essere in grado di elaborare una grande quantità di messaggi quando la rete radio è composta da centinaia o addirittura migliaia di radio (alcune installazioni processano oltre 300 messaggi al minuto). Di conseguenza, l’applicazione consuma grandi quantità di memoria, rendendo necessario un Garbage Collector molto rapido nel liberare lo spazio inutilizzato nell’Heap Space.

 

Il problema

In situazioni di stress applicativo, abbiamo riscontrato due problemi gravi:

  • Eccezione OutOfMemoryException: viene lanciata quando l’applicazione tenta di utilizzare più memoria di quella assegnata
  • Blocco dell’applicazione: l’applicazione Java rimaneva bloccata per circa 15 secondi, senza produrre log o output.
Questo secondo punto era particolarmente critico, poiché tutti i messaggi ricevuti durante quei 15 secondi di inattività venivano completamente persi.

 

La nostra indagine

Per risolvere l’eccezione OOM, abbiamo analizzato l’utilizzo della memoria da parte dell’applicazione.

Abbiamo creato un ambiente di test con un dataset simile a quello di produzione, generando heap dump della JVM a intervalli regolari e analizzandoli con Eclipse MAT. Grazie al rilevatore automatico di anomalie di MAT, abbiamo ottenuto grafici come quello mostrato in figura e individuato un memory leak che causava un utilizzo improprio della memoria.

 

Per quanto riguarda il problema del blocco, era necessario monitorare in tempo reale l’utilizzo della memoria, sperando di rilevare comportamenti anomali durante i blocchi.

Prima di mostrare i risultati delle nostre ricerche, è utile introdurre alcune definizioni riguardanti la struttura dell’Heap e il funzionamento del Garbage Collector, dato che nell’ultima parte dell’articolo useremo terminologia più specifica.

 

Come è strutturato il Java Heap Space

L’Heap Space è la porzione di memoria fisica utilizzata da Java per allocare dinamicamente oggetti e classi durante l’esecuzione dell’applicazione. Il Garbage Collector interviene periodicamente per liberare memoria.

In teoria, il Garbage Collector analizza tutti gli oggetti nell’Heap Space ancora raggiungibili da riferimenti attivi; tutti gli altri vengono considerati spazzatura e rimossi (operazione chiamata collection).
Nella pratica, però, si usa un approccio diverso. Empiricamente si osserva che la maggior parte degli oggetti Java ha vita breve, mentre pochi hanno vita lunga. Da ciò nasce la suddivisione dell’Heap in Generations, dove gli oggetti sono collocati in base alla loro età:
  1. Young Generation
    Qui vengono allocati i nuovi oggetti. Quando questa area si riempie, viene eseguita una minor collection. La Young Generation è ulteriormente suddivisa in Eden Space e Survivor Space. Un oggetto che sopravvive a un certo numero di minor collections viene promosso da Eden a Survivor, e successivamente alla Old Generation.
  2. Old Generation
    Contiene gli oggetti con vita più lunga, chiamata anche Tenured Space. Quando questa area si riempie, si avvia una major collection, che ripulisce l’intero Heap. Le major collection sono meno frequenti ma molto più costose dal punto di vista computazionale

L’obiettivo delle Generations è minimizzare la necessità di eseguire major collections.

 

Monitoraggio della memoria Heap in tempo reale

Per monitorare l’utilizzo della memoria heap in tempo reale abbiamo utilizzato Prometheus, uno strumento intuitivo che permette di visualizzare in grafici personalizzati molte metriche prodotte dalla JVM.

Nel grafico seguente è evidenziato l’utilizzo della memoria Heap delle diverse Generations:
  • giallo = Young/Eden
  • azzurro = Young/Survivor
  • rosso = Old/Tenured

aiknow-image

Analizzando i grafici, abbiamo scoperto che i blocchi dell’applicazione coincidevano con le major collections.

 

Come leggere i log del Garbage Collector

Per confermare la nostra ipotesi, abbiamo attivato i log del Garbage Collector aggiungendo il parametro -verbose:gc al comando di avvio della JVM.

Ad esempio, abbiamo osservato:
[768327.260s][info][gc] GC(24076) Pause Full (Allocation Failure) 956M->215M(989M) 15921.040ms
Dettagli:
  • [768327.280s]: timestamp da avvio applicazione
  • [info]: livello di log
  • [gc]: indica log del Garbage Collector
  • GC(24076): identificativo del GC
  • Pause Young/Full: tipo di collection (minor o major)
  • (Allocation Failure): causa della collection (normale, indica che la JVM non ha potuto allocare memoria e quindi ha attivato la collection)
  • 956M->215M(989M): memoria in uso prima e dopo la collection, e dimensione totale dell’heap
  • 15921.040ms: durata della collection (circa 15 secondi)

 

Diversi algoritmi di Garbage Collecting

Abbiamo identificato che l’algoritmo usato era il Serial. All’avvio infatti compariva:

[0.034s][info][gc] Using Serial
Il Serial è un Garbage Collector single-threaded, inefficace per grandi quantità di memoria. Come riportato dalla documentazione ufficiale:
“Il collector Serial usa un singolo thread per tutto il lavoro di garbage collecting, risultando efficiente solo su macchine con un solo processore o su applicazioni con piccoli dataset (fino a circa 100 MB).”
Era evidente che non fosse adatto alla nostra applicazione:
  • il server è multiprocessore, e quindi serve un GC multi-thread
  • il dataset è ben più grande di 100 MB

Esistono altri algoritmi multi-thread, come il G1, molto più efficienti. Nel nostro caso, utilizzando il GC G1, anche le major collections (indicate nei log come Concurrent Cycle) venivano eseguite in background e con tempi decisamente inferiori (373 ms contro i circa 15 secondi del Serial):

[1913.644s][info][gc] GC(209) Pause Young (Normal) (G1 Evacuation Pause) 220M->131M(256M) 84.159ms

[1915.310s][info][gc] GC(211) Concurrent Cycle

[1915.683s][info][gc] GC(211) Concurrent Cycle 372.997ms
Come riportato dalla documentazione:
“Il Concurrent Mark Sweep (CMS) e il Garbage-First (G1) garbage collector operano prevalentemente in parallelo, eseguendo operazioni costose simultaneamente all’applicazione. G1 è progettato per macchine multiprocessore con grande memoria, garantendo brevi pause del GC e alto throughput.”

 

Quale algoritmo scegliere per la mia applicazione?

Riassumendo:

  • dataset piccoli (fino a 100 MB): -XX:+UseSerialGC
  • esecuzione su singolo processore senza vincoli di pausa:-XX:+UseSerialGC
  • massima performance senza vincoli di pausa o pause accettabili (≥1s): -XX:+UseParallelGC o default
  • pause inferiori a circa 1 secondo e tempo di risposta prioritario: -XX:+UseG1GC o -XX:+UseConcMarkSweepGC
  • heap molto grandi e priorità alta sul tempo di risposta:-XX:+UseZGC

 

Conclusione

In questo articolo abbiamo spiegato cos’è il Garbage Collector, la struttura della memoria Heap, come attivare e interpretare i log del GC. Abbiamo quindi analizzato un caso reale che ha evidenziato l’importanza di configurare correttamente la JVM scegliendo l’algoritmo di Garbage Collection più adatto alle caratteristiche dell’applicazione Java. Infine, abbiamo illustrato le differenze principali tra i vari algoritmi di Garbage Collecting.

Hai bisogno di supporto per migliorare le prestazioni della tua applicazione Java? contattaci