Verbeter de prestaties van toepassingen met deze geavanceerde GC-technieken

Prestaties van toepassingen staan op de voorgrond, en optimalisatie van garbage collection (GC) is een goede plaats om kleine, maar zinvolle vorderingen te maken

Automatische garbage collection (samen met de JIT HotSpot Compiler) is een van de meest geavanceerde en gewaardeerde onderdelen van de JVM, maar veel ontwikkelaars en technici zijn veel minder bekend met garbage collection (GC), hoe het werkt en hoe het de prestaties van toepassingen beïnvloedt.

Eerst, waar is GC eigenlijk voor? Garbage collection is het geheugenbeheerproces voor objecten in de heap. Wanneer objecten aan de heap worden toegewezen, doorlopen ze een paar verzamelfasen – meestal vrij snel, omdat de meerderheid van de objecten in de heap een korte levensduur hebben.

Garbage collection-events bevatten drie fasen – markeren, verwijderen en kopiëren/compacteren. In de eerste fase doorloopt de GC de heap en markeert alles als live (referenced) objecten, unreferenced objecten of beschikbare geheugenruimte. Objecten waarnaar niet verwezen wordt, worden dan verwijderd, en de resterende objecten worden gecompacteerd. In generatie-afvalverzamelingen, “verouderen” objecten en worden ze gepromoveerd door 3 ruimtes in hun leven – Eden, Survivor ruimte en Tenured (Oude) ruimte. Deze verschuiving vindt ook plaats als onderdeel van de verdichtingsfase.

Maar genoeg daarover, laten we naar het leuke gedeelte gaan.

Psst! Op zoek naar een oplossing om de prestaties van applicaties te verbeteren? OverOps helpt bedrijven niet alleen te identificeren wanneer en waar vertragingen optreden, maar ook waarom en hoe ze optreden. Bekijk een live demo om te zien hoe het werkt.

Getting to Know Garbage Collection (GC) in Java

Eén van de geweldige dingen van geautomatiseerde GC is dat ontwikkelaars niet echt hoeven te begrijpen hoe het werkt. Helaas betekent dit dat veel ontwikkelaars NIET begrijpen hoe het werkt. Het begrijpen van garbage collection en de vele beschikbare GC’s, is een beetje zoals het kennen van Linux CLI commando’s. Je hoeft ze technisch gezien niet te gebruiken, maar ze kennen en er vertrouwd mee raken, kan een grote invloed hebben op je productiviteit.

Net als met CLI commando’s, zijn er de absolute basics. ls commando om een lijst van mappen binnen een bovenliggende map te bekijken, mv om een bestand van de ene naar de andere locatie te verplaatsen, enz. In GC zouden dat soort commando’s gelijk staan met weten dat er meer dan één GC is om uit te kiezen, en dat GC prestatieproblemen kan veroorzaken. Natuurlijk is er zoveel meer te leren (over het gebruik van de Linux CLI EN over garbage collection).

Het doel van het leren over Java’s garbage collection proces is niet alleen voor gratuite (en saaie) gespreksstarters, het doel is om te leren hoe je effectief de juiste GC implementeert en onderhoudt met optimale prestaties voor je specifieke omgeving. Weten dat garbage collection de prestaties van applicaties beïnvloedt is basis, en er zijn veel geavanceerde technieken voor het verbeteren van GC prestaties en het verminderen van de impact op de betrouwbaarheid van applicaties.

GC Performance Concerns

Memory Leaks –

Met kennis van de heap structuur en hoe garbage collection wordt uitgevoerd, weten we dat het geheugengebruik geleidelijk toeneemt totdat een garbage collection event optreedt en het gebruik weer daalt. Het gebruik van de heap voor objecten waarnaar verwezen wordt, blijft gewoonlijk stabiel, dus de daling zou ongeveer even groot moeten zijn.

Met een geheugenlek wordt bij elke GC-gebeurtenis een kleiner deel van de heap-objecten opgeruimd (hoewel veel achtergebleven objecten niet in gebruik zijn), dus het gebruik van de heap zal blijven toenemen totdat het heap-geheugen vol is en er een OutOfMemoryError exceptie wordt gegooid. De oorzaak hiervan is dat de GC alleen objecten waarnaar niet verwezen wordt markeert voor verwijdering. Dus, zelfs als een object waarnaar verwezen wordt niet langer in gebruik is, zal het niet van de heap verwijderd worden. Er zijn enkele handige coderingstrucs om dit te voorkomen, die we later zullen behandelen.

