Migliorare le prestazioni delle applicazioni con queste tecniche avanzate di GC

Le prestazioni delle applicazioni sono in primo piano nelle nostre menti, e l’ottimizzazione della Garbage Collection è un buon posto per fare piccoli, ma significativi progressi

La garbage collection automatizzata (insieme al compilatore JIT HotSpot) è uno dei componenti più avanzati e più apprezzati della JVM, ma molti sviluppatori e ingegneri sono molto meno familiari con la Garbage Collection (GC), come funziona e come influisce sulle prestazioni delle applicazioni.

Prima di tutto, a cosa serve la GC? Garbage collection è il processo di gestione della memoria per gli oggetti nell’heap. Quando gli oggetti vengono allocati nell’heap, passano attraverso alcune fasi di raccolta – di solito piuttosto velocemente, dato che la maggior parte degli oggetti nell’heap ha una vita breve.

Garbage collection contiene tre fasi – marcatura, cancellazione e copia/compattazione. Nella prima fase, il GC percorre l’heap e contrassegna tutto come oggetti vivi (referenziati), oggetti non referenziati o spazio di memoria disponibile. Gli oggetti non referenziati vengono poi cancellati, e gli oggetti rimanenti vengono compattati. Nelle garbage collection generazionali, gli oggetti “invecchiano” e vengono promossi attraverso 3 spazi nella loro vita – Eden, spazio Survivor e spazio Tenured (Old). Questo spostamento avviene anche come parte della fase di compattazione.

Ma basta parlare di questo, passiamo alla parte divertente!

Psst! Cerchi una soluzione per migliorare le prestazioni delle applicazioni? OverOps aiuta le aziende a identificare non solo quando e dove si verificano i rallentamenti, ma perché e come si verificano. Guarda una demo dal vivo per vedere come funziona.

Conoscere la Garbage Collection (GC) in Java

Una delle grandi cose della GC automatizzata è che gli sviluppatori non hanno bisogno di capire come funziona. Sfortunatamente, questo significa che molti sviluppatori NON capiscono come funziona. Capire la garbage collection e i molti GC disponibili, è un po’ come conoscere i comandi CLI di Linux. Non è tecnicamente necessario usarli, ma conoscerli e diventare a proprio agio nell’usarli può avere un impatto significativo sulla vostra produttività.

Proprio come con i comandi CLI, ci sono le basi assolute. il comando ls per visualizzare un elenco di cartelle all’interno di una cartella madre, mv per spostare un file da una posizione ad un’altra, ecc. In GC, questo tipo di comandi sarebbe equivalente a sapere che c’è più di un GC da scegliere e che il GC può causare problemi di prestazioni. Naturalmente, c’è molto di più da imparare (sull’uso della CLI di Linux E sulla garbage collection).

Lo scopo di imparare il processo di garbage collection di Java non è solo per iniziare una conversazione gratuita (e noiosa), lo scopo è quello di imparare come implementare e mantenere efficacemente il giusto GC con prestazioni ottimali per il vostro ambiente specifico. Sapere che la garbage collection influisce sulle prestazioni dell’applicazione è fondamentale, e ci sono molte tecniche avanzate per migliorare le prestazioni della GC e ridurre il suo impatto sull’affidabilità dell’applicazione.

GC Performance Concerns

Memory Leaks –

Con la conoscenza della struttura dell’heap e di come viene eseguita la garbage collection, sappiamo che l’utilizzo della memoria aumenta gradualmente fino a quando si verifica un evento di garbage collection e l’utilizzo scende di nuovo. L’utilizzo dell’heap per gli oggetti referenziati di solito rimane costante, quindi il calo dovrebbe essere più o meno lo stesso volume.

Con una perdita di memoria, ogni evento GC cancella una porzione minore di oggetti heap (anche se molti oggetti lasciati indietro non sono in uso), quindi l’utilizzo dell’heap continuerà ad aumentare fino a quando la memoria heap sarà piena e verrà lanciata un’eccezione OutOfMemoryError. La causa di questo è che il GC segna solo gli oggetti non referenziati per la cancellazione. Quindi, anche se un oggetto referenziato non è più in uso, non sarà cancellato dall’heap. Ci sono alcuni utili trucchi di codifica per prevenire questo che copriremo un po’ più avanti.

