Förbättra applikationsprestanda med dessa avancerade GC-tekniker

Applikationsprestanda är en viktig fråga och optimering av Garbage Collection är ett bra ställe att göra små men betydelsefulla framsteg

Automatiserad garbage collection (tillsammans med JIT HotSpot-kompilatorn) är en av de mest avancerade och uppskattade komponenterna i JVM, men många utvecklare och ingenjörer är inte så väl förtrogna med Garbage Collection (GC), hur den fungerar och hur den påverkar applikationsprestanda.

För det första, vad är GC ens till för? Garbage collection är minneshanteringsprocessen för objekt i heap. När objekt allokeras till heap genomgår de några insamlingsfaser – vanligtvis ganska snabbt eftersom majoriteten av objekten i heap har kort livslängd.

Soprumshändelser innehåller tre faser – märkning, radering och kopiering/kompaktering. I den första fasen kör GC igenom heapen och markerar allt antingen som levande (refererade) objekt, icke-refererade objekt eller tillgängligt minnesutrymme. Objekten utan referens tas sedan bort och återstående objekt komprimeras. I generationsbaserade sopkollektioner ”åldras” objekten och befordras genom tre utrymmen i sitt liv – Eden, Survivor space och Tenured (Old) space. Denna förflyttning sker också som en del av komprimeringsfasen.

Men nog om det, låt oss komma till den roliga delen!

Psst! Letar du efter en lösning för att förbättra applikationsprestanda?OverOps hjälper företag att identifiera inte bara när och var fördröjningar inträffar, utan också varför och hur de inträffar. Titta på en live-demo för att se hur det fungerar.

För att lära känna Garbage Collection (GC) i Java

En av de fantastiska sakerna med automatiserad GC är att utvecklare egentligen inte behöver förstå hur den fungerar. Tyvärr innebär det att många utvecklare INTE förstår hur den fungerar. Att förstå garbage collection och de många tillgängliga GC:erna är ungefär som att känna till Linux CLI-kommandon. Tekniskt sett behöver du inte använda dem, men om du känner till dem och blir bekväm med att använda dem kan det ha en betydande inverkan på din produktivitet.

Samma som med CLI-kommandon finns det de absoluta grunderna. ls kommandot för att visa en lista över mappar inom en överordnad mapp, mv för att flytta en fil från en plats till en annan, osv. I GC skulle den typen av kommandon motsvara att veta att det finns mer än en GC att välja mellan och att GC kan orsaka prestandaproblem. Naturligtvis finns det så mycket mer att lära sig (om att använda Linux CLI OCH om garbage collection).

Syftet med att lära sig om Javas garbage collection-process är inte bara för gratis (och tråkiga) konversationsstartare, syftet är att lära sig hur man effektivt implementerar och underhåller rätt GC med optimal prestanda för din specifika miljö. Att veta att garbage collection påverkar applikationens prestanda är grundläggande, och det finns många avancerade tekniker för att förbättra GC-prestanda och minska dess inverkan på applikationens tillförlitlighet.

GC Performance Concerns

Memory Leaks –

Med kunskap om heap-strukturen och hur garbage collection utförs, vet vi att minnesanvändningen gradvis ökar tills en garbage collection-händelse inträffar och användningen sjunker tillbaka. Heap-användningen för refererade objekt förblir vanligtvis stabil så minskningen bör ske till mer eller mindre samma volym.

Med en minnesläcka rensar varje GC-händelse en mindre del av heap-objekten (även om många objekt som lämnas kvar inte används) så heap-användningen fortsätter att öka tills heap-minnet är fullt och ett OutOfMemoryError-undantag utlöses. Orsaken till detta är att GC endast markerar orefererade objekt för radering. Så även om ett refererat objekt inte längre används kommer det inte att rensas från heap. Det finns några användbara kodningstricks för att förhindra detta som vi kommer att ta upp lite senare.

Kontinuerliga ”Stop the World”-händelser –

I vissa scenarier kan garbage collection kallas för en ”Stop the World”-händelse, eftersom när den inträffar stoppas alla trådar i JVM:n (och därmed programmet som körs på den) för att GC ska kunna utföras. I friska applikationer är GC-exekveringstiden relativt låg och har ingen stor effekt på applikationens prestanda.

I suboptimala situationer kan dock Stop the World-händelser i hög grad påverka en applikations prestanda och tillförlitlighet. Om en GC-händelse kräver en Stop the World-paus och tar 2 sekunder att utföra, kommer slutanvändaren av den applikationen att uppleva en fördröjning på 2 sekunder eftersom de trådar som kör applikationen stoppas för att möjliggöra GC.

När minnesläckor uppstår är kontinuerliga Stop the World-händelser också problematiska. Eftersom mindre utrymme i heapminnet rensas vid varje körning av GC tar det mindre tid för det återstående minnet att fyllas upp. När minnet är fullt utlöser JVM en ny GC-händelse. Så småningom kommer JVM:n att köra upprepade Stop the World-händelser som orsakar stora prestandaproblem.

