Java >> Java opplæring >  >> Java

Forbedre applikasjonsytelsen med disse avanserte GC-teknikkene

App-ytelse er i forkant av våre sinn, og Garbage Collection-optimalisering er et bra sted å gjøre små, men meningsfulle fremskritt

Automatisk søppelinnsamling (sammen med JIT HotSpot Compiler) er en av de mest avanserte og mest verdsatte komponenter i JVM, men mange utviklere og ingeniører er langt mindre kjent med Garbage Collection (GC), hvordan det fungerer og hvordan det påvirker applikasjonsytelsen.

For det første, hva er GC selv for? Søppelinnsamling er minnehåndteringsprosessen for objekter i haugen. Ettersom objekter tildeles til haugen, går de gjennom noen få innsamlingsfaser – vanligvis ganske raskt ettersom flertallet av objektene i haugen har kort levetid.

Søppelhentingsarrangementer inneholder tre faser – merking, sletting og kopiering/komprimering. I den første fasen kjører GC gjennom haugen og merker alt enten som levende (refererte) objekter, ikke-refererte objekter eller tilgjengelig minneplass. Ikke-refererte objekter blir deretter slettet, og gjenværende objekter komprimeres. I generasjons søppelsamlinger "eldes" objekter og markedsføres gjennom tre områder i livet deres – Eden, Survivor Space og Tenured (gamle) plass. Denne forskyvningen skjer også som en del av komprimeringsfasen.

Men nok om det, la oss komme til den morsomme delen!

Bli kjent med Garbage Collection (GC) i Java

En av de flotte tingene med automatisert GC er at utviklere egentlig ikke trenger for å forstå hvordan det fungerer. Dessverre betyr det at mange utviklere IKKE forstår hvordan det fungerer. Å forstå søppelinnsamling og de mange tilgjengelige GC-ene er litt som å kjenne til Linux CLI-kommandoer. Du trenger ikke teknisk sett å bruke dem, men å kjenne og bli komfortabel med å bruke dem kan ha en betydelig innvirkning på produktiviteten din.

Akkurat som med CLI-kommandoer, er det det absolutte grunnleggende. ls kommando for å vise en liste over mapper i en overordnet mappe, mv å flytte en fil fra en plassering til en annen osv. I GC vil slike kommandoer tilsvare å vite at det er mer enn én GC å velge mellom, og at GC kan forårsake ytelsesbekymringer. Selvfølgelig er det så mye mer å lære (om bruk av Linux CLI OG om søppelinnsamling).

Hensikten med å lære om Javas søppelinnsamlingsprosess er ikke bare for gratis (og kjedelige) samtalestartere, hensikten er å lære hvordan du effektivt implementerer og vedlikeholder riktig GC med optimal ytelse for ditt spesifikke miljø. Å vite at søppelinnsamling påvirker applikasjonsytelsen er grunnleggende, og det er mange avanserte teknikker for å forbedre GC-ytelsen og redusere dens innvirkning på applikasjonens pålitelighet.

GC ytelsesbekymringer

1. Minnelekkasjer –

Med kunnskap om haugstruktur og hvordan søppelinnsamling utføres, vet vi at minnebruken øker gradvis inntil en søppelinnsamlingshendelse inntreffer og bruken faller ned igjen. Heap-utnyttelsen for refererte objekter forblir vanligvis jevn, så fallet bør være til mer eller mindre samme volum.

Med en minnelekkasje sletter hver GC-hendelse en mindre del av heap-objekter (selv om mange gjenstående gjenstander ikke er i bruk), slik at heap-utnyttelsen vil fortsette å øke til heap-minnet er fullt og et OutOfMemoryError-unntak vil bli kastet. Årsaken til dette er at GC bare merker ikke-refererte objekter for sletting. Så selv om et referert objekt ikke lenger er i bruk, blir det ikke fjernet fra haugen. Det er noen nyttige kodetriks for å forhindre dette som vi skal dekke litt senere.

