Ottimizzazione delle prestazioni 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 eccessivamente le risorse dell'applicazione. In questo terzo articolo della serie sull'ottimizzazione delle prestazioni JVM , Eva Andreasson offre ai principianti di Java una panoramica del modello di memoria della piattaforma Java e del meccanismo GC. Spiega quindi perché la frammentazione (e non GC) è il principale "problema!" delle prestazioni delle applicazioni Java e del motivo per cui la raccolta e la compattazione generazionali dei rifiuti sono attualmente gli approcci principali (sebbene non i più innovativi) per la 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 alcun 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 vengono mantenuti tutti gli oggetti che sono ancora referenziati e quindi raggiungibili. Lo spazio occupato da oggetti precedentemente referenziati viene liberato e recuperato per consentire l'allocazione di nuovi oggetti.

Per comprendere la garbage collection e i vari approcci e algoritmi GC, è necessario prima conoscere alcune cose sul modello di memoria della piattaforma Java.

Ottimizzazione delle prestazioni JVM: leggi la serie

  • Parte 1: panoramica
  • Parte 2: compilatori
  • Parte 3: raccolta dei rifiuti
  • Parte 4: compattazione simultanea di GC
  • Parte 5: scalabilità

Garbage collection e il modello di memoria della piattaforma Java

Quando si specifica l'opzione di avvio -Xmxsulla riga di comando dell'applicazione Java (ad esempio :), la java -Xmx:2g MyAppmemoria viene assegnata a un processo Java. Questa memoria è denominata heap Java (o semplicemente heap ). Questo è lo spazio degli indirizzi di memoria dedicato in cui verranno allocati tutti gli oggetti creati dal programma Java (o talvolta dalla JVM). Man mano che il programma Java continua a essere eseguito e ad allocare nuovi oggetti, l'heap Java (ovvero lo spazio degli indirizzi) si riempirà.

Alla fine, l'heap Java sarà pieno, il che significa che un thread di allocazione non è in grado di trovare una sezione consecutiva abbastanza grande di memoria libera per l'oggetto che desidera allocare. A quel punto, la JVM determina che deve essere eseguita una garbage collection e lo notifica al garbage collector. È inoltre possibile attivare una garbage collection quando viene chiamato un programma Java System.gc(). UtilizzandoSystem.gc()non garantisce una garbage collection. Prima che qualsiasi operazione di Garbage Collection possa iniziare, un meccanismo GC determinerà innanzitutto se è sicuro avviarlo. È sicuro avviare una garbage collection quando tutti i thread attivi dell'applicazione sono in un punto sicuro per consentirlo, ad esempio spiegato semplicemente che sarebbe sbagliato avviare la garbage collection nel mezzo di un'allocazione di oggetti in corso, o nel mezzo eseguire una sequenza di istruzioni CPU ottimizzate (vedere il mio precedente articolo sui compilatori), poiché potresti perdere il contesto e quindi rovinare i risultati finali.

Un garbage collector non dovrebbe mai recuperare un oggetto referenziato attivamente; fare ciò infrangerebbe la specifica della macchina virtuale Java. Inoltre, non è necessario un garbage collector per raccogliere immediatamente gli oggetti morti. Gli oggetti inattivi vengono infine raccolti durante i successivi cicli di Garbage Collection. Sebbene ci siano 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 è attivo (ancora referenziato) e recuperare qualsiasi memoria non referenziata, ma farlo senza influire sulle applicazioni in esecuzione più del necessario. Un garbage collector ha quindi due mandati:

  1. Per liberare rapidamente memoria non referenziata al fine di soddisfare la velocità di allocazione di un'applicazione in modo che non si esaurisca la memoria.
  2. Recuperare la memoria con un impatto minimo sulle prestazioni (ad esempio, latenza e velocità effettiva) di un'applicazione in esecuzione.

Due tipi di raccolta dei rifiuti

Nel primo articolo di questa serie ho toccato i due approcci principali alla garbage collection, che sono il conteggio dei riferimenti e i raccoglitori di traccia. Questa volta approfondirò ulteriormente ogni approccio, quindi introdurrò alcuni degli algoritmi utilizzati per implementare i raccoglitori di traccia negli ambienti di produzione.

Leggi la serie sull'ottimizzazione delle prestazioni di JVM

  • Ottimizzazione delle prestazioni di JVM, Parte 1: Panoramica
  • Ottimizzazione delle prestazioni di JVM, parte 2: compilatori

