Forbedre applikationspræstationen med disse avancerede GC-teknikker

Applikationspræstationen er i fokus, og optimering af Garbage Collection er et godt sted at gøre små, men betydningsfulde fremskridt

Automatisk garbage collection (sammen med JIT HotSpot Compiler) er en af de mest avancerede og mest værdsatte komponenter i JVM’en, men mange udviklere og ingeniører er langt mindre fortrolige med Garbage Collection (GC), hvordan den fungerer, og hvordan den påvirker applikationspræstationen.

Først, hvad er GC overhovedet til for? Garbage Collection er hukommelseshåndteringsprocessen for objekter i heap’en. Når objekter allokeres til heap’en, gennemløber de et par opsamlingsfaser – normalt ret hurtigt, da størstedelen af objekter i heap’en har en kort levetid.

Garbage collection-hændelser indeholder tre faser – markering, sletning og kopiering/komprimering. I den første fase kører GC’en heap’en igennem og markerer alt enten som levende (refererede) objekter, ikke-refererede objekter eller ledig hukommelsesplads. Objekter uden reference slettes derefter, og de resterende objekter komprimeres. I generationsbaserede skraldesamlinger “ældes” objekter og forfremmes gennem 3 rum i deres liv – Eden, Survivor space og Tenured (Old) space. Dette skift sker også som en del af komprimeringsfasen.

Men nok om det, lad os komme til den sjove del!

Psst! Leder du efter en løsning til at forbedre applikationspræstationen?OverOps hjælper virksomheder med at identificere ikke kun hvornår og hvor der opstår forsinkelser, men også hvorfor og hvordan de opstår. Se en live-demo for at se, hvordan det fungerer.

Lær Garbage Collection (GC) i Java at kende

En af de gode ting ved automatiseret GC er, at udviklere ikke rigtig behøver at forstå, hvordan det fungerer. Desværre betyder det, at mange udviklere IKKE forstår, hvordan den fungerer. At forstå garbage collection og de mange tilgængelige GC’er, er lidt ligesom at kende Linux CLI-kommandoer. Du behøver teknisk set ikke at bruge dem, men hvis du kender dem og bliver fortrolig med at bruge dem, kan det have en betydelig indvirkning på din produktivitet.

Som med CLI-kommandoer er der de absolut grundlæggende. ls kommando til at se en liste over mapper i en overordnet mappe, mv til at flytte en fil fra et sted til et andet osv. I GC ville den slags kommandoer svare til at vide, at der er mere end en GC at vælge imellem, og at GC kan give problemer med ydeevnen. Selvfølgelig er der så meget mere at lære (om at bruge Linux CLI OG om garbage collection).

Formålet med at lære om Javas garbage collection-proces er ikke kun for at få gratis (og kedelige) samtalestartere, formålet er at lære, hvordan man effektivt implementerer og vedligeholder den rigtige GC med optimal ydelse for dit specifikke miljø. Det er grundlæggende at vide, at garbage collection påvirker applikationens ydeevne, og der findes mange avancerede teknikker til at forbedre GC-ydelsen og reducere dens indvirkning på applikationens pålidelighed.

GC Performance Concerns

Memory Leaks –

Med viden om heap-strukturen og hvordan garbage collection udføres, ved vi, at hukommelsesforbruget gradvist stiger, indtil en garbage collection-hændelse indtræffer, og forbruget falder ned igen. Heap-anvendelsen for refererede objekter forbliver normalt stabil, så faldet bør være til mere eller mindre samme volumen.

Med en hukommelseslækage rydder hver GC-hændelse en mindre del af heap-objekterne (selv om mange objekter, der efterlades, ikke er i brug), så heap-anvendelsen vil fortsætte med at stige, indtil heap-hukommelsen er fuld, og der vil blive kastet en OutOfMemoryError-undtagelse. Årsagen til dette er, at GC kun markerer objekter, der ikke refereres, til sletning. Så selv om et objekt med reference ikke længere er i brug, vil det ikke blive slettet fra heap’en. Der er nogle nyttige kodetricks til at forhindre dette, som vi dækker lidt senere.

