Optimering av JVM-prestanda, del 3: Skräpplockning

Java-plattformens mekanism för skräpplockning ökar utvecklarens produktivitet avsevärt, men en dåligt implementerad skräpplockare kan överkonsumera programresurser. I den här tredje artikeln i serien om optimering av JVM-prestanda ger Eva Andreasson Java-nybörjare en översikt över Java-plattformens minnesmodell och GC-mekanism. Hon förklarar sedan varför fragmentering (och inte GC) är den största “gotcha!” för Java-applikationers prestanda, och varför generationsbaserad garbage collection och komprimering för närvarande är de ledande (om än inte de mest innovativa) tillvägagångssätten för att hantera heap-fragmentering i Java-applikationer.

Garbage collection (GC) är den process som syftar till att frigöra upptaget minne som inte längre hänvisas till av något nåbart Java-objekt, och är en väsentlig del av den virtuella Javamaskinens (JVM:s) dynamiska minneshanteringssystem. I en typisk cykel för sophämtning behålls alla objekt som fortfarande är refererade och därmed nåbara. Det utrymme som upptas av tidigare refererade objekt frigörs och återtas för att möjliggöra allokering av nya objekt.

För att förstå garbage collection och de olika GC-strategierna och -algoritmerna måste du först veta några saker om Javaplattformens minnesmodell.

Garage collection and the Java platform memory model

När du anger startalternativet -Xmx på kommandoraden för din Java-applikation (till exempel: java -Xmx:2g MyApp) tilldelas minne till en Java-process. Detta minne kallas Java-heap (eller bara heap). Detta är det dedikerade minnesadressutrymmet där alla objekt som skapas av ditt Javaprogram (eller ibland JVM) allokeras. När ditt Javaprogram fortsätter att köra och allokera nya objekt kommer Java-heap (dvs. detta adressutrymme) att fyllas upp.

Till slut kommer Java-heap att vara full, vilket innebär att en allokerande tråd inte kan hitta en tillräckligt stor sammanhängande sektion av ledigt minne för det objekt som den vill allokera. Vid den tidpunkten fastställer JVM att en sophämtning måste ske och meddelar sophämtaren. En skräpplockning kan också utlösas när ett Javaprogram anropar System.gc(). Att använda System.gc() garanterar inte en sophämtning. Innan en skräpplockning kan starta kommer en GC-mekanism först att avgöra om det är säkert att starta den. Det är säkert att starta en skräpplockning när alla programmets aktiva trådar befinner sig i en säker punkt som tillåter det, t.ex. enkelt förklarat skulle det vara dåligt att starta en skräpplockning mitt i en pågående objektsallokering, eller mitt i utförandet av en sekvens av optimerade CPU-instruktioner (se min tidigare artikel om kompilatorer), eftersom du kan förlora kontexten och därmed förstöra slutresultatet.

En skräpplockare bör aldrig återkräva ett objekt som aktivt refereras till; att göra det skulle bryta mot specifikationen för Javas virtuella maskin. En skräpplockare behöver inte heller omedelbart samla in döda objekt. Döda objekt samlas så småningom in under efterföljande sophämtningscykler. Det finns många olika sätt att genomföra garbage collection, men dessa två antaganden gäller för alla varianter. Den verkliga utmaningen med garbage collection är att identifiera allt som är levande (fortfarande refererat) och återta allt orefererat minne, men utan att påverka pågående program mer än nödvändigt. En garbage collector har således två uppdrag:

  1. Att snabbt frigöra orefererat minne för att uppfylla en applikations tilldelningshastighet så att den inte får slut på minne.
  2. Att återkräva minne samtidigt som prestandan påverkas minimalt (t.ex, latenstid och genomströmning) för ett pågående program.

Två typer av garbage collection

I den första artikeln i den här serien berörde jag de två huvudsakliga tillvägagångssätten för garbage collection, som är referensräknande och spårande samlare. Den här gången kommer jag att borra ner ytterligare i varje tillvägagångssätt och sedan presentera några av de algoritmer som används för att implementera tracing collectors i produktionsmiljöer.

Läs serien om optimering av JVM-prestanda

  • Optimering av JVM-prestanda, del 1: Översikt
  • JVM-prestandaoptimering, del 2: Kompilatorer

Refensräkningssamlare