CPU-användning –

Och allt handlar om CPU-användning. Ett viktigt symptom på kontinuerliga GC / Stop the World-händelser är en topp i CPU-användning. GC är en beräkningstung operation och kan därför ta mer än sin beskärda del av processorkraften i anspråk. För GC:er som kör samtidiga trådar kan CPU-användningen vara ännu högre. Att välja rätt GC för din applikation kommer att ha störst inverkan på CPU-användningen, men det finns också andra sätt att optimera för bättre prestanda på detta område.

Vi kan förstå från dessa prestandaproblem kring garbage collection att hur avancerade GC:er än blir (och de blir ganska avancerade) så förblir deras akilleshäl densamma. Redundanta och oförutsägbara objektallokeringar. För att förbättra programprestanda räcker det inte att välja rätt GC. Vi måste veta hur processen fungerar och vi måste optimera vår kod så att våra GC:er inte drar överdrivna resurser eller orsakar överdrivna pauser i vår applikation.

Generationell GC

För att dyka ner i de olika GC:erna i Java och deras inverkan på prestandan är det viktigt att förstå grunderna för generationsbaserad garbage collection. Det grundläggande konceptet för generational GC bygger på idén att ju längre en referens finns till ett objekt i heap, desto mindre sannolikt är det att det markeras för radering. Genom att märka objekt med en figurativ ”ålder” kan de separeras till olika lagringsutrymmen för att markeras av GC mindre ofta.

När ett objekt allokeras till högen placeras det i det som kallas Eden-utrymmet. Det är där objekten börjar och i de flesta fall är det där de markeras för radering. Objekt som överlever det stadiet ”firar en födelsedag” och kopieras till Survivor space. Denna process visas nedan:

Eden- och Survivor-utrymmena utgör det som kallas den unga generationen. Det är här som huvuddelen av handlingen sker. När (om) ett objekt i den unga generationen når en viss ålder befordras det till Tenured (även kallad Old) space. Fördelen med att dela upp Objektminnen baserat på ålder är att GC kan arbeta på olika nivåer.

En Minor GC är en samling som endast fokuserar på Young Generation och i praktiken ignorerar Tenured space helt och hållet. I allmänhet är majoriteten av objekten i den unga generationen markerade för radering och en större eller fullständig GC (inklusive den gamla generationen) är inte nödvändig för att frigöra minne på heapen. Naturligtvis utlöses en Major eller Full GC vid behov.

Ett snabbt knep för att optimera GC-verksamheten baserat på detta är att justera storlekarna på heap-områdena så att de bäst passar dina applikationers behov.

Collector Types

Det finns många tillgängliga GC:er att välja mellan, och även om G1 blev standard-GC i Java 9, så var den ursprungligen tänkt att ersätta CMS-kollektorn, som har låg paus, så applikationer som körs med Throughput-kollektorer kan vara bättre lämpade att stanna kvar med sin nuvarande kollektör. Det är fortfarande viktigt att förstå de operativa skillnaderna, och skillnaderna i prestandapåverkan, för Java garbage collectors.

Throughput Collectors

Bättre för applikationer som måste optimeras för hög genomströmning och som kan byta ut högre latenstid för att uppnå det.

Serial –

Den seriella insamlaren är den enklaste och den som du troligen kommer att använda minst, eftersom den främst är utformad för enkeltrådade miljöer (t.ex. 32-bitars eller Windows) och för små heaps. Denna insamlare kan vertikalt skala minnesanvändningen i JVM men kräver flera Major/Full GCs för att frigöra oanvända heap-resurser. Detta orsakar frekventa Stop the World-pauser, vilket diskvalificerar den i alla avseenden från att användas i användarvänliga miljöer.

Parallel –

Som namnet beskriver använder den här GC:n flera trådar som körs parallellt för att skanna igenom och komprimera heap:en. Även om den parallella GC:n använder flera trådar för sophämtning pausar den fortfarande alla programtrådar när den körs. Den parallella insamlaren lämpar sig bäst för program som behöver optimeras för bästa genomströmning och som kan tolerera högre latenstid i utbyte.

Samlare med låg paus

De flesta program som vänder sig till användaren kommer att kräva en GC med låg paus, så att användarupplevelsen inte påverkas av långa eller frekventa pauser. Dessa GC:er handlar om att optimera för reaktionsförmåga (tid/händelse) och stark kortsiktig prestanda.

Concurrent Mark Sweep (CMS) –

Som liknar den parallella insamlaren använder insamlaren Concurrent Mark Sweep (CMS) flera trådar för att markera och sopa (ta bort) orefererade objekt. Denna GC initierar dock endast Stop the World-händelser endast i två specifika fall:

(1) när den initiala markeringen av rötter (objekt i den gamla generationen som kan nås från trådingångar eller statiska variabler) eller eventuella referenser från main()-metoden initieras, och några fler