Collezionisti di conteggio di riferimento

I raccoglitori di conteggio dei riferimenti tengono traccia di quanti riferimenti puntano a ciascun oggetto Java. Quando il conteggio per un oggetto diventa zero, la memoria può essere immediatamente recuperata. Questo accesso immediato alla memoria recuperata è il principale vantaggio dell'approccio del conteggio dei riferimenti alla raccolta dei rifiuti. C'è pochissimo sovraccarico quando si tratta di mantenere una memoria senza riferimento. Tuttavia, tenere aggiornati tutti i conteggi dei riferimenti può essere piuttosto costoso.

La principale difficoltà con i raccoglitori del conteggio dei riferimenti è mantenere accurati i conteggi dei riferimenti. Un'altra sfida ben nota è la complessità associata alla gestione delle strutture circolari. Se due oggetti fanno riferimento l'uno all'altro e nessun oggetto live fa riferimento a loro, la loro memoria non verrà mai rilasciata. Entrambi gli oggetti rimarranno per sempre con un conteggio diverso da zero. Recuperare la memoria associata a strutture circolari richiede un'analisi approfondita, che comporta un sovraccarico costoso per l'algoritmo e quindi per l'applicazione.

Rintracciare collezionisti

I raccoglitori di tracciamento si basano sul presupposto che tutti gli oggetti attivi possono essere trovati tracciando iterativamente tutti i riferimenti e i riferimenti successivi da un insieme iniziale di oggetti noti come oggetti attivi. Il set iniziale di oggetti live (chiamati oggetti root o semplicemente radici in breve) viene individuato analizzando i registri, i campi globali e gli stack frame nel momento in cui viene attivata una raccolta di rifiuti. Dopo che un set live iniziale è stato identificato, il raccoglitore di traccia segue i riferimenti da questi oggetti e li mette in coda per essere contrassegnati come live e successivamente tracciati i riferimenti. Contrassegnare tutti gli oggetti referenziati trovati livesignifica che il live set conosciuto aumenta nel tempo. Questo processo continua finché tutti gli oggetti referenziati (e quindi tutti attivi) non vengono trovati e contrassegnati. Una volta che il raccoglitore di tracciamento ha trovato tutti gli oggetti attivi, recupererà la memoria rimanente.

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

I raccoglitori di traccia 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 sperimentati commercialmente negli ambienti di produzione per molti anni. Mi concentrerò sulla traccia dei raccoglitori per il resto di questo articolo, iniziando con alcuni degli algoritmi che implementano questo approccio alla raccolta dei rifiuti.

Tracciare algoritmi di raccolta

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 traccia oggi.

Copia di collezionisti

I raccoglitori di copia tradizionali utilizzano uno spazio da e uno a spazio , ovvero due spazi di indirizzi dell'heap definiti separatamente. Al punto della raccolta dei rifiuti, gli oggetti live all'interno dell'area definita come dallo spazio vengono copiati nel successivo spazio disponibile all'interno dell'area definita come dallo spazio. Quando tutti gli oggetti vivi all'interno dello spazio vengono spostati, l'intero oggetto può essere recuperato. Quando l'allocazione ricomincia, inizia dalla prima posizione libera nello spazio-spazio.

Nelle implementazioni precedenti di questo algoritmo le posizioni dello switch from-space e to-space, il che significa che quando il to-space è pieno, la garbage collection viene nuovamente attivata e lo to-space diventa dallo spazio, come mostrato nella Figura 1.

Le implementazioni più moderne dell'algoritmo di copia consentono di assegnare spazi di indirizzi arbitrari all'interno dell'heap come a-spazio e da-spazio. In questi casi non devono necessariamente cambiare posizione tra loro; piuttosto, ognuno diventa un altro spazio di indirizzi all'interno dell'heap.

Un vantaggio della copia dei collector è che gli oggetti sono allocati insieme strettamente nello spazio, eliminando completamente la frammentazione. La frammentazione è un problema comune con cui altri algoritmi di garbage collection lottano; qualcosa di cui parlerò più avanti in questo articolo.

Aspetti negativi di copiare i collezionisti