Refensräkningssamlare håller reda på hur många referenser som pekar på varje Java-objekt. När räkningen för ett objekt blir noll kan minnet omedelbart återvinnas. Denna omedelbara tillgång till återvunnet minne är den stora fördelen med referensräkning som metod för sophämtning. Det finns mycket lite overhead när det gäller att hålla fast vid oreferenserat minne. Att hålla alla referensräkningar uppdaterade kan dock vara ganska kostsamt.

Den största svårigheten med referensräkningssamlare är att hålla referensräkningarna korrekta. En annan välkänd utmaning är komplexiteten i samband med hanteringen av cirkulära strukturer. Om två objekt refererar till varandra och inget levande objekt refererar till dem kommer deras minne aldrig att frigöras. Båda objekten kommer för alltid att ha ett antal som inte är noll. För att återkräva minne i samband med cirkulära strukturer krävs stora analyser, vilket medför kostsam overhead för algoritmen och därmed för programmet.

Spårningskollektorer

Spårningskollektorer bygger på antagandet att alla levande objekt kan hittas genom att iterativt spåra alla referenser och efterföljande referenser från en initial uppsättning av kända levande objekt. Den första uppsättningen levande objekt (kallade rotobjekt eller bara rötter) lokaliseras genom att analysera register, globala fält och stackramar i det ögonblick då en skräpplockning utlöses. När en första uppsättning levande objekt har identifierats följer spårningsinsamlaren referenser från dessa objekt och ställer dem i kö för att markeras som levande och därefter få sina referenser spårade. Att markera alla funna refererade objekt som är levande innebär att den kända levande uppsättningen ökar med tiden. Denna process fortsätter tills alla refererade (och därmed alla levande) objekt har hittats och markerats. När spårningskollektorn har hittat alla levande objekt återkräver den det återstående minnet.

Spårningskollektorer skiljer sig från referensräkningskollektorer genom att de kan hantera cirkulära strukturer. Haken med de flesta spårningskollektorer är markeringsfasen, som innebär en väntetid innan man kan återkräva icke-referenserat minne.

Spårningskollektorer används oftast för minneshantering i dynamiska språk; de är överlägset vanligast för språket Java och har varit kommersiellt beprövade i produktionsmiljöer i många år. Jag kommer att fokusera på spårningsinsamlare för resten av den här artikeln och börja med några av de algoritmer som implementerar det här tillvägagångssättet för skräpplockning.

Spårningsinsamlaralgoritmer

Kopiering och mark-and-sweep-skräpplockning är inte nya, men de är fortfarande de två vanligaste algoritmerna som implementerar spårningsskräpplockning idag.

Kopierande insamlare

Traditionella kopierande insamlare använder ett from-space och ett to-space – det vill säga två separat definierade adressutrymmen i högen. Vid tidpunkten för sophämtning kopieras de levande objekten inom det område som definieras som from-space till nästa tillgängliga utrymme inom det område som definieras som to-space. När alla levande objekt inom from-space har flyttats ut kan hela from-space återvinnas. När tilldelningen börjar igen startar den från den första lediga platsen i to-space.

I äldre implementeringar av denna algoritm byter from-space och to-space plats, vilket innebär att när to-space är fullt utlöses garbage collection igen och to-space blir till from-space, enligt figur 1.

Figur 1. En traditionell kopierande skräpplockningssekvens (klicka för att förstora)

Mer moderna implementeringar av kopieringsalgoritmen gör det möjligt att tilldela godtyckliga adressutrymmen inom högen som to-space och from-space. I dessa fall behöver de inte nödvändigtvis byta plats med varandra, utan blir snarare ett annat adressutrymme inom heap.

En fördel med kopierande insamlare är att objekten allokeras tätt tillsammans i to-space, vilket helt eliminerar fragmentering. Fragmentering är ett vanligt problem som andra skräpinsamlingsalgoritmer kämpar med; något som jag kommer att diskutera senare i den här artikeln.

Neddelar med kopierande insamlare

Kopierande insamlare är vanligen stop-the-world-insamlare, vilket innebär att inget programarbete kan exekveras så länge som skräpinsamlingen är i cykel. I en stop-the-world-implementering gäller att ju större område du behöver kopiera, desto större blir påverkan på programprestandan. Detta är en nackdel för program som är känsliga för svarstid. Med en kopierande insamlare måste du också ta hänsyn till det värsta scenariot, när allt är levande i från-området. Du måste alltid lämna tillräckligt med utrymme för att levande objekt ska kunna flyttas, vilket innebär att to-space måste vara tillräckligt stort för att hysa allt i from-space. Kopieringsalgoritmen är något minnesineffektiv på grund av denna begränsning.