Kontinuerlige “Stop the World”-hændelser –

I nogle scenarier kan garbage collection kaldes en Stop the World-hændelse, fordi alle tråde i JVM’en (og dermed det program, der kører på den) stoppes, når den opstår, for at GC kan udføre den. I sunde programmer er GC-eksekveringstiden relativt lav og har ikke nogen stor indvirkning på programmets ydeevne.

I suboptimale situationer kan Stop the World-hændelser imidlertid have stor indflydelse på et programs ydeevne og pålidelighed. Hvis en GC-hændelse kræver en Stop the World-pause og tager 2 sekunder at udføre, vil slutbrugeren af den pågældende applikation opleve en forsinkelse på 2 sekunder, da de tråde, der kører applikationen, stoppes for at tillade GC.

Når der opstår hukommelseslækager, er kontinuerlige Stop the World-hændelser også problematiske. Da mindre heap-hukommelsesplads renses ved hver udførelse af GC’en, tager det mindre tid for den resterende hukommelse at blive fyldt op. Når hukommelsen er fuld, udløser JVM’en endnu en GC-hændelse. Til sidst vil JVM’en køre gentagne Stop the World-hændelser, hvilket giver store problemer med ydeevnen.

CPU-forbrug –

Og det hele handler om CPU-forbrug. Et vigtigt symptom på kontinuerlige GC / Stop the World-hændelser er en spids i CPU-forbruget. GC er en beregningstung operation og kan derfor tage mere end sin del af CPU-kraften. For GC’er, der kører samtidige tråde, kan CPU-forbruget være endnu højere. Valg af den rigtige GC til din applikation vil have den største indvirkning på CPU-forbruget, men der er også andre måder at optimere for bedre ydeevne på dette område.

Vi kan forstå ud fra disse ydelsesproblemer omkring garbage collection, at uanset hvor avancerede GC’er bliver (og de er ved at blive ret avancerede), forbliver deres akilleshæl den samme. Redundante og uforudsigelige objektallokeringer. For at forbedre applikationens ydeevne er det ikke nok at vælge den rigtige GC. Vi skal vide, hvordan processen fungerer, og vi skal optimere vores kode, så vores GC’er ikke trækker for mange ressourcer eller forårsager for store pauser i vores applikation.

Generational GC

Hvor vi dykker ned i de forskellige Java GC’er og deres indvirkning på ydeevnen, er det vigtigt at forstå det grundlæggende i generational garbage collection. Det grundlæggende koncept for generational GC er baseret på den idé, at jo længere en reference eksisterer til et objekt i heap’en, jo mindre sandsynligt er det, at det bliver markeret til sletning. Ved at mærke objekter med en figurativ “alder” kan de adskilles i forskellige lagerrum for at blive markeret af GC mindre hyppigt.

Når et objekt allokeres til heap’en, placeres det i det, der kaldes Eden-rummet. Det er der, hvor objekterne starter, og i de fleste tilfælde er det der, de bliver markeret til sletning. Objekter, der overlever denne fase, “fejrer en fødselsdag” og kopieres til Survivor space. Denne proces er vist nedenfor:

Eden- og Survivor-rummene udgør det, der kaldes den unge generation. Det er her, hovedparten af handlingen foregår. Når (hvis) et objekt i den unge generation når en vis alder, bliver det forfremmet til Tenured-rummet (også kaldet Old-rummet). Fordelen ved at opdele Object memories baseret på alder er, at GC’en kan operere på forskellige niveauer.

En Minor GC er en samling, der kun fokuserer på Young Generation, og som effektivt ignorerer Tenured-rummet helt og holdent. Generelt er størstedelen af objekterne i den unge generation markeret til sletning, og en større eller fuld GC (inklusive den gamle generation) er ikke nødvendig for at frigøre hukommelse på heap’en. Selvfølgelig vil en Major eller Full GC blive udløst, når det er nødvendigt.

