Gecomprimeerde OOP's in de JVM

1. Overzicht

De JVM beheert het geheugen voor ons. Dit verwijdert de geheugenbeheerlast van de ontwikkelaars, dus we hoeven objectaanwijzers niet handmatig te manipuleren, wat is bewezen tijdrovend en foutgevoelig te zijn.

Onder de motorkap bevat de JVM veel handige trucs om het geheugenbeheerproces te optimaliseren. Een truc is het gebruik van Gecomprimeerde aanwijzers, die we in dit artikel gaan evalueren. Laten we eerst eens kijken hoe de JVM objecten tijdens runtime vertegenwoordigt.

2. Runtime-objectweergave

De HotSpot JVM gebruikt een datastructuur met de naam oepss of Gewone Object Pointers om objecten te vertegenwoordigen. Deze oeps zijn gelijk aan native C-pointers. De instantie Oops zijn een speciaal soort oeps dat vertegenwoordigt de objectinstanties in Java. Bovendien ondersteunt de JVM ook een handvol andere oeps die worden bewaard in de OpenJDK-broncodestructuur.

Laten we eens kijken hoe de JVM eruitziet instantie Oops in het geheugen.

2.1. Object Geheugen Layout

De geheugenlay-out van een instantie Oop is simpel: het is gewoon de objectkop die onmiddellijk wordt gevolgd door nul of meer verwijzingen naar instantievelden.

De JVM-weergave van een objectheader bestaat uit:

  • Een merkwoord dient vele doeleinden zoals Vooringenomen vergrendeling, Identity Hash-waarden, en GC. Het is geen oeps, maar om historische redenen bevindt het zich in de OpenJDK's oeps broncodeboom. Ook bevat de markwoordstatus alleen een uintptr_t, daarom de grootte varieert tussen 4 en 8 bytes in respectievelijk 32-bits en 64-bits architecturen
  • Een, mogelijk gecomprimeerd, Klass-woord, wat een verwijzing naar de metagegevens van een klasse vertegenwoordigt. Vóór Java 7 wezen ze naar de Permanente generatie, maar vanaf Java 8 wijzen ze naar de Metaspace
  • Een 32-bit gap om objectuitlijning af te dwingen. Dit maakt de lay-out hardware-vriendelijker, zoals we later zullen zien

Direct na de koptekst moeten er nul of meer verwijzingen naar instantievelden zijn. In dit geval een woord is een native machine-woord, dus 32-bits op oudere 32-bits machines en 64-bits op modernere systemen.

De objectkop van arrays bevat naast mark- en klass-woorden een 32-bits woord om de lengte aan te geven.

2.2. Anatomie van afval

Stel dat we gaan overschakelen van een oude 32-bits architectuur naar een modernere 64-bits machine. In eerste instantie mogen we verwachten dat we een onmiddellijke prestatieverbetering krijgen. Dat is echter niet altijd het geval als de JVM erbij betrokken is.

De belangrijkste boosdoener voor deze mogelijke verslechtering van de prestaties zijn 64-bits objectreferenties. 64-bits verwijzingen nemen tweemaal zoveel ruimte in als 32-bits verwijzingen, dus dit leidt tot meer geheugengebruik in het algemeen en frequentere GC-cycli. Hoe meer tijd er aan GC-cycli wordt besteed, hoe minder CPU-uitvoeringsplakken voor onze applicatiedraden.

Moeten we dus terugschakelen en die 32-bits architecturen opnieuw gebruiken? Zelfs als dit een optie was, zouden we zonder wat meer werk niet meer dan 4 GB heapruimte in 32-bits procesruimten kunnen hebben.

3. Gecomprimeerde OOP's

Het blijkt dat de JVM geheugenverspilling kan voorkomen door de objectaanwijzers of te comprimeren oeps, zodat we het beste van twee werelden kunnen hebben: waardoor meer dan 4 GB heapruimte met 32-bits verwijzingen in 64-bits machines mogelijk is!

3.1. Basisoptimalisatie

Zoals we eerder zagen, voegt de JVM opvulling toe aan de objecten, zodat hun grootte een veelvoud is van 8 bytes. Met deze vullingen komen de laatste drie bits erin oeps zijn altijd nul. Dit komt doordat getallen die een veelvoud zijn van 8 altijd eindigen op 000 in binair.