(2) när programmet har ändrat tillståndet för högen medan algoritmen kördes samtidigt, vilket tvingar den att gå tillbaka och göra några sista ändringar för att se till att rätt objekt är markerade

G1 –

Den första insamlaren av skräp (Garbage first collector) (allmänt känd som G1) använder sig av flera bakgrundstrådar för att skanna igenom högen som den delar upp i regioner. Den fungerar genom att skanna de regioner som innehåller flest skräpobjekt först, vilket gett den dess namn (Garbage first).

Denna strategi minskar risken för att heapen töms innan bakgrundstrådarna har skannat färdigt efter oanvända objekt, vilket gör att insamlaren i så fall måste stoppa programmet. En annan fördel för G1-kollektorn är att den komprimerar högen i farten, något som CMS-kollektorn endast gör under fullständiga Stop the World-kollektioner.

Förbättring av GC-prestanda

Applikationsprestanda påverkas direkt av frekvensen och varaktigheten av skräpkollektionerna, vilket innebär att optimering av GC-processen sker genom att minska dessa mätvärden. Det finns två huvudsakliga sätt att göra detta. För det första genom att justera heapstorlekarna för unga och gamla generationer, och för det andra genom att minska hastigheten för objektallokering och befordran.

För att justera heapstorlekarna är det inte så enkelt som man skulle kunna tro. Den logiska slutsatsen skulle vara att en ökning av heapstorleken skulle minska GC-frekvensen samtidigt som varaktigheten ökar, och en minskning av heapstorleken skulle minska GC-varaktigheten samtidigt som frekvensen ökar.

Fakten är dock att varaktigheten för en Minor GC inte beror på heapstorleken, utan på antalet objekt som överlever insamlingen. Det betyder att för program som mestadels skapar kortlivade objekt kan en ökning av storleken på den unga generationen faktiskt minska både GC-durationen och frekvensen. Om en ökning av storleken på den unga generationen leder till en betydande ökning av antalet objekt som måste kopieras i överlevnadsutrymmen kommer dock GC-pauserna att ta längre tid, vilket leder till ökad latenstid.

3 tips för att skriva GC-effektiv kod

Tip #1: Förutse insamlingskapaciteten –

Alla standardinsamlingar i Java, liksom de flesta anpassade och utökade implementationer (t.ex. Trove och Googles Guava), använder sig av underliggande matriser (antingen primitiva- eller objektbaserade). Eftersom arrayer är oföränderliga i storlek när de väl är allokerade kan tillägg av objekt till en samling i många fall leda till att en gammal underliggande array släpps till förmån för en större nyallokerad array.

De flesta insamlingsimplementationer försöker optimera denna omallokeringsprocess och hålla den till ett avskrivet minimum, även om den förväntade storleken på samlingen inte anges. De bästa resultaten kan dock uppnås genom att tillhandahålla samlingen med dess förväntade storlek vid konstruktionen.

Tip #2: Bearbeta strömmar direkt –

När man bearbetar dataströmmar, t.ex. data som läses från filer eller data som laddas ner över nätverket, är det mycket vanligt att man ser något i stil med:

Den resulterande byte-matrisen kan sedan analyseras till ett XML-dokument, ett JSON-objekt eller ett protokollbuffertmeddelande, för att nämna några populära alternativ.

När det gäller stora filer eller filer av oförutsägbar storlek är detta naturligtvis en dålig idé, eftersom det utsätter oss för OutOfMemoryErrors om JVM faktiskt inte kan allokera en buffert av hela filens storlek.

Ett bättre sätt att närma sig detta är att använda lämplig InputStream (FileInputStream i det här fallet) och mata in den direkt i parsern, utan att först läsa in det hela i en byte array. Alla större bibliotek tillhandahåller API:er för att analysera strömmar direkt, till exempel:

Tip #3: Använd omutbara objekt –

Immuterbarhet har många fördelar. En som sällan får den uppmärksamhet den förtjänar är dess effekt på garbage collection.

Ett immutabelt objekt är ett objekt vars fält (och särskilt icke-primitiva fält i vårt fall) inte kan ändras efter att objektet har konstruerats.

Immutabilitet innebär att alla objekt som refereras av en immutabel behållare har skapats innan konstruktionen av behållaren avslutas. I GC-termer: Det är en container som är minst lika ung som den yngsta referensen som den innehåller. Detta innebär att när GC:n utför sophämtningscykler på unga generationer kan den hoppa över oföränderliga objekt som ligger i äldre generationer, eftersom den är säker på att de inte kan referera till något i den generation som samlas in.

Mindre objekt att skanna innebär färre minnessidor att skanna, och färre minnessidor att skanna innebär kortare GC-cykler, vilket innebär kortare GC-pauser och bättre total genomströmning.

För fler tips och detaljerade exempel kan du läsa det här inlägget om ingående taktik för att skriva mer minneseffektiv kod.

*** Ett stort tack till Amit Hurvitz från OverOps R&D-team för hans passion och insikt i det här inlägget!

Lämna en kommentar