Et hurtigt trick til optimering af GC-operationen baseret på dette er at justere størrelserne på heap-områderne, så de passer bedst muligt til dine applikationers behov.

Collector Types

Der er mange tilgængelige GC’er at vælge imellem, og selv om G1 blev standard-GC i Java 9, var det oprindeligt meningen, at den skulle erstatte CMS-collector, som er Low Pause, så applikationer, der kører med Throughput-collectors, kan være bedre egnet til at blive med deres nuværende collector. Det er stadig vigtigt at forstå de operationelle forskelle og forskellene i ydelsesmæssige konsekvenser for Java garbage collectors.

Throughput Collectors

Bedre for applikationer, der skal optimeres til høj gennemstrømning og kan bytte højere latenstid for at opnå det.

Seriel –

Den serielle opsamler er den enkleste, og den, du sandsynligvis mindst vil bruge, da den primært er designet til enkelttrådede miljøer (f.eks. 32-bit eller Windows) og til små heaps. Denne opsamler kan vertikalt skalere hukommelsesforbruget i JVM’en, men kræver flere Major/Full GC’er for at frigive ubrugte heapressourcer. Dette forårsager hyppige Stop the World-pauser, hvilket diskvalificerer den til alle formål fra at blive brugt i brugervendte miljøer.

Parallel –

Som navnet beskriver, bruger denne GC flere tråde, der kører parallelt, til at scanne gennem og komprimere heap’en. Selv om den parallelle GC bruger flere tråde til garbage collection, sætter den stadig alle programtråde på pause, mens den kører. Den parallelle opsamler er bedst egnet til programmer, der skal optimeres for det bedste gennemløb og kan tolerere en højere latenstid til gengæld.

Low Pause Collectors

De fleste brugervendte programmer vil kræve en GC med lav pause, så brugeroplevelsen ikke påvirkes af lange eller hyppige pauser. Disse GC’er handler om at optimere for responsivitet (tid/begivenhed) og stærk kortsigtet ydeevne.

Concurrent Mark Sweep (CMS) –

I lighed med Parallel Collector anvender Concurrent Mark Sweep (CMS)-opsamleren flere tråde til at markere og feje (fjerne) objekter, der ikke er refereret. Denne GC starter dog kun Stop the World-hændelser kun i to specifikke tilfælde:

(1) ved initialisering af den indledende markering af rødder (objekter i den gamle generation, der kan nås fra trådindgangspunkter eller statiske variabler) eller eventuelle referencer fra main()-metoden, og et par stykker mere

(2), når programmet har ændret tilstanden af heap’en, mens algoritmen kørte sideløbende, hvilket tvinger den til at gå tilbage og foretage nogle sidste justeringer for at sikre, at den har de rigtige objekter markeret

G1 –

Garbage first collector (almindeligvis kendt som G1) anvender flere baggrundstråde til at scanne gennem heap’en, som den opdeler i regioner. Den fungerer ved at scanne de regioner, der indeholder flest affaldsobjekter først, hvilket har givet den dens navn (Garbage first).

Denne strategi reducerer risikoen for, at heap’en bliver opbrugt, før baggrundstrådene er færdige med at scanne efter ubrugte objekter, hvilket i så fald ville betyde, at opsamleren skulle stoppe programmet. En anden fordel for G1-opsamleren er, at den komprimerer heap’en undervejs, hvilket CMS-opsamleren kun gør under fulde Stop the World-opsamlinger.

Forbedring af GC-ydelsen

Applikationens ydelse påvirkes direkte af frekvensen og varigheden af garbage collections, hvilket betyder, at optimering af GC-processen sker ved at reducere disse metrikker. Der er to vigtige måder at gøre dette på. For det første ved at justere heapstørrelserne for unge og gamle generationer, og for det andet ved at reducere hastigheden af objektallokering og -fremmelse.

