OOP compressi nella JVM

1. Panoramica

La JVM gestisce la memoria per noi. Ciò rimuove il carico di gestione della memoria da parte degli sviluppatori, quindi non è necessario manipolare manualmente i puntatori agli oggetti , il che si è dimostrato dispendioso in termini di tempo e soggetto a errori.

Sotto il cofano, la JVM incorpora molti trucchi ingegnosi per ottimizzare il processo di gestione della memoria. Un trucco è l'uso dei puntatori compressi , che valuteremo in questo articolo. Prima di tutto, vediamo come la JVM rappresenta gli oggetti in fase di runtime.

2. Rappresentazione di oggetti in runtime

All'HotSpot JVM utilizza una struttura dati chiamata oop s o puntatori oggetto comune per rappresentare oggetti. Questi oops sono equivalenti ai puntatori C nativi. I instanceOop sono un tipo speciale di oop che rappresenta le istanze degli oggetti in Java . Inoltre, la JVM supporta anche una manciata di altri oops che sono conservati nell'albero dei sorgenti di OpenJDK.

Vediamo come la JVM dispone in memoria le instanceOop .

2.1. Layout della memoria degli oggetti

Il layout di memoria di un instanceOop è semplice: è solo l'intestazione dell'oggetto immediatamente seguita da zero o più riferimenti ai campi dell'istanza.

La rappresentazione JVM di un'intestazione di oggetto consiste in:

  • Una parola di contrassegno ha molti scopi come blocco parziale , valori hash identità e GC . Non è un oop, ma per ragioni storiche risiede nell'albero dei sorgenti oop di OpenJDK . Inoltre, lo stato della parola del marchio contiene solo un uintptr_t, quindi la sua dimensione varia tra 4 e 8 byte nelle architetture a 32 bit e 64 bit, rispettivamente
  • Una parola Klass, possibilmente compressa, che rappresenta un puntatore ai metadati della classe. Prima di Java 7, puntavano alla generazione permanente , ma da Java 8 in poi puntano al Metaspace
  • Uno spazio di 32 bit per imporre l'allineamento degli oggetti. Questo rende il layout più hardware friendly, come vedremo più avanti

Immediatamente dopo l'intestazione, devono esserci zero o più riferimenti ai campi di istanza. In questo caso, una parola è una parola macchina nativa, quindi 32 bit su macchine legacy a 32 bit e 64 bit su sistemi più moderni.

L'intestazione dell'oggetto degli array, oltre alle parole mark e klass, contiene una parola a 32 bit per rappresentarne la lunghezza.

2.2. Anatomia dei rifiuti

Supponiamo di passare da un'architettura a 32 bit legacy a una macchina a 64 bit più moderna. All'inizio, potremmo aspettarci di ottenere un aumento immediato delle prestazioni. Tuttavia, non è sempre così quando è coinvolta la JVM.

Il principale colpevole di questo possibile degrado delle prestazioni sono i riferimenti a oggetti a 64 bit. I riferimenti a 64 bit occupano il doppio dello spazio dei riferimenti a 32 bit, quindi questo porta a un maggiore consumo di memoria in generale e cicli GC più frequenti. Più tempo è dedicato ai cicli GC, minori sono le sezioni di esecuzione della CPU per i nostri thread dell'applicazione.

Quindi, dovremmo tornare indietro e utilizzare di nuovo quelle architetture a 32 bit? Anche se questa fosse un'opzione, non potremmo avere più di 4 GB di spazio heap negli spazi di elaborazione a 32 bit senza un po 'più di lavoro.

3. OOP compressi

A quanto pare, la JVM può evitare di sprecare memoria comprimendo i puntatori agli oggetti o oops, così possiamo avere il meglio di entrambi i mondi: consentire più di 4 GB di spazio heap con riferimenti a 32 bit in macchine a 64 bit!

3.1. Ottimizzazione di base

Come abbiamo visto in precedenza, la JVM aggiunge il riempimento agli oggetti in modo che la loro dimensione sia un multiplo di 8 byte. Con queste imbottiture, gli ultimi tre bit in oops sono sempre zero. Questo perché i numeri multipli di 8 finiscono sempre con 000 in binario.