Mark-and-sweep-kollektorer

De flesta kommersiella JVM:er som används i produktionsmiljöer i företag kör mark-and-sweep-kollektorer (eller markeringskollektorer), som inte har den prestandapåverkan som kopieringskollektorer har. Några av de mest kända markörinsamlarna är CMS, G1, GenPar och DeterministicGC (se Resurser).

En markörinsamlare spårar referenser och markerar varje funnet objekt med en “levande” bit. Vanligtvis motsvarar en satt bit en adress eller i vissa fall en uppsättning adresser på högen. Den levande biten kan till exempel lagras som en bit i objekthuvudet, en bitvektor eller en bitmapp.

När allt har markerats som levande kommer svepfasen igång. Om en insamlare har en svepfas innehåller den i princip någon mekanism för att korsa heapen igen (inte bara den levande uppsättningen utan hela heaplängden) för att lokalisera alla icke-märkta bitar av på varandra följande minnesadressutrymmen. Icke-märkt minne är fritt och kan återkrävas. Insamlaren kopplar sedan samman dessa omarkerade delar till organiserade fria listor. Det kan finnas olika fria listor i en skräpplockare – vanligtvis organiserade efter storleken på bitarna. Vissa JVM:er (t.ex. JRockit Real Time) implementerar insamlare med heuristik som dynamiskt ordnar storlekslistor baserat på data från programprofilering och statistik över objektens storlek.

När svepfasen är avslutad börjar allokeringen igen. Nya allokeringsområden allokeras från de fria listorna och minnesdelar kan matchas mot objektstorlekar, genomsnitt av objektstorlekar per tråd-ID eller de programanpassade TLAB-storlekarna. Genom att anpassa lediga områden bättre till storleken på det som programmet försöker allokera optimeras minnet och kan bidra till att minska fragmenteringen.

Mer om TLAB-storlekar

TLAB- och TLA-partitionering (Thread Local Allocation Buffer eller Thread Local Area) diskuteras i JVM-prestandaoptimering, del 1.

Neddelar med mark- och svepinsamlare

Markeringsfasen är beroende av mängden levande data på din heap, medan svepfasen är beroende av heapstorleken. Eftersom du måste vänta tills både markerings- och svepfasen är klar för att återkräva minne, orsakar den här algoritmen paustidsutmaningar för större heaps och större levande datamängder.

Ett sätt att hjälpa kraftigt minneskrävande program är att använda GC-avstämningsalternativ som passar olika programscenarier och behov. Tuning kan i många fall hjälpa till att åtminstone skjuta upp någon av dessa faser från att bli en risk för din applikation eller servicenivåavtal (SLA). (Ett SLA anger att applikationen ska uppfylla vissa svarstider – dvs. latens – för applikationen.) Att trimma för varje belastningsändring och programändring är dock en repetitiv uppgift, eftersom trimningen endast gäller för en viss arbetsbelastning och tilldelningshastighet.

Implementeringar av mark-and-sweep

Det finns minst två kommersiellt tillgängliga och beprövade tillvägagångssätt för att implementera mark-and-sweep-insamling. Den ena är det parallella tillvägagångssättet och den andra är det samtidiga (eller mestadels samtidiga) tillvägagångssättet.

Parallella insamlare

Parallell insamling innebär att resurser som tilldelats processen används parallellt i syfte att samla in sopor. De flesta kommersiellt implementerade parallella insamlare är monolitiska stop-the-world-insamlare — alla programtrådar stoppas tills hela sophämtningscykeln är avslutad. Genom att stoppa alla trådar kan alla resurser användas effektivt parallellt för att slutföra sophämtningen genom markerings- och svepfaserna. Detta leder till en mycket hög effektivitet, vilket vanligen resulterar i höga poäng i genomströmningsjämförelser som SPECjbb. Om genomströmning är viktigt för din applikation är det parallella tillvägagångssättet ett utmärkt val.

Lämna ett svar

Din e-postadress kommer inte publiceras.