Med hensyn til justering af heapstørrelser er det ikke så ligetil, som man kunne forvente. Den logiske konklusion ville være, at en forøgelse af heapstørrelsen ville mindske GC-frekvensen, mens varigheden øges, og en formindskelse af heapstørrelsen ville mindske GC-varigheden, mens frekvensen øges.

Sagen er dog den, at varigheden af en mindre GC ikke er afhængig af heapstørrelsen, men af antallet af objekter, der overlever indsamlingen. Det betyder, at for programmer, der for det meste opretter kortlivede objekter, kan en forøgelse af størrelsen af den unge generation faktisk reducere både GC-varigheden og -frekvensen. Men hvis en forøgelse af størrelsen af den unge generation vil føre til en betydelig stigning i antallet af objekter, der skal kopieres i overlevende rum, vil GC-pauser tage længere tid, hvilket fører til øget latens.

3 tips til at skrive GC-effektiv kode

Tip #1: Forudsig opsamlingskapaciteter –

Alle standard Java-samlinger samt de fleste brugerdefinerede og udvidede implementeringer (såsom Trove og Googles Guava) anvender underliggende arrays (enten primitive- eller objektbaserede). Da arrays er uforanderlige i størrelse, når de først er allokeret, kan tilføjelse af elementer til en samling i mange tilfælde medføre, at et gammelt underliggende array slettes til fordel for et større nyallokeret array.

De fleste implementeringer af samlinger forsøger at optimere denne genallokeringsproces og holde den til et afskrevet minimum, selv om den forventede størrelse af samlingen ikke er oplyst. De bedste resultater kan dog opnås ved at give samlingen dens forventede størrelse ved konstruktionen.

Tip #2: Behandl streams direkte –

Når man behandler datastrømme, f.eks. data læst fra filer eller data hentet over netværket, er det meget almindeligt at se noget i retning af:

Det resulterende bytearray kan derefter analyseres til et XML-dokument, et JSON-objekt eller en Protocol Buffer-meddelelse, for at nævne nogle få populære muligheder.

Når der er tale om store filer eller filer af uforudsigelig størrelse, er dette naturligvis en dårlig idé, da det udsætter os for OutOfMemoryErrors, hvis JVM’en faktisk ikke kan allokere en buffer på størrelse med hele filen.

En bedre måde at gribe dette an på er at bruge den relevante InputStream (FileInputStream i dette tilfælde) og fodre den direkte ind i parseren uden først at læse det hele ind i et byte array. Alle større biblioteker udsætter API’er til at parse streams direkte, f.eks.:

Tip #3: Brug immutable objekter –

Immutabilitet har mange fordele. En af dem, der sjældent får den opmærksomhed, den fortjener, er dens effekt på garbage collection.

Et immutabelt objekt er et objekt, hvis felter (og specifikt ikke-primitive felter i vores tilfælde) ikke kan ændres, efter at objektet er blevet konstrueret.

Immutabilitet indebærer, at alle objekter, der refereres til af en immutabel container, er blevet oprettet, før konstruktionen af containeren er færdiggjort. I GC-termer: Containeren er mindst lige så ung som den yngste reference, den indeholder. Det betyder, at GC’en, når den udfører garbage collection-cyklusser på unge generationer, kan springe immutable objekter, der ligger i ældre generationer, over, da den ved med sikkerhed, at de ikke kan referere til noget i den generation, der indsamles.

Mindre objekter at scanne betyder færre hukommelsessider at scanne, og færre hukommelsessider at scanne betyder kortere GC-cyklusser, hvilket betyder kortere GC-pauser og bedre samlet gennemløb.

For flere tips og detaljerede eksempler kan du tjekke dette indlæg, der dækker dybdegående taktikker til at skrive mere hukommelseseffektiv kode.

*** En stor tak til Amit Hurvitz fra OverOps’ R&D-team for hans passion og indsigt, der indgik i dette indlæg!

Skriv en kommentar