Ottimizzazione delle prestazioni della JVM, parte 3: Garbage collection

Il meccanismo di garbage collection della piattaforma Java aumenta notevolmente la produttività degli sviluppatori, ma un garbage collector mal implementato può consumare troppo le risorse dell’applicazione. In questo terzo articolo della serie sull’ottimizzazione delle prestazioni della JVM, Eva Andreasson offre ai principianti di Java una panoramica del modello di memoria della piattaforma Java e del meccanismo di GC. Spiega poi perché la frammentazione (e non la GC) è il principale “gotcha!” delle prestazioni delle applicazioni Java, e perché la garbage collection generazionale e la compattazione sono attualmente i principali (anche se non i più innovativi) approcci alla gestione della frammentazione dell’heap nelle applicazioni Java.

Garbage collection (GC) è il processo che mira a liberare la memoria occupata che non è più referenziata da nessun oggetto Java raggiungibile, ed è una parte essenziale del sistema di gestione dinamica della memoria della Java virtual machine (JVM). In un tipico ciclo di garbage collection tutti gli oggetti che sono ancora referenziati, e quindi raggiungibili, vengono mantenuti. Lo spazio occupato dagli oggetti precedentemente referenziati viene liberato e recuperato per permettere l’allocazione di nuovi oggetti.

Per capire la garbage collection e i vari approcci e algoritmi di GC, devi prima sapere alcune cose sul modello di memoria della piattaforma Java.

Garbage collection e il modello di memoria della piattaforma Java

Quando specificate l’opzione di avvio -Xmx sulla linea di comando della vostra applicazione Java (per esempio: java -Xmx:2g MyApp) la memoria viene assegnata a un processo Java. Questa memoria viene chiamata heap Java (o semplicemente heap). Questo è lo spazio di indirizzi di memoria dedicato dove tutti gli oggetti creati dal vostro programma Java (o talvolta dalla JVM) saranno allocati. Man mano che il vostro programma Java continua a funzionare e ad allocare nuovi oggetti, l’heap Java (cioè lo spazio di indirizzi) si riempirà.

Finalmente, l’heap Java sarà pieno, il che significa che un thread allocatore non è in grado di trovare una sezione consecutiva abbastanza grande di memoria libera per l’oggetto che vuole allocare. A quel punto, la JVM determina che deve avvenire una garbage collection e la notifica al garbage collector. Una garbage collection può essere attivata anche quando un programma Java chiama System.gc(). Usare System.gc() non garantisce una garbage collection. Prima che qualsiasi garbage collection possa iniziare, un meccanismo GC determinerà prima se è sicuro iniziarla. È sicuro iniziare una garbage collection quando tutti i thread attivi dell’applicazione sono in un punto sicuro che lo permetta, ad esempio spiegando semplicemente che sarebbe brutto iniziare la garbage collection nel mezzo di un’allocazione di un oggetto in corso, o nel mezzo dell’esecuzione di una sequenza di istruzioni ottimizzate della CPU (vedi il mio precedente articolo sui compilatori), poiché si potrebbe perdere il contesto e quindi incasinare i risultati finali.

Un garbage collector non dovrebbe mai recuperare un oggetto referenziato attivamente; farlo violerebbe le specifiche della macchina virtuale Java. Un garbage collector non è anche tenuto a raccogliere immediatamente gli oggetti morti. Gli oggetti morti sono eventualmente raccolti durante i successivi cicli di garbage collection. Mentre ci sono molti modi per implementare la garbage collection, questi due presupposti sono veri per tutte le varietà. La vera sfida della garbage collection è identificare tutto ciò che è vivo (ancora referenziato) e recuperare qualsiasi memoria non referenziata, ma farlo senza impattare le applicazioni in esecuzione più del necessario. Un garbage collector ha quindi due mandati:

  1. Per liberare rapidamente la memoria non referenziata al fine di soddisfare il tasso di allocazione di un’applicazione in modo che non rimanga senza memoria.
  2. Per recuperare la memoria mentre impatta minimamente sulle prestazioni (es, latenza e throughput) di un’applicazione in esecuzione.

Due tipi di garbage collection

Nel primo articolo di questa serie ho toccato i due approcci principali alla garbage collection, che sono il reference counting e il tracing collector. Questa volta approfondirò ulteriormente ogni approccio e poi introdurrò alcuni degli algoritmi utilizzati per implementare i tracing collector negli ambienti di produzione.

Leggi la serie sull’ottimizzazione delle prestazioni della JVM

  • Ottimizzazione delle prestazioni della JVM, parte 1: Panoramica
  • Ottimizzazione delle prestazioni della JVM, Parte 2: Compilatori

Collettori di conteggio dei riferimenti

