Un'introduzione a ZGC: un Garbage Collector JVM scalabile e sperimentale a bassa latenza

1. Introduzione

Oggi non è raro che le applicazioni servano contemporaneamente migliaia o addirittura milioni di utenti. Tali applicazioni richiedono enormi quantità di memoria. Tuttavia, la gestione di tutta quella memoria può facilmente influire sulle prestazioni dell'applicazione.

Per risolvere questo problema, Java 11 ha introdotto Z Garbage Collector (ZGC) come implementazione sperimentale di Garbage Collector (GC).

In questo tutorial, vedremo come ZGC riesce a mantenere bassi i tempi di pausa anche su heap multi-terabyte .

2. Concetti principali

Per capire come funziona ZGC, dobbiamo comprendere i concetti di base e la terminologia alla base della gestione della memoria e dei garbage collector.

2.1. Gestione della memoria

La memoria fisica è la RAM fornita dal nostro hardware.

Il sistema operativo (OS) alloca lo spazio di memoria virtuale per ogni applicazione.

Naturalmente, archiviamo la memoria virtuale nella memoria fisica e il sistema operativo è responsabile del mantenimento della mappatura tra i due. Questa mappatura di solito comporta l'accelerazione hardware.

2.2. Mappatura multipla

La mappatura multipla significa che ci sono indirizzi specifici nella memoria virtuale, che punta allo stesso indirizzo nella memoria fisica. Poiché le applicazioni accedono ai dati attraverso la memoria virtuale, non sanno nulla di questo meccanismo (e non ne hanno bisogno).

In effetti, mappiamo più intervalli della memoria virtuale allo stesso intervallo nella memoria fisica:

A prima vista, i suoi casi d'uso non sono ovvi, ma vedremo più avanti che ZGC ha bisogno che faccia la sua magia. Inoltre, fornisce una certa sicurezza perché separa gli spazi di memoria delle applicazioni.

2.3. Trasferimento

Poiché utilizziamo l'allocazione dinamica della memoria, la memoria di un'applicazione media diventa frammentata nel tempo. È perché quando liberiamo un oggetto nel mezzo della memoria, rimane uno spazio libero. Nel tempo, queste lacune si accumulano e la nostra memoria sembrerà una scacchiera fatta di aree alternate di spazio libero e utilizzato.

Certo, potremmo provare a colmare queste lacune con nuovi oggetti. Per fare ciò, dovremmo scansionare la memoria per trovare spazio libero abbastanza grande da contenere il nostro oggetto. Questa operazione è un'operazione costosa, soprattutto se dobbiamo farlo ogni volta che vogliamo allocare memoria. Inoltre, la memoria sarà ancora frammentata, poiché probabilmente non saremo in grado di trovare uno spazio libero che abbia le dimensioni esatte di cui abbiamo bisogno. Pertanto, ci saranno degli spazi tra gli oggetti. Ovviamente queste lacune sono minori. Inoltre, possiamo provare a ridurre al minimo queste lacune, ma utilizza ancora più potenza di elaborazione.

L'altra strategia consiste nel riposizionare frequentemente oggetti da aree di memoria frammentate ad aree libere in un formato più compatto . Per essere più efficaci, dividiamo lo spazio di memoria in blocchi. Riposizioniamo tutti gli oggetti in un blocco o nessuno di essi. In questo modo, l'allocazione della memoria sarà più veloce poiché sappiamo che ci sono interi blocchi vuoti nella memoria.

2.4. Raccolta dei rifiuti

Quando creiamo un'applicazione Java, non dobbiamo liberare la memoria che abbiamo allocato, perché i garbage collector lo fanno per noi. In sintesi, GC controlla quali oggetti possiamo raggiungere dalla nostra applicazione attraverso una catena di riferimenti e libera quelli che non possiamo raggiungere .

Un GC deve tenere traccia dello stato degli oggetti nello spazio heap per svolgere il proprio lavoro. Ad esempio, un possibile stato è raggiungibile. Significa che l'applicazione contiene un riferimento all'oggetto. Questo riferimento potrebbe essere transitivo. L'unica cosa che conta è che l'applicazione possa accedere a questi oggetti tramite riferimenti. Un altro esempio è finalizzabile: oggetti a cui non possiamo accedere. Questi sono gli oggetti che consideriamo spazzatura.

Per ottenerlo, i garbage collector hanno più fasi.

2.5. Proprietà fase GC

Le fasi GC possono avere proprietà diverse:

  • una fase parallela può essere eseguita su più thread GC
  • una fase seriale viene eseguita su un singolo thread
  • una fase stop-the-world non può essere eseguita contemporaneamente al codice dell'applicazione
  • una fase simultanea può essere eseguita in background, mentre la nostra applicazione fa il suo lavoro
  • una fase incrementale può terminare prima di terminare tutto il suo lavoro e continuare in seguito