Continue “Stop the World” Events –

In sommige scenario’s kan garbage collection een Stop the World event worden genoemd, omdat wanneer het optreedt, alle threads in de JVM (en dus, de applicatie die er op draait) worden gestopt om GC te laten uitvoeren. In gezonde applicaties is de uitvoeringstijd van GC relatief laag en heeft het geen groot effect op de prestaties van de applicatie.

In suboptimale situaties kunnen Stop the World events echter grote invloed hebben op de prestaties en betrouwbaarheid van een applicatie. Als een GC-gebeurtenis een Stop the World-pauze vereist en het 2 seconden duurt om uit te voeren, ondervindt de eindgebruiker van die toepassing een vertraging van 2 seconden omdat de threads die de toepassing uitvoeren, worden gestopt om GC mogelijk te maken.

Wanneer geheugenlekken optreden, zijn continue Stop the World-gebeurtenissen ook problematisch. Omdat bij elke uitvoering van de GC minder heap-geheugenruimte wordt opgebruikt, duurt het minder lang voordat het resterende geheugen vol is. Wanneer het geheugen vol is, start de JVM nog een GC event. Uiteindelijk zal de JVM herhaaldelijk Stop the World events uitvoeren, wat tot grote prestatieproblemen leidt.

CPU Usage –

En het komt allemaal neer op CPU-gebruik. Een belangrijk symptoom van voortdurende GC / Stop de Wereld gebeurtenissen is een piek in CPU-gebruik. GC is een rekenkundig zware operatie, en kan dus meer dan zijn deel van CPU kracht vragen. Voor GC’s die gelijktijdige threads draaien, kan het CPU-gebruik zelfs nog hoger zijn. Het kiezen van de juiste GC voor uw toepassing zal de grootste invloed hebben op het CPU-gebruik, maar er zijn ook andere manieren om te optimaliseren voor betere prestaties op dit gebied.

Uit deze prestatieproblemen rond garbage collection kunnen we opmaken dat hoe geavanceerd GC’s ook worden (en ze worden behoorlijk geavanceerd), hun achilleshiel hetzelfde blijft. Overbodige en onvoorspelbare object-toewijzingen. Om de prestaties van applicaties te verbeteren, is het niet genoeg om de juiste GC te kiezen. We moeten weten hoe het proces werkt, en we moeten onze code optimaliseren zodat onze GC’s geen excessieve resources trekken of excessieve pauzes in onze applicatie veroorzaken.

Generational GC

Voordat we in de verschillende Java GC’s en hun performance impact duiken, is het belangrijk om de basis van generational garbage collection te begrijpen. Het basisconcept van generationele GC is gebaseerd op het idee dat hoe langer een referentie naar een object in de heap bestaat, hoe kleiner de kans is dat het gemarkeerd wordt voor verwijdering. Door objecten een figuurlijke “leeftijd” te geven, zouden ze in verschillende opslagruimten kunnen worden gescheiden om minder vaak door de GC gemarkeerd te worden.

Wanneer een object aan de heap wordt toegewezen, wordt het in wat de Eden ruimte wordt genoemd geplaatst. Dat is waar de objecten beginnen, en in de meeste gevallen is dat waar ze worden gemarkeerd voor verwijdering. Objecten die dat stadium overleven “vieren een verjaardag” en worden gekopieerd naar de Survivor ruimte. Dit proces wordt hieronder weergegeven:

De Eden- en Survivor-ruimten vormen samen wat de Jonge Generatie wordt genoemd. Dit is waar het grootste deel van de actie plaatsvindt. Wanneer (Als) een object in de Jonge Generatie een bepaalde leeftijd bereikt, wordt het gepromoveerd naar de Tenured (ook wel Oude) ruimte. Het voordeel van het verdelen van Objectgeheugens op basis van leeftijd is dat de GC op verschillende niveaus kan werken.

Een Minor GC is een verzameling die zich alleen op de Jonge Generatie concentreert, waarbij de Tenured-ruimte in feite helemaal wordt genegeerd. Over het algemeen is het merendeel van de Objecten in de Jonge Generatie gemarkeerd voor verwijdering en is een Major of Full GC (inclusief de Oude Generatie) niet nodig om geheugen op de heap vrij te maken. Natuurlijk zal een Major of Full GC worden getriggerd wanneer dat nodig is.