Omdat de JVM al weet dat de laatste drie bits altijd nul zijn, heeft het geen zin om die onbeduidende nullen in de heap op te slaan. In plaats daarvan gaat het ervan uit dat ze er zijn en slaat het 3 andere, meer significante bits op die we voorheen niet in 32-bits konden passen. Nu hebben we een 32-bits adres met 3 naar rechts verschoven nullen, dus we comprimeren een 35-bits aanwijzer in een 32-bits. Dit betekent dat we tot 32 GB - 232 + 3 = 235 = 32 GB - heapruimte kunnen gebruiken zonder 64-bits verwijzingen te gebruiken.

Om deze optimalisatie te laten werken, moet de JVM een object in het geheugen vinden het verschuift de aanwijzer met 3 bits naar links (voegt die 3-nullen in feite weer toe aan het einde). Aan de andere kant, wanneer een aanwijzer naar de heap wordt geladen, verschuift de JVM de aanwijzer 3 bits naar rechts om de eerder toegevoegde nullen te verwijderen. Kortom, de JVM voert een beetje meer berekeningen uit om wat ruimte te besparen. Gelukkig is bit-shifting een heel triviale handeling voor de meeste CPU's.

In staat te stellen oeps compressie kunnen we de -XX: + UseCompressedOops tuning vlag. De oeps compressie is het standaardgedrag vanaf Java 7 wanneer de maximale heapgrootte kleiner is dan 32 GB. Als de maximale heap-grootte meer is dan 32 GB, zal de JVM automatisch het oeps compressie. Geheugengebruik boven een heapgrootte van 32 GB moet dus anders worden beheerd.

3.2. Meer dan 32 GB

Het is ook mogelijk om gecomprimeerde aanwijzers te gebruiken wanneer de Java-heapgrootte groter is dan 32 GB. Hoewel de standaard uitlijning van objecten 8 bytes is, kan deze waarde worden geconfigureerd met de -XX:ObjectAlignmentInBytes tuning vlag. De opgegeven waarde moet een macht van twee zijn en moet tussen 8 en 256 liggen.

We kunnen de maximaal mogelijke heapgrootte met gecomprimeerde aanwijzers als volgt berekenen:

4 GB * ObjectAlignmentInBytes

Als de objectuitlijning bijvoorbeeld 16 bytes is, kunnen we tot 64 GB heapruimte gebruiken met gecomprimeerde pointers.

Houd er rekening mee dat naarmate de uitlijningswaarde toeneemt, de ongebruikte ruimte tussen objecten ook kan toenemen. Als gevolg hiervan realiseren we mogelijk geen voordelen bij het gebruik van gecomprimeerde aanwijzers met grote Java-heapgroottes.

3.3. Futuristische GC's

ZGC, een nieuwe toevoeging in Java 11, was een experimentele en schaalbare garbagecollector met lage latentie.

Het kan verschillende heapgroottes aan, terwijl de GC-pauzes onder de 10 milliseconden blijven. Aangezien ZGC 64-bits kleurwijzers moet gebruiken, het ondersteunt geen gecomprimeerde verwijzingen. Dus het gebruik van een GC met ultralage latentie zoals ZGC moet worden afgewogen tegen het gebruik van meer geheugen.

Vanaf Java 15 ondersteunt ZGC de gecomprimeerde klassenwijzers, maar ontbreekt nog steeds de ondersteuning voor gecomprimeerde OOP's.

Alle nieuwe GC-algoritmen zullen het geheugen echter niet inruilen voor een lage latentie. Shenandoah GC ondersteunt bijvoorbeeld gecomprimeerde referenties en is niet alleen een GC met korte pauzetijden.

Bovendien zijn zowel Shenandoah als ZGC voltooid vanaf Java 15.

4. Conclusie

In dit artikel hebben we een Probleem met JVM-geheugenbeheer in 64-bits architecturen. We keken naar gecomprimeerde aanwijzers en objectuitlijning, en we hebben gezien hoe de JVM deze problemen kan aanpakken, waardoor we grotere heap-formaten kunnen gebruiken met minder verkwistende aanwijzingen en een minimum aan extra berekeningen.

Voor een meer gedetailleerde bespreking van gecomprimeerde referenties, wordt het ten zeerste aanbevolen om nog een ander geweldig stuk van Aleksey Shipilëv te bekijken. Om te zien hoe objecttoewijzing werkt in de HotSpot JVM, raadpleegt u het artikel Geheugenlay-out van objecten in Java.