2. Kontinuerlige «Stopp verden»-arrangementer –

I noen scenarier kan søppelinnsamling kalles en Stop the World-hendelse fordi når den oppstår, stoppes alle tråder i JVM (og dermed applikasjonen som kjører på den) for å tillate GC å kjøre. I sunne applikasjoner er GC-utførelsestiden relativt lav og har ikke stor effekt på applikasjonsytelsen.

I suboptimale situasjoner kan Stop the World-hendelser imidlertid ha stor innvirkning på ytelsen og påliteligheten til en applikasjon. Hvis en GC-hendelse krever en Stop the World-pause og tar 2 sekunder å kjøre, vil sluttbrukeren av den applikasjonen oppleve en forsinkelse på 2 sekunder ettersom trådene som kjører applikasjonen stoppes for å tillate GC.

Når det oppstår minnelekkasjer, er kontinuerlige Stop the World-hendelser også problematiske. Ettersom mindre haugminneplass blir tømt med hver kjøring av GC, tar det mindre tid før det gjenværende minnet fylles opp. Når minnet er fullt, utløser JVM en annen GC-hendelse. Etter hvert vil JVM kjøre gjentatte Stop the World-arrangementer som forårsaker store ytelsesbekymringer.

3. CPU-bruk –

Og alt kommer ned til CPU-bruk. Et hovedsymptom på kontinuerlige GC / Stop the World-hendelser er en økning i CPU-bruk. GC er en regnemessig tung operasjon, og kan derfor ta mer enn sin rettferdige del av CPU-kraften. For GC-er som kjører samtidige tråder, kan CPU-bruken være enda høyere. Å velge riktig GC for applikasjonen din vil ha størst innvirkning på CPU-bruken, men det finnes også andre måter å optimalisere for bedre ytelse på dette området.

Vi kan forstå fra disse ytelsesbekymringene rundt søppelinnsamling at uansett hvor avanserte GC-er blir (og de begynner å bli ganske avanserte), forblir deres akilles-hæl den samme. Redundante og uforutsigbare objektallokeringer. For å forbedre applikasjonsytelsen er det ikke nok å velge riktig GC. Vi må vite hvordan prosessen fungerer, og vi må optimalisere koden vår slik at GC-ene våre ikke trekker for store ressurser eller forårsaker overdrevne pauser i applikasjonen vår.

Generasjon GC

Før vi dykker inn i de forskjellige Java GC-ene og deres ytelsespåvirkning, er det viktig å forstå det grunnleggende om generasjons søppelinnsamling. Det grunnleggende konseptet med generasjons-GC er basert på ideen om at jo lenger en referanse eksisterer til et objekt i haugen, jo mindre sannsynlig er det at det blir merket for sletting. Ved å merke objekter med en figurativ "alder", kan de deles i forskjellige lagringsplasser for å merkes av GC sjeldnere.

Når en gjenstand er allokert til haugen, plasseres den i det som kalles Eden-rommet. Det er der objektene starter, og i de fleste tilfeller er det merket for sletting. Gjenstander som overlever det stadiet "feirer en bursdag" og blir kopiert til Survivor-rommet. Denne prosessen er vist nedenfor:

Eden- og Survivor-rommene utgjør det som kalles den unge generasjonen. Det er her hoveddelen av handlingen skjer. Når (hvis) et objekt i den unge generasjonen når en viss alder, blir det forfremmet til det faste (også kalt gammelt) området. Fordelen med å dele objektminner basert på alder er at GC kan operere på forskjellige nivåer.

A Minor GC er en samling som kun fokuserer på den unge generasjonen, og ignorerer den faste plassen totalt. Generelt er flertallet av objektene i den unge generasjonen merket for sletting, og en major eller full GC (inkludert den gamle generasjonen) er ikke nødvendig for å frigjøre minne på haugen. Selvfølgelig vil en Major eller Full GC utløses når det er nødvendig.