Nota che tutte le tecniche di cui sopra hanno i loro punti di forza e di debolezza. Ad esempio, supponiamo di avere una fase che può essere eseguita contemporaneamente alla nostra applicazione. Un'implementazione seriale di questa fase richiede l'1% delle prestazioni complessive della CPU e funziona per 1000 ms. Al contrario, un'implementazione parallela utilizza il 30% della CPU e completa il suo lavoro in 50 ms.

In questo esempio, la soluzione parallela utilizza complessivamente più CPU, perché potrebbe essere più complessa e dover sincronizzare i thread . Per le applicazioni pesanti della CPU (ad esempio, lavori batch), è un problema poiché abbiamo meno potenza di calcolo per svolgere un lavoro utile.

Naturalmente, questo esempio ha numeri inventati. Tuttavia, è chiaro che tutte le applicazioni hanno le loro caratteristiche, quindi hanno requisiti GC diversi.

Per descrizioni più dettagliate, visita il nostro articolo sulla gestione della memoria Java.

3. Concetti ZGC

ZGC intende fornire fasi stop-the-world il più brevi possibile. Lo ottiene in modo tale che la durata di questi tempi di pausa non aumenti con la dimensione dell'heap. Queste caratteristiche rendono ZGC una buona soluzione per le applicazioni server, dove sono comuni grandi quantità di risorse e tempi di risposta rapidi delle applicazioni sono un requisito.

Oltre alle tecniche GC collaudate, ZGC introduce nuovi concetti, che tratteremo nelle sezioni seguenti.

Ma per ora, diamo un'occhiata al quadro generale di come funziona ZGC.

3.1. Quadro generale

ZGC ha una fase chiamata marcatura, dove troviamo gli oggetti raggiungibili. Un GC può memorizzare le informazioni sullo stato dell'oggetto in diversi modi. Ad esempio, potremmo creare una mappa, in cui le chiavi sono indirizzi di memoria e il valore è lo stato dell'oggetto a quell'indirizzo. È semplice ma necessita di memoria aggiuntiva per memorizzare queste informazioni. Inoltre, mantenere una mappa del genere può essere difficile.

ZGC utilizza un approccio diverso: memorizza lo stato di riferimento come i bit del riferimento. Si chiama coloritura di riferimento. Ma in questo modo abbiamo una nuova sfida. L'impostazione dei bit di un riferimento per memorizzare i metadati su un oggetto significa che più riferimenti possono puntare allo stesso oggetto poiché i bit di stato non contengono alcuna informazione sulla posizione dell'oggetto. Multimapping in soccorso!

Vogliamo anche ridurre la frammentazione della memoria. ZGC utilizza il trasferimento per raggiungere questo obiettivo. Ma con un mucchio di grandi dimensioni, il trasferimento è un processo lento. Poiché ZGC non desidera lunghi tempi di pausa, esegue la maggior parte del trasferimento in parallelo con l'applicazione. Ma questo introduce un nuovo problema.

Supponiamo di avere un riferimento a un oggetto. ZGC lo riposiziona e si verifica un cambio di contesto, in cui il thread dell'applicazione viene eseguito e tenta di accedere a questo oggetto tramite il suo vecchio indirizzo. ZGC utilizza barriere di carico per risolvere questo problema. Una barriera di carico è un pezzo di codice che viene eseguito quando un thread carica un riferimento dall'heap , ad esempio quando accediamo a un campo non primitivo di un oggetto.

In ZGC, le barriere di carico controllano i bit di metadati del riferimento. A seconda di questi bit, ZGC può eseguire alcune elaborazioni sul riferimento prima di ottenerlo. Pertanto, potrebbe produrre un riferimento completamente diverso. Chiamiamo questa rimappatura.

3.2. Marcatura

ZGC suddivide la marcatura in tre fasi.

La prima fase è una fase stop-the-world. In questa fase, cerchiamo i riferimenti radice e li contrassegniamo. I riferimenti radice sono i punti di partenza per raggiungere gli oggetti nell'heap , ad esempio, variabili locali o campi statici. Poiché il numero di riferimenti radice è generalmente piccolo, questa fase è breve.

La fase successiva è simultanea. In questa fase, attraversiamo l'oggetto grafico, partendo dai riferimenti di radice. Contrassegniamo ogni oggetto che raggiungiamo. Inoltre, quando una barriera di carico rileva un riferimento non contrassegnato, lo contrassegna anche lui.

L'ultima fase è anche una fase stop-the-world per gestire alcuni casi limite, come i riferimenti deboli.

A questo punto sappiamo quali oggetti possiamo raggiungere.

ZGC utilizza i bit di metadati marcati0 e marcati1 per la marcatura.

3.3. Colorazione di riferimento

Un riferimento rappresenta la posizione di un byte nella memoria virtuale. Tuttavia, non dobbiamo necessariamente utilizzare tutti i bit di un riferimento per farlo: alcuni bit possono rappresentare le proprietà del riferimento . Questo è ciò che chiamiamo colorazione di riferimento.