Eventi continui “Stop the World” –

In alcuni scenari, la garbage collection può essere chiamata un evento Stop the World perché quando si verifica, tutti i thread nella JVM (e quindi, l’applicazione che è in esecuzione su di essa) vengono fermati per consentire l’esecuzione della GC. In applicazioni sane, il tempo di esecuzione di GC è relativamente basso e non ha un grande effetto sulle prestazioni dell’applicazione.

In situazioni subottimali, tuttavia, gli eventi Stop the World possono avere un grande impatto sulle prestazioni e sull’affidabilità di un’applicazione. Se un evento GC richiede una pausa di Stop the World e impiega 2 secondi per essere eseguito, l’utente finale di quell’applicazione sperimenterà un ritardo di 2 secondi mentre i thread che eseguono l’applicazione vengono fermati per permettere il GC.

Quando si verificano perdite di memoria, anche i continui eventi Stop the World sono problematici. Poiché meno spazio di memoria heap viene eliminato ad ogni esecuzione del GC, ci vuole meno tempo perché la memoria rimanente si riempia. Quando la memoria è piena, la JVM attiva un altro evento GC. Alla fine, la JVM eseguirà ripetuti eventi Stop the World, causando grandi problemi di prestazioni.

Uso della CPU –

E tutto si riduce all’uso della CPU. Uno dei principali sintomi di continui eventi GC / Stop the World è un picco nell’uso della CPU. Il GC è un’operazione computazionalmente pesante, e quindi può prendere più della sua giusta quota di potenza della CPU. Per i GC che eseguono thread concorrenti, l’utilizzo della CPU può essere ancora più alto. Scegliere il giusto GC per la vostra applicazione avrà il maggiore impatto sull’utilizzo della CPU, ma ci sono anche altri modi per ottimizzare le prestazioni in quest’area.

Da queste preoccupazioni sulle prestazioni che circondano la garbage collection possiamo capire che per quanto i GC diventino avanzati (e stanno diventando piuttosto avanzati), il loro tallone d’Achille rimane lo stesso. Allocazioni di oggetti ridondanti e imprevedibili. Per migliorare le prestazioni delle applicazioni, scegliere il giusto GC non è sufficiente. Dobbiamo sapere come funziona il processo, e dobbiamo ottimizzare il nostro codice in modo che i nostri GC non richiedano risorse eccessive o causino pause eccessive nella nostra applicazione.

Generational GC

Prima di immergerci nei diversi GC di Java e nel loro impatto sulle prestazioni, è importante capire le basi della garbage collection generazionale. Il concetto di base del GC generazionale si basa sull’idea che più a lungo esiste un riferimento a un oggetto nell’heap, meno è probabile che sia segnato per la cancellazione. Etichettando gli oggetti con una “età” figurativa, potrebbero essere separati in diversi spazi di memorizzazione per essere marcati dal GC meno frequentemente.

Quando un oggetto viene allocato nello heap, viene messo in quello che viene chiamato spazio Eden. Questo è dove gli oggetti iniziano, e nella maggior parte dei casi è dove sono segnati per la cancellazione. Gli oggetti che sopravvivono a questa fase “festeggiano un compleanno” e vengono copiati nello spazio Survivor. Questo processo è mostrato qui sotto:

Gli spazi Eden e Survivor formano quella che è chiamata la Young Generation. È qui che avviene il grosso dell’azione. Quando (se) un oggetto nella Young Generation raggiunge una certa età, viene promosso allo spazio Tenured (chiamato anche Old). Il vantaggio di dividere le memorie degli oggetti in base all’età è che il GC può operare a diversi livelli.

Un GC minore è una collezione che si concentra solo sulla Young Generation, ignorando di fatto lo spazio Tenured. Generalmente, la maggior parte degli oggetti nella Young Generation sono segnati per la cancellazione e un Major o Full GC (inclusa la Old Generation) non è necessario per liberare la memoria sull’heap. Naturalmente un GC Maggiore o Completo sarà attivato quando necessario.