Et raskt triks for å optimalisere GC-drift basert på dette, er å justere størrelsene på haugområdene for å passe best til applikasjonenes behov.

Samlertyper

Det er mange tilgjengelige GC-er å velge mellom, og selv om G1 ble standard GC i Java 9, var det opprinnelig ment å erstatte CMS-samleren som er Low Pause, så applikasjoner som kjører med Throughput-samlere kan være bedre egnet til å bli med sin nåværende samler. Det er fortsatt viktig å forstå de operasjonelle forskjellene, og forskjellene i ytelsespåvirkning, for Java søppelsamlere.

Throughput Collectors

Bedre for applikasjoner som må optimaliseres for høy gjennomstrømning og kan handle høyere ventetid for å oppnå det.

Serie –

Seriesamleren er den enkleste, og den du har minst sannsynlighet for å bruke, siden den hovedsakelig er designet for enkelt-trådede miljøer (f.eks. 32-bit eller Windows) og for små hauger. Denne samleren kan vertikalt skalere minnebruk i JVM, men krever flere Major/Full GCer for å frigjøre ubrukte heapressurser. Dette forårsaker hyppige Stop the World-pauser, noe som diskvalifiserer den for alle hensikter fra å bli brukt i brukervennlige miljøer.

Parallell –

Som navnet beskriver, bruker denne GC flere tråder som kjører parallelt for å skanne gjennom og komprimere haugen. Selv om Parallel GC bruker flere tråder for søppelinnsamling, stopper den fortsatt alle applikasjonstråder mens den kjører. Parallel-samleren er best egnet for apper som må optimaliseres for best gjennomstrømning og som tåler høyere ventetid i bytte.

Lav pause-samlere

De fleste brukervendte applikasjoner vil kreve en lav pause GC, slik at brukeropplevelsen ikke påvirkes av lange eller hyppige pauser. Disse GC-ene handler om å optimalisere for respons (tid/hendelse) og sterk kortsiktig ytelse.

Concurrent Mark Sweep (CMS) –

I likhet med Parallel-samleren, bruker Concurrent Mark Sweep (CMS)-samleren flere tråder for å merke og sveipe (fjerne) ikke-refererte objekter. Denne GC starter imidlertid bare Stop the World-arrangementene i to spesifikke tilfeller:

(1) ved initialisering av den første merkingen av røtter (objekter i den gamle generasjonen som er tilgjengelige fra trådinngangspunkter eller statiske variabler) eller referanser fra main()-metoden, og noen flere

(2) når applikasjonen har endret tilstanden til haugen mens algoritmen kjørte samtidig, og tvinger den til å gå tilbake og gjøre noen siste finpuss for å sikre at den har de riktige objektene merket

G1 –

Den første søppelsamleren (ofte kjent som G1) bruker flere bakgrunnstråder for å skanne gjennom haugen som den deler inn i regioner. Det fungerer ved å skanne de områdene som inneholder flest søppelobjekter først, og gi det navnet (Søppel først).

Denne strategien reduserer sjansen for at haugen tømmes før bakgrunnstrådene er ferdige med å skanne for ubrukte objekter, i så fall må samleren stoppe applikasjonen. En annen fordel for G1-samleren er at den komprimerer haugen mens du er på farten, noe CMS-samleren bare gjør under fulle Stop the World-samlinger.

Forbedre GC-ytelsen

Applikasjonsytelsen påvirkes direkte av frekvensen og varigheten av søppelinnsamlinger, noe som betyr at optimalisering av GC-prosessen gjøres ved å redusere disse beregningene. Det er to hovedmåter å gjøre dette på. Først ved å justere haugstørrelsene til unge og gamle generasjoner , og for det andre å redusere frekvensen av objekttildeling og forfremmelse .