I collettori di conteggio dei riferimenti tengono traccia di quanti riferimenti puntano a ciascun oggetto Java. Una volta che il conteggio per un oggetto diventa zero, la memoria può essere immediatamente recuperata. Questo accesso immediato alla memoria recuperata è il principale vantaggio dell’approccio al garbage collection basato sul conteggio dei riferimenti. C’è molto poco overhead quando si tratta di mantenere la memoria senza riferimenti. Mantenere tutti i conteggi di riferimento aggiornati può essere abbastanza costoso, tuttavia.

La principale difficoltà con i collettori di conteggio dei riferimenti è mantenere i conteggi di riferimento accurati. Un’altra sfida ben nota è la complessità associata alla gestione delle strutture circolari. Se due oggetti si referenziano a vicenda e nessun oggetto vivo si riferisce a loro, la loro memoria non sarà mai rilasciata. Entrambi gli oggetti rimarranno per sempre con un conteggio non nullo. Il recupero della memoria associata alle strutture circolari richiede un’analisi importante, che porta un costoso overhead all’algoritmo, e quindi all’applicazione.

Collettori di tracciamento

I collettori di tracciamento sono basati sull’assunzione che tutti gli oggetti vivi possono essere trovati tracciando iterativamente tutti i riferimenti e i riferimenti successivi da un insieme iniziale di oggetti noti per essere vivi. L’insieme iniziale di oggetti vivi (chiamati oggetti radice o solo radici in breve) sono localizzati analizzando i registri, i campi globali e i frame dello stack nel momento in cui viene attivata una garbage collection. Dopo che un insieme iniziale di oggetti vivi è stato identificato, il raccoglitore di tracciamento segue i riferimenti da questi oggetti e li mette in coda per essere marcati come vivi e successivamente avere i loro riferimenti tracciati. Contrassegnare tutti gli oggetti referenziati trovati come vivi significa che l’insieme vivo conosciuto aumenta nel tempo. Questo processo continua fino a quando tutti gli oggetti referenziati (e quindi tutti i live) sono trovati e marcati. Una volta che il raccoglitore di tracciamento ha trovato tutti gli oggetti vivi, recupera la memoria rimanente.

I raccoglitori di tracciamento differiscono dai raccoglitori che contano i riferimenti in quanto possono gestire strutture circolari. Il problema con la maggior parte dei collettori di tracciamento è la fase di marcatura, che comporta un’attesa prima di poter recuperare la memoria non referenziata.

I collettori di tracciamento sono più comunemente usati per la gestione della memoria nei linguaggi dinamici; sono di gran lunga i più comuni per il linguaggio Java e sono stati provati commercialmente in ambienti di produzione per molti anni. Mi concentrerò sui collettori di tracciamento per il resto di questo articolo, iniziando con alcuni degli algoritmi che implementano questo approccio alla garbage collection.

Algoritmi del collettore di tracciamento

La copia e la garbage collection mark-and-sweep non sono nuove, ma sono ancora i due algoritmi più comuni che implementano la garbage collection di tracciamento oggi.

Collettori a copia

I collettori a copia tradizionali usano un from-space e un to-space — cioè due spazi di indirizzo dell’heap definiti separatamente. Al punto di garbage collection, gli oggetti vivi all’interno dell’area definita come from-space sono copiati nel prossimo spazio disponibile all’interno dell’area definita come to-space. Quando tutti gli oggetti vivi all’interno del from-space sono spostati fuori, l’intero from-space può essere recuperato. Quando l’allocazione ricomincia inizia dalla prima posizione libera nel to-space.

Nelle vecchie implementazioni di questo algoritmo il from-space e il to-space si scambiano di posto, il che significa che quando il to-space è pieno, la garbage collection viene attivata di nuovo e il to-space diventa il from-space, come mostrato nella Figura 1.

Figura 1. Una tradizionale sequenza di garbage collection con copia (clicca per ingrandire)

Implementazioni più moderne dell’algoritmo di copia permettono di assegnare come to-space e from-space spazi di indirizzi arbitrari all’interno dell’heap. In questi casi non devono necessariamente cambiare posizione l’uno con l’altro; piuttosto, ognuno diventa un altro spazio di indirizzo all’interno dell’heap.

Un vantaggio dei collettori di copia è che gli oggetti sono allocati insieme strettamente nel to-space, eliminando completamente la frammentazione. La frammentazione è un problema comune con cui altri algoritmi di garbage collection lottano; qualcosa che discuterò più avanti in questo articolo.

Svantaggi dei collettori di copia