Un rapido trucco per ottimizzare il funzionamento del GC basato su questo è quello di regolare le dimensioni delle aree di heap per adattarsi meglio alle esigenze delle vostre applicazioni.

Tipi di collettore

Ci sono molti GC disponibili tra cui scegliere, e anche se G1 è diventato il GC predefinito in Java 9, è stato originariamente destinato a sostituire il collettore CMS che è Low Pause, quindi le applicazioni che funzionano con collettori Throughput potrebbero essere più adatte a rimanere con il loro collettore attuale. Comprendere le differenze operative, e le differenze nell’impatto delle prestazioni, per i garbage collector Java è ancora importante.

Collettori di throughput

Meglio per le applicazioni che hanno bisogno di essere ottimizzate per l’high-throughput e possono scambiare una latenza più alta per ottenerla.

Serial –

Il collettore seriale è il più semplice, e quello che è meno probabile che tu usi, poiché è principalmente progettato per ambienti a thread singolo (ad esempio 32-bit o Windows) e per piccoli heap. Questo collettore può scalare verticalmente l’uso della memoria nella JVM, ma richiede diversi Major/Full GCs per rilasciare le risorse di heap inutilizzate. Questo causa frequenti pause di Stop the World, che lo squalifica a tutti gli effetti dall’essere usato in ambienti rivolti all’utente.

Parallelo –

Come descrive il suo nome, questo GC usa più threads in esecuzione parallela per scansionare e compattare l’heap. Sebbene il GC parallelo utilizzi più thread per la garbage collection, mette comunque in pausa tutti i thread dell’applicazione durante l’esecuzione. Il collettore parallelo è più adatto per le applicazioni che hanno bisogno di essere ottimizzate per il miglior throughput e possono tollerare una latenza più alta in cambio.

Collettori a bassa pausa

La maggior parte delle applicazioni rivolte all’utente richiedono un GC a bassa pausa, in modo che l’esperienza dell’utente non sia influenzata da pause lunghe o frequenti. Questi GC sono tutti incentrati sull’ottimizzazione per la reattività (tempo/evento) e una forte prestazione a breve termine.

Concurrent Mark Sweep (CMS) –

Simile al collettore Parallel, il collettore Concurrent Mark Sweep (CMS) utilizza più thread per marcare e spazzare (rimuovere) oggetti non referenziati. Tuttavia, questo GC avvia gli eventi Stop the World solo in due istanze specifiche:

(1) quando si inizializza la marcatura iniziale delle radici (oggetti della vecchia generazione che sono raggiungibili dai punti di ingresso dei thread o dalle variabili statiche) o qualsiasi riferimento dal metodo main(), e pochi altri

(2) quando l’applicazione ha cambiato lo stato dell’heap mentre l’algoritmo era in esecuzione simultanea, costringendolo a tornare indietro e fare alcuni ritocchi finali per assicurarsi di avere gli oggetti giusti contrassegnati

G1 –

Il Garbage first collector (comunemente conosciuto come G1) utilizza più thread in background per scansionare l’heap che divide in regioni. Funziona scansionando prima le regioni che contengono più oggetti spazzatura, da cui il suo nome (Garbage first).

Questa strategia riduce la possibilità che l’heap si esaurisca prima che i thread in background abbiano finito la scansione degli oggetti inutilizzati, nel qual caso il collettore dovrebbe fermare l’applicazione. Un altro vantaggio per il collettore G1 è che compatta l’heap on-the-go, qualcosa che il collettore CMS fa solo durante le collezioni Stop the World.

Migliorare le prestazioni GC

Le prestazioni dell’applicazione sono direttamente influenzate dalla frequenza e dalla durata delle garbage collection, il che significa che l’ottimizzazione del processo GC è fatta riducendo queste metriche. Ci sono due modi principali per farlo. Primo, regolando le dimensioni dell’heap delle generazioni giovani e vecchie, e secondo, riducendo il tasso di allocazione e promozione degli oggetti.