Når det gjelder justering av haugstørrelser, er det ikke så enkelt som man kunne forvente. Den logiske konklusjonen vil være at å øke haugstørrelsen vil redusere GC-frekvensen mens den øker varigheten, og å redusere haugstørrelsen vil redusere GC-varigheten mens frekvensen økes.

Saken er imidlertid at varigheten av en Minor GC ikke er avhengig av størrelsen på haugen, men på antall gjenstander som overlever samlingen. Det betyr at for applikasjoner som for det meste skaper kortlivede objekter, kan øke størrelsen på den unge generasjonen faktisk redusere både GC-varigheten og Frekvens. Men hvis å øke størrelsen på den unge generasjonen vil føre til en betydelig økning i gjenstander som må kopieres i overlevende rom, vil GC-pauser ta lengre tid og føre til økt latens.

3 tips for å skrive GC-effektiv kode

Tips #1:Forutsi innsamlingskapasitet –

Alle standard Java-samlinger, så vel som de fleste tilpassede og utvidede implementeringer (som Trove og Googles Guava), bruker underliggende matriser (enten primitive eller objektbaserte). Siden matriser er uforanderlige i størrelse når de først er tildelt, kan det å legge til elementer i en samling i mange tilfeller føre til at en gammel underliggende matrise droppes til fordel for en større nytildelt matrise.

De fleste samlingsimplementeringer prøver å optimalisere denne re-allokeringsprosessen og holde den til et amortisert minimum, selv om den forventede størrelsen på samlingen ikke er oppgitt. De beste resultatene kan imidlertid oppnås ved å gi samlingen sin forventede størrelse ved konstruksjon.

Tips #2:Behandle strømmer direkte –

Når du for eksempel behandler datastrømmer, som data lest fra filer eller data lastet ned over nettverket, er det veldig vanlig å se noe i retning av:

byte[] fileData = readFileToByteArray(new File("myfile.txt"));

Den resulterende byte-matrisen kan deretter analyseres til et XML-dokument, JSON-objekt eller Protocol Buffer-melding, for å nevne noen populære alternativer.

Når vi arbeider med store filer eller av uforutsigbar størrelse, er dette åpenbart en dårlig idé, siden det utsetter oss for OutOfMemoryErrors i tilfelle JVM ikke kan tildele en buffer på størrelse med hele filen.

En bedre måte å nærme seg dette på er å bruke den riktige InputStream (FileInputStream i dette tilfellet) og mate den direkte inn i parseren, uten først å lese hele greia inn i en byte-array. Alle større biblioteker eksponerer API-er for å analysere strømmer direkte, for eksempel:

FileInputStream fis = new FileInputStream(fileName);
MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);

Tips #3:Bruk uforanderlige objekter –

Uforanderlighet har mange fordeler. En som sjelden får den oppmerksomheten den fortjener, er effekten på søppelinnsamlingen.

Et uforanderlig objekt er et objekt hvis felt (og spesifikt ikke-primitive felt i vårt tilfelle) ikke kan endres etter at objektet er konstruert.

Uforanderlighet innebærer at alle objekter referert til av en uforanderlig beholder er opprettet før konstruksjonen av beholderen er fullført. I GC-termer:Beholderen er minst like ung som den yngste referansen den har. Dette betyr at når du utfører søppelinnsamlingssykluser på unge generasjoner, kan GC hoppe over uforanderlige objekter som ligger i eldre generasjoner, siden den vet med sikkerhet at de ikke kan referere til noe i generasjonen som samles inn.

Færre objekter å skanne betyr færre minnesider å skanne, og færre minnesider å skanne betyr kortere GC-sykluser, noe som betyr kortere GC-pauser og bedre total gjennomstrømning.

For flere tips og detaljerte eksempler, sjekk ut dette innlegget som dekker inngående taktikker for å skrive mer minneeffektiv kode.

*** En stor takk til Amit Hurvitz fra OverOps' R&D Team for hans lidenskap og innsikt som gikk inn i dette innlegget!

Java Tag