Een snelle truc voor het optimaliseren van GC-operaties op basis hiervan is het aanpassen van de grootte van heap-gebieden om zo goed mogelijk aan de behoeften van uw applicaties te voldoen.

Collector Types

Er zijn veel beschikbare GC’s om uit te kiezen, en hoewel G1 de standaard GC werd in Java 9, was het oorspronkelijk bedoeld om de CMS-collector te vervangen die Low Pause is, dus applicaties die met Throughput-collectors werken kunnen beter bij hun huidige collector blijven. Inzicht in de operationele verschillen, en de verschillen in prestatie-impact, voor Java-afvalverzamelaars is nog steeds belangrijk.

Throughput Collectors

Beter voor toepassingen die moeten worden geoptimaliseerd voor hoge doorvoer en een hogere latency kunnen inruilen om dat te bereiken.

Serieel –

De seriële collector is de eenvoudigste, en degene die u waarschijnlijk het minst zult gebruiken, omdat hij voornamelijk is ontworpen voor single-threaded omgevingen (bijv. 32-bit of Windows) en voor kleine heaps. Deze collector kan het geheugengebruik in de JVM verticaal schalen, maar vereist meerdere Major/Full GC’s om ongebruikte heap-bronnen vrij te maken. Dit veroorzaakt frequente Stop the World-pauzes, waardoor het in principe niet geschikt is voor gebruik in gebruikersomgevingen.

Parallel –

Zoals de naam al aangeeft, gebruikt deze GC meerdere parallel lopende threads om de heap te scannen en te comprimeren. Hoewel de Parallel GC meerdere threads gebruikt voor het verzamelen van afval, pauzeert het nog steeds alle applicatie-threads tijdens het draaien. De parallelle collector is het meest geschikt voor toepassingen die voor de beste verwerkingscapaciteit moeten worden geoptimaliseerd en in ruil daarvoor een hogere latentie kunnen verdragen.

Low Pause Collectors

De meeste toepassingen die op gebruikers zijn gericht, vereisen een GC met een lage pauze, zodat de gebruikerservaring niet wordt beïnvloed door lange of frequente pauzes. Bij deze GC’s draait alles om het optimaliseren van de reactiesnelheid (tijd/gebeurtenis) en sterke kortetermijnprestaties.

Concurrent Mark Sweep (CMS) –

Gelijk aan het Parallel-verzamelprogramma gebruikt het Concurrent Mark Sweep (CMS)-verzamelprogramma meerdere threads om objecten waarnaar niet wordt verwezen, te markeren en te sweepen (verwijderen). Deze GC start echter alleen Stop de wereld-events in twee specifieke gevallen:

(1) bij het initialiseren van de initiële markering van roots (objecten in de oude generatie die bereikbaar zijn vanaf thread entry points of statische variabelen) of alle referenties van de main() methode, en nog een paar

(2) wanneer de applicatie de toestand van de heap heeft gewijzigd terwijl het algoritme gelijktijdig draaide, waardoor het gedwongen wordt terug te gaan en de laatste hand te leggen aan de markering van de juiste objecten

G1 –

De Garbage first-collector (algemeen bekend als G1) gebruikt meerdere achtergrondthreads om de heap te scannen die het in regio’s verdeelt. De regio’s die de meeste vuilnisobjecten bevatten worden het eerst gescand, waaraan de verzamelprogramma’s hun naam ontlenen (Garbage first).

Deze strategie verkleint de kans dat de heap leeg raakt voordat de achtergrondthreads klaar zijn met het scannen naar ongebruikte objecten, in welk geval het verzamelprogramma de toepassing zou moeten stoppen. Een ander voordeel voor de G1-collector is dat hij de heap on-the-go comprimeert, iets wat de CMS-collector alleen doet tijdens volledige Stop the World-collecties.

Verbeteren van GC-prestaties

Applicatieprestaties worden direct beïnvloed door de frequentie en duur van afvalverzamelingen, wat betekent dat optimalisatie van het GC-proces wordt gedaan door deze metriek te verminderen. Er zijn twee belangrijke manieren om dit te doen. Ten eerste door de heapgrootte van jonge en oude generaties aan te passen, en ten tweede door de toewijzings- en promotiesnelheid van objecten te verlagen.

