Een inleiding tot ZGC: een schaalbare en experimentele JVM Garbage Collector met lage latentie

1. Inleiding

Tegenwoordig is het niet ongebruikelijk dat applicaties duizenden of zelfs miljoenen gebruikers tegelijkertijd bedienen. Dergelijke applicaties hebben enorme hoeveelheden geheugen nodig. Het beheer van al dat geheugen kan echter gemakkelijk de prestaties van de applicatie beïnvloeden.

Om dit probleem op te lossen, introduceerde Java 11 de Z Garbage Collector (ZGC) als een experimentele garbage collector (GC) -implementatie.

In deze tutorial zullen we zien hoe ZGC erin slaagt om korte pauzetijden te houden, zelfs op stapels van meerdere terabytes.

2. Hoofdconcepten

Om te begrijpen hoe ZGC werkt, moeten we de basisconcepten en terminologie achter geheugenbeheer en garbage collectors begrijpen.

2.1. Geheugen management

Fysiek geheugen is het RAM-geheugen dat onze hardware levert.

Het besturingssysteem (OS) wijst virtuele geheugenruimte toe aan elke applicatie.

Natuurlijk, we slaan virtueel geheugen op in fysiek geheugen en het besturingssysteem is verantwoordelijk voor het onderhouden van de toewijzing tussen de twee. Deze mapping omvat meestal hardwareversnelling.

2.2. Multi-mapping

Multi-mapping betekent dat er specifieke adressen in het virtuele geheugen zijn, die naar hetzelfde adres in het fysieke geheugen verwijzen. Omdat applicaties toegang hebben tot gegevens via virtueel geheugen, weten ze niets over dit mechanisme (en dat is ook niet nodig).

In feite wijzen we meerdere bereiken van het virtuele geheugen toe aan hetzelfde bereik in het fysieke geheugen:

Op het eerste gezicht zijn de use-cases niet duidelijk, maar we zullen later zien dat ZGC het nodig heeft om zijn magie te doen. Het biedt ook enige beveiliging omdat het de geheugenruimten van de applicaties scheidt.

2.3. Verhuizing

Omdat we dynamische geheugentoewijzing gebruiken, raakt het geheugen van een gemiddelde applicatie in de loop van de tijd gefragmenteerd. Het is omdat wanneer we een object in het midden van het geheugen vrijmaken, er een lege ruimte overblijft. Na verloop van tijd stapelen deze hiaten zich op en ons geheugen zal eruitzien als een schaakbord dat bestaat uit afwisselend vrije en gebruikte ruimte.

We zouden natuurlijk kunnen proberen deze gaten op te vullen met nieuwe objecten. Om dit te doen, moeten we het geheugen scannen op vrije ruimte die groot genoeg is om ons object te bevatten. Dit is een dure operatie, vooral als we het elke keer moeten doen als we geheugen willen toewijzen. Bovendien zal het geheugen nog steeds gefragmenteerd zijn, omdat we waarschijnlijk geen vrije ruimte kunnen vinden met de exacte grootte die we nodig hebben. Daarom zullen er gaten zijn tussen de objecten. Deze gaten zijn natuurlijk kleiner. We kunnen ook proberen deze hiaten te minimaliseren, maar het gebruikt nog meer verwerkingskracht.

De andere strategie is te vaak verplaats objecten van gefragmenteerde geheugengebieden naar vrije gebieden in een compacter formaat. Om effectiever te zijn, splitsen we de geheugenruimte op in blokken. We verplaatsen alle objecten in een blok of geen enkele. Op deze manier zal geheugentoewijzing sneller zijn, omdat we weten dat er hele lege blokken in het geheugen zitten.

2.4. Garbage Collection

Wanneer we een Java-applicatie maken, hoeven we het toegewezen geheugen niet vrij te maken, omdat garbage collectors het voor ons doen. Samengevat, GC controleert welke objecten we kunnen bereiken vanuit onze applicatie via een reeks referenties en maakt degene vrij die we niet kunnen bereiken.

Een GC moet de toestand van de objecten in de heapruimte volgen om zijn werk te kunnen doen. Zo is een mogelijke staat bereikbaar. Het betekent dat de applicatie een verwijzing naar het object bevat. Deze verwijzing kan transitief zijn. Het enige dat telt is dat de applicatie via referenties toegang heeft tot deze objecten. Een ander voorbeeld is af te ronden: objecten waartoe we geen toegang hebben. Dit zijn de objecten die we als afval beschouwen.

Om dit te bereiken, hebben vuilnismannen meerdere fasen.

2.5. GC Phase-eigenschappen

GC-fasen kunnen verschillende eigenschappen hebben:

  • een parallel fase kan op meerdere GC-threads worden uitgevoerd
  • een serieel fase draait op een enkele thread
  • een stop-de-wereld fase kan niet gelijktijdig met applicatiecode worden uitgevoerd
  • een gelijktijdig fase kan op de achtergrond draaien, terwijl onze applicatie zijn werk doet
  • een incrementeel fase kan worden beëindigd voordat al zijn werk is voltooid en later kan worden voortgezet