In termini di regolazione delle dimensioni dell’heap, non è così semplice come ci si potrebbe aspettare. La conclusione logica sarebbe che aumentando la dimensione dell’heap si diminuisce la frequenza dei GC mentre si aumenta la durata, e diminuendo la dimensione dell’heap si diminuisce la durata dei GC mentre si aumenta la frequenza.

Il fatto della questione, però, è che la durata di un GC minore non dipende dalla dimensione dell’heap, ma dal numero di oggetti che sopravvivono alla raccolta. Ciò significa che per le applicazioni che creano principalmente oggetti a vita breve, aumentare la dimensione della generazione giovane può effettivamente ridurre sia la durata che la frequenza del GC. Tuttavia, se l’aumento della dimensione della generazione giovane porterà ad un aumento significativo degli oggetti che hanno bisogno di essere copiati negli spazi di sopravvivenza, le pause GC richiederanno più tempo portando ad un aumento della latenza.

3 Suggerimenti per scrivere codice GC-Efficiente

Tip #1: Prevedere le capacità di raccolta –

Tutte le collezioni Java standard, così come la maggior parte delle implementazioni personalizzate ed estese (come Trove e Guava di Google), usano array sottostanti (sia primitivi che basati su oggetti). Poiché gli array sono immutabili nelle dimensioni una volta allocati, l’aggiunta di elementi ad una collezione può in molti casi causare l’abbandono di un vecchio array sottostante in favore di un array più grande appena allocato.

La maggior parte delle implementazioni delle collezioni cerca di ottimizzare questo processo di riallocazione e mantenerlo ad un minimo ammortizzato, anche se non viene fornita la dimensione prevista della collezione. Tuttavia, i migliori risultati possono essere raggiunti fornendo la collezione con la sua dimensione prevista al momento della costruzione.

Tip #2: Elaborare i flussi direttamente –

Quando si elaborano flussi di dati, come i dati letti da file, o i dati scaricati in rete, per esempio, è molto comune vedere qualcosa di simile a:

L’array di byte risultante potrebbe poi essere analizzato in un documento XML, un oggetto JSON o un messaggio Protocol Buffer, per nominare alcune opzioni popolari.

Quando si ha a che fare con file di grandi dimensioni o di dimensioni imprevedibili, questa è ovviamente una cattiva idea, in quanto ci espone a OutOfMemoryErrors nel caso in cui la JVM non possa effettivamente allocare un buffer delle dimensioni dell’intero file.

Un modo migliore per affrontare questo è quello di utilizzare l’appropriato InputStream (FileInputStream in questo caso) e alimentarlo direttamente nel parser, senza prima leggere il tutto in un byte array. Tutte le maggiori librerie espongono API per analizzare direttamente i flussi, per esempio:

Tip #3: Usa oggetti immutabili –

L’immutabilità ha molti vantaggi. Uno che raramente riceve l’attenzione che merita è il suo effetto sulla garbage collection.

Un oggetto immutabile è un oggetto i cui campi (e in particolare i campi non primitivi nel nostro caso) non possono essere modificati dopo che l’oggetto è stato costruito.

Immutabilità implica che tutti gli oggetti referenziati da un contenitore immutabile sono stati creati prima che la costruzione del contenitore sia completata. In termini di GC: Il contenitore è giovane almeno quanto il riferimento più giovane che contiene. Questo significa che quando si eseguono cicli di garbage collection su generazioni giovani, il GC può saltare oggetti immutabili che si trovano in generazioni più vecchie, poiché sa per certo che non possono fare riferimento a nulla nella generazione che viene raccolta.

Meno oggetti da analizzare significano meno pagine di memoria da analizzare, e meno pagine di memoria da analizzare significano cicli GC più brevi, che significano pause GC più brevi e un migliore rendimento complessivo.

Per ulteriori suggerimenti ed esempi dettagliati, controlla questo post che copre tattiche approfondite per scrivere codice più efficiente dal punto di vista della memoria.

*** Un enorme ringraziamento ad Amit Hurvitz del team R&D di OverOps per la sua passione e intuizione che è andata in questo post!

Lascia un commento