I collettori di copia sono di solito collettori stop-the-world, il che significa che nessun lavoro dell’applicazione può essere eseguito finché la garbage collection è in ciclo. In un’implementazione stop-the-world, più grande è l’area da copiare, maggiore sarà l’impatto sulle prestazioni dell’applicazione. Questo è uno svantaggio per le applicazioni che sono sensibili al tempo di risposta. Con un collettore di copia devi anche considerare lo scenario peggiore, quando tutto è live nello spazio from. Devi sempre lasciare abbastanza spazio per lo spostamento degli oggetti vivi, il che significa che il to-space deve essere abbastanza grande da ospitare tutto ciò che si trova nel from-space. L’algoritmo di copia è leggermente inefficiente in termini di memoria a causa di questo vincolo.

Collettori mark-and-sweep

La maggior parte delle JVM commerciali distribuite in ambienti di produzione aziendale eseguono collettori mark-and-sweep (o marking), che non hanno l’impatto sulle prestazioni dei collettori di copia. Alcuni dei più famosi collettori di marcatura sono CMS, G1, GenPar, e DeterministicGC (vedi Risorse).

Un collettore mark-and-sweep traccia i riferimenti e marca ogni oggetto trovato con un bit “live”. Di solito un bit impostato corrisponde ad un indirizzo o in alcuni casi ad un insieme di indirizzi sull’heap. Il bit live può, per esempio, essere memorizzato come un bit nell’intestazione dell’oggetto, un vettore di bit o una mappa di bit.

Dopo che tutto è stato marcato live, inizia la fase di sweep. Se un collettore ha una fase di sweep, include fondamentalmente qualche meccanismo per attraversare nuovamente l’heap (non solo l’insieme live ma l’intera lunghezza dell’heap) per localizzare tutti i pezzi non marcati di spazi di indirizzi di memoria consecutivi. La memoria non contrassegnata è libera e recuperabile. Il collettore collega quindi questi pezzi non marcati in liste libere organizzate. Ci possono essere varie liste libere in un garbage collector — di solito organizzate per dimensioni di chunk. Alcune JVM (come JRockit Real Time) implementano collettori con euristica che dinamicamente organizzano liste di dimensioni in base ai dati di profiling dell’applicazione e alle statistiche sulle dimensioni degli oggetti.

Quando la fase di sweep è completa, l’allocazione ricomincia. Le nuove aree di allocazione sono allocate dalle liste libere e i chunks di memoria potrebbero essere abbinati alle dimensioni degli oggetti, alle medie delle dimensioni degli oggetti per ID di thread, o alle dimensioni TLAB regolate dall’applicazione. Adattare lo spazio libero più strettamente alla dimensione di ciò che l’applicazione sta cercando di allocare ottimizza la memoria e potrebbe aiutare a ridurre la frammentazione.

Più informazioni sulle dimensioni TLAB

TLAB e il partizionamento TLA (Thread Local Allocation Buffer o Thread Local Area) sono discussi in ottimizzazione delle prestazioni della JVM, Parte 1.

Svantaggi dei collettori mark-and-sweep

La fase di mark dipende dalla quantità di dati live sul vostro heap, mentre la fase di sweep dipende dalla dimensione dello heap. Dal momento che dovete aspettare che entrambe le fasi di mark e sweep siano completate per recuperare la memoria, questo algoritmo causa problemi di tempo di pausa per heap più grandi e set di dati live più grandi.

Un modo in cui potete aiutare le applicazioni che consumano molta memoria è quello di usare opzioni di tuning di GC che si adattano a vari scenari e necessità delle applicazioni. Il tuning può, in molti casi, aiutare almeno a posticipare una di queste fasi dal diventare un rischio per la vostra applicazione o per gli accordi sul livello di servizio (SLA). (Uno SLA specifica che l’applicazione soddisferà determinati tempi di risposta dell’applicazione, cioè la latenza). La messa a punto per ogni cambiamento di carico e modifica dell’applicazione è un compito ripetitivo, tuttavia, poiché la messa a punto è valida solo per un carico di lavoro e un tasso di allocazione specifici.

Implementazioni di mark-and-sweep

Ci sono almeno due approcci disponibili in commercio e provati per implementare la raccolta mark-and-sweep. Uno è l’approccio parallelo e l’altro è l’approccio concorrente (o per lo più concorrente).

Collettori paralleli

La raccolta parallela significa che le risorse assegnate al processo sono usate in parallelo allo scopo della raccolta dei rifiuti. La maggior parte dei collettori paralleli implementati commercialmente sono collettori monolitici stop-the-world — tutti i thread dell’applicazione sono fermati fino a quando l’intero ciclo di garbage collection è completo. Fermare tutti i thread permette a tutte le risorse di essere efficientemente usate in parallelo per finire la garbage collection attraverso le fasi di mark e sweep. Questo porta ad un livello molto alto di efficienza, che di solito si traduce in punteggi elevati nei benchmark di throughput come SPECjbb. Se il throughput è essenziale per la vostra applicazione, l’approccio parallelo è una scelta eccellente.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.