Merk op dat alle bovenstaande technieken hun sterke en zwakke punten hebben. Laten we bijvoorbeeld zeggen dat we een fase hebben die gelijktijdig met onze applicatie kan worden uitgevoerd. Een seriële implementatie van deze fase vereist 1% van de algehele CPU-prestaties en loopt gedurende 1000 ms. Een parallelle implementatie gebruikt daarentegen 30% van de CPU en voltooit zijn werk in 50 ms.

In dit voorbeeld is de parallelle oplossing gebruikt in het algemeen meer CPU, omdat deze mogelijk complexer is en de threads moet worden gesynchroniseerd. Voor CPU-zware applicaties (bijvoorbeeld batchtaken) is dit een probleem omdat we minder rekenkracht hebben om nuttig werk te doen.

Dit voorbeeld heeft natuurlijk verzonnen nummers. Het is echter duidelijk dat alle applicaties hun kenmerken hebben, dus ze hebben verschillende GC-vereisten.

Bezoek ons ​​artikel over Java-geheugenbeheer voor meer gedetailleerde beschrijvingen.

3. ZGC-concepten

ZGC wil de stop-de-wereld-fasen zo kort mogelijk aanbieden. Het bereikt dit op een zodanige manier dat de duur van deze pauzetijden niet toeneemt met de heapgrootte. Deze eigenschappen maken ZGC zeer geschikt voor servertoepassingen, waar grote hoeveelheden veel voorkomen en snelle reactietijden van applicaties een vereiste zijn.

Naast de beproefde GC-technieken introduceert ZGC nieuwe concepten, die we in de volgende secties zullen behandelen.

Maar laten we voor nu eens kijken naar het algemene beeld van hoe ZGC werkt.

3.1. Grote foto

ZGC heeft een fase genaamd markering, waar we de bereikbare objecten vinden. Een GC kan objectstatusinformatie op meerdere manieren opslaan. We kunnen bijvoorbeeld een Kaart, waarbij de sleutels geheugenadressen zijn en de waarde de toestand van het object op dat adres. Het is eenvoudig maar heeft extra geheugen nodig om deze informatie op te slaan. Het onderhouden van een dergelijke kaart kan ook een uitdaging zijn.

ZGC gebruikt een andere benadering: het slaat de referentiestatus op als de bits van de referentie. Het heet referentiekleuring. Maar zo hebben we een nieuwe uitdaging. Het instellen van bits van een referentie om metagegevens over een object op te slaan, betekent dat meerdere referenties naar hetzelfde object kunnen verwijzen, aangezien de statusbits geen informatie bevatten over de locatie van het object. Multimapping om te redden!

We willen ook de geheugenfragmentatie verminderen. ZGC maakt hiervoor gebruik van relocation. Maar met een grote hoop is verhuizing een langzaam proces. Omdat ZGC geen lange pauzetijden wil, worden de meeste verhuizingen parallel met de applicatie uitgevoerd. Maar dit introduceert een nieuw probleem.

Laten we zeggen dat we een verwijzing naar een object hebben. ZGC verplaatst het, en er vindt een contextomschakeling plaats, waarbij de toepassingsthread wordt uitgevoerd en probeert toegang te krijgen tot dit object via het oude adres. ZGC gebruikt laadbarrières om dit op te lossen. Een laadbarrière is een stuk code dat wordt uitgevoerd wanneer een thread een referentie van de heap laadt - bijvoorbeeld wanneer we toegang krijgen tot een niet-primitief veld van een object.

In ZGC controleren laadbarrières de metadatabits van de referentie. Afhankelijk van deze bits, ZGC voert mogelijk enige verwerking uit op de referentie voordat we deze krijgen. Daarom kan het een geheel andere referentie opleveren. We noemen dit opnieuw toewijzen.

3.2. Markering

ZGC verdeelt markering in drie fasen.

De eerste fase is een stop-de-wereldfase. In deze fase zoeken we naar wortelreferenties en markeren deze. Rootreferenties zijn de startpunten om objecten in de heap te bereikenbijvoorbeeld lokale variabelen of statische velden. Omdat het aantal wortelreferenties meestal klein is, is deze fase kort.

De volgende fase is gelijktijdig. In deze fase we doorlopen de objectgrafiek, beginnend bij de wortelreferenties. We markeren elk object dat we bereiken. Wanneer een vangrail een ongemarkeerde referentie detecteert, wordt deze ook gemarkeerd.

De laatste fase is ook een stop-de-wereld-fase om enkele randgevallen, zoals zwakke referenties, af te handelen.

Op dit punt weten we welke objecten we kunnen bereiken.

ZGC gebruikt de gemarkeerd0 en gemarkeerd1 metadatabits voor markering.

3.3. Referentie kleuren

Een verwijzing vertegenwoordigt de positie van een byte in het virtuele geheugen. We hoeven echter niet per se alle stukjes van een referentie te gebruiken om dat te doen - sommige bits kunnen eigenschappen van de referentie vertegenwoordigen. Dat is wat we referentiekleuren noemen.