Con 32 bit possiamo indirizzare 4 gigabyte. Poiché oggigiorno è molto diffuso che un computer abbia più memoria di questa, ovviamente non possiamo usare nessuno di questi 32 bit per colorare. Pertanto, ZGC utilizza riferimenti a 64 bit. Significa che ZGC è disponibile solo su piattaforme a 64 bit:

I riferimenti ZGC utilizzano 42 bit per rappresentare l'indirizzo stesso. Di conseguenza, i riferimenti ZGC possono indirizzare 4 terabyte di spazio di memoria.

Inoltre, abbiamo 4 bit per memorizzare gli stati di riferimento:

  • bit finalizzabile : l'oggetto è raggiungibile solo tramite un finalizzatore
  • bit di rimappatura : il riferimento è aggiornato e punta alla posizione corrente dell'oggetto (vedere riposizionamento)
  • bits marcati0 e marcati1 : sono usati per contrassegnare gli oggetti raggiungibili

Abbiamo anche chiamato questi bit bit di metadati. In ZGC, proprio uno di questi bit di metadati è 1.

3.4. Trasferimento

In ZGC, il trasferimento consiste nelle seguenti fasi:

  1. Una fase simultanea, che cerca i blocchi, vogliamo riposizionarli e inserirli nel set di rilocazione.
  2. Una fase stop-the-world trasferisce tutti i riferimenti radice nel set di rilocazione e aggiorna i loro riferimenti.
  3. Una fase simultanea trasferisce tutti gli oggetti rimanenti nel set di rilocazione e memorizza la mappatura tra il vecchio e il nuovo indirizzo nella tabella di inoltro.
  4. La riscrittura dei rimanenti riferimenti avviene nella fase di marcatura successiva. In questo modo, non dobbiamo attraversare l'albero degli oggetti due volte. In alternativa, anche le barriere di carico possono farlo.

3.5. Rimappatura e barriere di carico

Si noti che nella fase di trasferimento, non abbiamo riscritto la maggior parte dei riferimenti agli indirizzi trasferiti. Pertanto, utilizzando quei riferimenti, non avremmo accesso agli oggetti che volevamo. Ancora peggio, potremmo accedere alla spazzatura.

ZGC utilizza barriere di carico per risolvere questo problema. Le barriere di carico fissano i riferimenti che puntano agli oggetti riposizionati con una tecnica chiamata rimappatura.

Quando l'applicazione carica un riferimento, attiva la barriera di carico, che quindi segue i seguenti passaggi per restituire il riferimento corretto:

  1. Controlla se il bit di rimappatura è impostato su 1. Se è così, significa che il riferimento è aggiornato, quindi possiamo tranquillamente restituirlo.
  2. Quindi controlliamo se l'oggetto di riferimento era nel set di rilocazione o meno. Se non lo era, significa che non volevamo trasferirlo. Per evitare questo controllo la prossima volta che carichiamo questo riferimento, impostiamo il bit di rimappatura su 1 e restituiamo il riferimento aggiornato.
  3. Ora sappiamo che l'oggetto a cui vogliamo accedere era l'obiettivo del trasferimento. L'unica domanda è se il trasferimento è avvenuto o no? Se l'oggetto è stato riposizionato, andiamo al passaggio successivo. Altrimenti, lo riposizioniamo ora e creiamo una voce nella tabella di inoltro, che memorizza il nuovo indirizzo per ogni oggetto riposizionato. Dopo questo, continuiamo con il passaggio successivo.
  4. Ora sappiamo che l'oggetto è stato spostato. O da ZGC, noi nel passaggio precedente, o dalla barriera di carico durante un precedente colpo di questo oggetto. Aggiorniamo questo riferimento alla nuova posizione dell'oggetto (con l'indirizzo del passaggio precedente o cercandolo nella tabella di inoltro), impostiamo il bit di rimappatura e restituiamo il riferimento.

E questo è tutto, con i passaggi precedenti ci siamo assicurati che ogni volta che proviamo ad accedere a un oggetto, otteniamo il riferimento più recente ad esso. Poiché ogni volta che cariciamo un riferimento, attiva la barriera di carico. Pertanto diminuisce le prestazioni dell'applicazione. Soprattutto la prima volta che accediamo a un oggetto spostato. Ma questo è un prezzo che dobbiamo pagare se vogliamo brevi tempi di pausa. E poiché questi passaggi sono relativamente veloci, non influiscono in modo significativo sulle prestazioni dell'applicazione.

4. Come abilitare ZGC?

Possiamo abilitare ZGC con le seguenti opzioni della riga di comando durante l'esecuzione della nostra applicazione:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

Nota che poiché ZGC è un GC sperimentale, ci vorrà del tempo prima che sia ufficialmente supportato.

5. conclusione

In questo articolo, abbiamo visto che ZGC intende supportare heap di grandi dimensioni con tempi di pausa delle applicazioni ridotti.

Per raggiungere questo obiettivo, utilizza tecniche, inclusi riferimenti colorati a 64 bit, barriere di carico, riposizionamento e rimappatura.