Het aanpassen van de heapgrootte is niet zo eenvoudig als men zou verwachten. De logische conclusie zou zijn dat het verhogen van de heap-grootte de GC-frequentie zou verlagen terwijl de duur toeneemt, en het verlagen van de heap-grootte de GC-duur zou verlagen terwijl de frequentie toeneemt.

Het is echter een feit dat de duur van een Minor GC niet afhangt van de grootte van de heap, maar van het aantal objecten dat de verzameling overleeft. Dat betekent dat voor toepassingen die meestal kortlevende objecten maken, het verhogen van de grootte van de jonge generatie in feite zowel de GC duur als de frequentie kan verminderen. Als het verhogen van de grootte van de jonge generatie echter zal leiden tot een aanzienlijke toename van objecten die moeten worden gekopieerd in overlevingsruimten, zullen GC-pauzes langer duren, wat zal leiden tot een hogere latentie.

3 Tips om GC-efficiënte code te schrijven

Tip #1: Voorspel Collectiecapaciteiten –

Alle standaard Java-collecties, alsook de meeste aangepaste en uitgebreide implementaties (zoals Trove en Google’s Guava), gebruiken onderliggende arrays (primitief of object-gebaseerd). Aangezien arrays na toewijzing onveranderlijk van grootte zijn, kan het toevoegen van items aan een verzameling er in veel gevallen toe leiden dat een oude onderliggende array wordt verwijderd ten gunste van een grotere, nieuw toegewezen array.

De meeste implementaties van verzamelingen proberen dit her-toewijzingsproces te optimaliseren en het tot een geamortiseerd minimum te beperken, zelfs als de verwachte grootte van de verzameling niet wordt gegeven. De beste resultaten worden echter bereikt door de verzameling bij de constructie de verwachte grootte te geven.

Tip #2: Verwerk streams direct –

Bij het verwerken van gegevensstromen, zoals gegevens die uit bestanden zijn gelezen of gegevens die via het netwerk zijn gedownload, ziet men vaak iets in de trant van:

De resulterende byte-array kan vervolgens worden verwerkt tot een XML-document, JSON-object of Protocol Buffer-bericht, om maar een paar populaire opties te noemen.

Bij grote bestanden of bestanden met een onvoorspelbare grootte is dit natuurlijk een slecht idee, omdat we dan te maken krijgen met OutOfMemoryErrors in het geval dat de JVM geen buffer ter grootte van het hele bestand kan toewijzen.

Een betere manier om dit aan te pakken is om de juiste InputStream (in dit geval FileInputStream) te gebruiken en deze rechtstreeks in de parser in te voeren, zonder eerst het hele bestand in een byte-array in te lezen. Alle grote bibliotheken stellen API’s beschikbaar om streams direct te parsen, bijvoorbeeld:

Tip #3: Gebruik Immutable Objects –

Immuteerbaarheid heeft vele voordelen. Een ervan die zelden de aandacht krijgt die het verdient, is het effect ervan op garbage collection.

Een immutabel object is een object waarvan de velden (en in ons geval met name niet-primitieve velden) niet kunnen worden gewijzigd nadat het object is geconstrueerd.

Immuteerbaarheid impliceert dat alle objecten waarnaar wordt verwezen door een immutabele container zijn gemaakt voordat de constructie van de container is voltooid. In GC-termen: De container is minstens zo jong als de jongste referentie die hij bevat. Dit betekent dat de GC bij het uitvoeren van garbage collection-cycli op jonge generaties, immutable objecten die in oudere generaties liggen kan overslaan, omdat het zeker weet dat ze niet kunnen verwijzen naar iets in de generatie die wordt verzameld.

Minder objecten te scannen betekent minder geheugenpagina’s te scannen, en minder geheugenpagina’s te scannen betekent kortere GC-cycli, wat kortere GC-pauzes en een betere algemene doorvoer betekent.

Voor meer tips en gedetailleerde voorbeelden, bekijk dit bericht over diepgaande tactieken voor het schrijven van geheugenefficiëntere code.

*** Grote dank aan Amit Hurvitz van het R&D-team van OverOps voor zijn passie en inzicht die in dit bericht zijn verwerkt.

Plaats een reactie