Poiché la JVM sa già che gli ultimi tre bit sono sempre zero, non ha senso memorizzare quegli zeri insignificanti nell'heap. Invece, presume che siano lì e memorizza altri 3 bit più significativi che non potevamo inserire in 32 bit in precedenza. Ora, abbiamo un indirizzo a 32 bit con 3 zeri spostati a destra, quindi comprimiamo un puntatore a 35 bit in uno a 32 bit. Ciò significa che possiamo utilizzare fino a 32 GB - 232 + 3 = 235 = 32 GB - di spazio heap senza utilizzare riferimenti a 64 bit.

Affinché questa ottimizzazione funzioni, quando la JVM ha bisogno di trovare un oggetto in memoria sposta il puntatore a sinistra di 3 bit (fondamentalmente aggiunge quei 3 zeri alla fine). D'altra parte, durante il caricamento di un puntatore nell'heap, la JVM sposta il puntatore a destra di 3 bit per scartare gli zeri aggiunti in precedenza. Fondamentalmente, la JVM esegue un po 'più di calcolo per risparmiare spazio. Fortunatamente, il cambio di bit è un'operazione davvero banale per la maggior parte delle CPU.

Per abilitare la compressione oop , possiamo usare il flag di ottimizzazione -XX: + UseCompressedOops . La compressione oop è il comportamento predefinito da Java 7 in poi ogni volta che la dimensione massima dell'heap è inferiore a 32 GB. Quando la dimensione massima dell'heap è superiore a 32 GB, la JVM disattiverà automaticamente la compressione oop . Pertanto, l'utilizzo della memoria oltre una dimensione dell'heap di 32 GB deve essere gestito in modo diverso.

3.2. Oltre 32 GB

È anche possibile utilizzare puntatori compressi quando le dimensioni dell'heap Java sono maggiori di 32 GB. Sebbene l'allineamento dell'oggetto predefinito sia di 8 byte, questo valore è configurabile utilizzando il flag di ottimizzazione -XX: ObjectAlignmentInBytes . Il valore specificato deve essere una potenza di due e deve essere compreso tra 8 e 256 .

Possiamo calcolare la dimensione massima possibile dell'heap con i puntatori compressi come segue:

4 GB * ObjectAlignmentInBytes

Ad esempio, quando l'allineamento dell'oggetto è di 16 byte, è possibile utilizzare fino a 64 GB di spazio heap con puntatori compressi.

Si noti che all'aumentare del valore di allineamento, potrebbe aumentare anche lo spazio inutilizzato tra gli oggetti. Di conseguenza, potremmo non realizzare alcun vantaggio dall'utilizzo di puntatori compressi con grandi dimensioni di heap Java.

3.3. GC futuristici

ZGC, una nuova aggiunta in Java 11, era un garbage collector a bassa latenza sperimentale e scalabile.

Può gestire diversi intervalli di dimensioni di heap mantenendo le pause del GC inferiori a 10 millisecondi. Poiché ZGC deve utilizzare puntatori colorati a 64 bit, non supporta i riferimenti compressi . Quindi, l'utilizzo di un GC a latenza ultra bassa come ZGC deve essere valutato rispetto all'utilizzo di più memoria.

A partire da Java 15, ZGC supporta i puntatori di classe compressi ma manca ancora il supporto per gli OOP compressi.

Tutti i nuovi algoritmi GC, tuttavia, non comprometteranno la memoria per essere a bassa latenza. Ad esempio, Shenandoah GC supporta riferimenti compressi oltre ad essere un GC con tempi di pausa ridotti.

Inoltre, sia Shenandoah che ZGC sono finalizzati a partire da Java 15.

4. Conclusione

In questo articolo, abbiamo descritto un problema di gestione della memoria JVM nelle architetture a 64 bit . Abbiamo esaminato i puntatori compressi e l'allineamento degli oggetti e abbiamo visto come la JVM può risolvere questi problemi, permettendoci di utilizzare heap di dimensioni maggiori con puntatori meno dispendiosi e un minimo di calcolo extra.

Per una discussione più dettagliata sui riferimenti compressi, si consiglia vivamente di dare un'occhiata a un altro grande pezzo di Aleksey Shipilëv. Inoltre, per vedere come funziona l'allocazione degli oggetti all'interno della JVM HotSpot, consulta l'articolo Layout di memoria degli oggetti in Java.