Met 32 ​​bits kunnen we 4 gigabyte adresseren. Aangezien het tegenwoordig wijdverbreid is dat een computer meer geheugen heeft, kunnen we uiteraard geen van deze 32 bits gebruiken om in te kleuren. Daarom gebruikt ZGC 64-bits verwijzingen. Het betekent ZGC is alleen beschikbaar op 64-bits platforms:

ZGC-referenties gebruiken 42 bits om het adres zelf weer te geven. Als resultaat kunnen ZGC-referenties 4 terabyte geheugenruimte adresseren.

Bovendien hebben we 4 bits om referentiestaten op te slaan:

  • af te ronden bit - het object is alleen bereikbaar via een finalizer
  • opnieuw toewijzen bit - de referentie is up-to-date en verwijst naar de huidige locatie van het object (zie verhuizing)
  • gemarkeerd0 en gemarkeerd1 bits - deze worden gebruikt om bereikbare objecten te markeren

We noemden deze bits ook wel metadatabits. In ZGC is precies één van deze metadatabits 1.

3.4. Verhuizing

In ZGC bestaat de verhuizing uit de volgende fasen:

  1. Een gelijktijdige fase, die blokken zoekt, we willen verhuizen en deze in de relocatieset plaatsen.
  2. Een stop-de-wereld-fase verplaatst alle rootreferenties in de relocatieset en werkt hun referenties bij.
  3. Een gelijktijdige fase verplaatst alle resterende objecten in de verplaatsingsset en slaat de toewijzing tussen de oude en nieuwe adressen op in de doorstuurtabel.
  4. Het herschrijven van de resterende referenties gebeurt in de volgende markeringsfase. Op deze manier hoeven we de objectenboom niet twee keer te doorlopen. Als alternatief kunnen laadbarrières het ook doen.

3.5. Remapping en Load Barriers

Merk op dat we in de verhuisfase de meeste verwijzingen naar de verhuisde adressen niet hebben herschreven. Daarom zouden we met behulp van die verwijzingen geen toegang krijgen tot de objecten die we wilden. Erger nog, we hadden toegang tot afval.

ZGC gebruikt laadbarrières om dit probleem op te lossen. Belastingsbarrières repareren de referenties die naar verplaatste objecten verwijzen met een techniek die remapping wordt genoemd.

Wanneer de applicatie een referentie laadt, activeert deze de lastbarrière, die vervolgens de volgende stappen volgt om de juiste referentie te retourneren:

  1. Controleert of het opnieuw toewijzen bit is ingesteld op 1. Als dit het geval is, betekent dit dat de referentie up-to-date is, dus we kunnen deze gerust retourneren.
  2. Vervolgens kijken we of het object waarnaar wordt verwezen in de verhuisset zat of niet. Als dat niet het geval was, betekent dat dat we het niet wilden verplaatsen. Om deze controle de volgende keer dat we deze referentie laden te vermijden, stellen we de opnieuw toewijzen bit naar 1 en retourneer de bijgewerkte referentie.
  3. Nu weten we dat het object waartoe we toegang willen, het doelwit was van verhuizing. De enige vraag is of de verhuizing heeft plaatsgevonden of niet? Als het object is verplaatst, gaan we verder met de volgende stap. Anders verplaatsen we het nu en maken we een vermelding in de doorstuurtabel, waarin het nieuwe adres voor elk verplaatst object wordt opgeslagen. Hierna gaan we verder met de volgende stap.
  4. Nu weten we dat het object is verplaatst. Ofwel door ZGC, ons in de vorige stap, of de laadbarrière tijdens een eerdere treffer van dit object. We werken deze verwijzing bij naar de nieuwe locatie van het object (ofwel met het adres van de vorige stap of door het op te zoeken in de doorstuurtabel), stellen de opnieuw toewijzen bit, en retourneer de referentie.

En dat is het, met de bovenstaande stappen hebben we ervoor gezorgd dat elke keer dat we toegang proberen te krijgen tot een object, we de meest recente verwijzing ernaar krijgen. Omdat elke keer dat we een referentie laden, deze de laadbarrière activeert. Daarom verlaagt het de prestaties van de applicatie. Vooral de eerste keer dat we toegang krijgen tot een verplaatst object. Maar dit is een prijs die we moeten betalen als we korte pauzetijden willen. En aangezien deze stappen relatief snel zijn, heeft dit geen significante invloed op de prestaties van de applicatie.

4. Hoe kan ik ZGC inschakelen?

We kunnen ZGC inschakelen met de volgende opdrachtregelopties bij het uitvoeren van onze applicatie:

-XX: + UnlockExperimentalVMOptions -XX: + UseZGC

Merk op dat aangezien ZGC een experimentele GC is, het enige tijd zal duren voordat het officieel wordt ondersteund.

5. Conclusie

In dit artikel hebben we gezien dat ZGC van plan is grote heapgroottes te ondersteunen met korte pauzetijden van applicaties.

Om dit doel te bereiken, maakt het gebruik van technieken, waaronder gekleurde 64-bits referenties, laadbarrières, verplaatsing en opnieuw toewijzen.