I raccoglitori di copia sono solitamente raccoglitori stop-the-world , il che significa che nessun lavoro sull'applicazione può essere eseguito fintanto che la garbage collection è in ciclo. In un'implementazione stop-the-world, maggiore è l'area da copiare, maggiore sarà l'impatto sulle prestazioni dell'applicazione. Questo è uno svantaggio per le applicazioni sensibili al tempo di risposta. Con un collezionista di copie devi anche considerare lo scenario peggiore, quando tutto è vivo nello spazio. Devi sempre lasciare abbastanza spazio per spostare gli oggetti vivi, il che significa che lo spazio deve essere abbastanza grande da ospitare tutto nello spazio. L'algoritmo di copia è leggermente inefficiente dalla memoria a causa di questo vincolo.

Collezionisti Mark-and-Sweep

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

Un raccoglitore mark-and-sweep traccia i riferimenti e contrassegna ogni oggetto trovato con un bit "live". Di solito un bit impostato corrisponde a un indirizzo o in alcuni casi a un insieme di indirizzi nell'heap. Il bit live può, ad esempio, essere memorizzato come un bit nell'intestazione dell'oggetto, un vettore di bit o una mappa di bit.

Dopo che tutto è stato contrassegnato come live, verrà avviata la fase di scansione. Se un raccoglitore ha una fase di scansione, fondamentalmente include un meccanismo per attraversare di nuovo l'heap (non solo il live set ma l'intera lunghezza dell'heap) per individuare tutti i non contrassegnati blocchi di spazi di indirizzi di memoria consecutivi. La memoria non contrassegnata è gratuita e recuperabile. Il raccoglitore quindi collega questi blocchi non contrassegnati in elenchi gratuiti organizzati. Ci possono essere vari elenchi liberi in un garbage collector, solitamente organizzati per dimensioni di blocchi. Alcune JVM (come JRockit Real Time) implementano collector con euristiche che dinamicamente elenchi di intervalli di dimensioni basati sui dati di profilatura dell'applicazione e statistiche sulla dimensione degli oggetti.

Quando la fase di scansione è completa, l'allocazione ricomincerà. Le nuove aree di allocazione vengono allocate dagli elenchi liberi e i blocchi di memoria possono essere abbinati alle dimensioni degli oggetti, alle medie delle dimensioni degli oggetti per ID thread o alle dimensioni TLAB ottimizzate per l'applicazione. Adattare lo spazio libero più strettamente alle dimensioni di ciò che l'applicazione sta tentando di allocare ottimizza la memoria e potrebbe aiutare a ridurre la frammentazione.

Maggiori informazioni sulle taglie TLAB

Il partizionamento TLAB e TLA (Thread Local Allocation Buffer o Thread Local Area) è discusso in Ottimizzazione delle prestazioni JVM, Parte 1.

Aspetti negativi dei collezionisti mark-and-sweep

La fase di marcatura dipende dalla quantità di dati in tempo reale nell'heap, mentre la fase di sweep dipende dalla dimensione dell'heap. Poiché è necessario attendere il completamento di entrambe le fasi di mark e sweep 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 per aiutare le applicazioni che consumano molto memoria è utilizzare le opzioni di ottimizzazione GC che soddisfano vari scenari e esigenze applicative. L'ottimizzazione può, in molti casi, aiutare almeno a posticipare una di queste fasi dal diventare un rischio per l'applicazione o gli accordi sul livello di servizio (SLA). (Uno SLA specifica che l'applicazione soddisferà determinati tempi di risposta dell'applicazione, ovvero latenza.) L'ottimizzazione per ogni modifica del carico e dell'applicazione è un'attività ripetitiva, tuttavia, poiché l'ottimizzazione è valida solo per un carico di lavoro e una velocità di allocazione specifici.

Implementazioni di mark-and-sweep

Esistono almeno due approcci disponibili in commercio e comprovati per l'implementazione della raccolta mark-and-sweep. Uno è l'approccio parallelo e l'altro è l'approccio simultaneo (o per lo più simultaneo).

Collettori paralleli

La raccolta parallela significa che le risorse assegnate al processo vengono utilizzate in parallelo ai fini della raccolta dei rifiuti. La maggior parte dei collector paralleli implementati commercialmente sono collector monolitici stop-the-world: tutti i thread dell'applicazione vengono interrotti fino al completamento dell'intero ciclo di garbage collection. L'arresto di tutti i thread consente a tutte le risorse di essere utilizzate in modo efficiente in parallelo per completare la garbage collection attraverso le fasi di mark e sweep. Ciò porta a un livello di efficienza molto elevato, che di solito si traduce in punteggi elevati nei benchmark di throughput come SPECjbb. Se la velocità effettiva è essenziale per la tua applicazione, l'approccio parallelo è una scelta eccellente.