Optimizarea performanțelor JVM, Partea 3: Colectarea gunoiului

Mecanismul de colectare a gunoiului al platformei Java crește foarte mult productivitatea dezvoltatorului, dar un colector de gunoi prost implementat poate consuma excesiv resursele aplicației. În acest al treilea articol din seria de optimizare a performanței JVM, Eva Andreasson oferă începătorilor Java o prezentare generală a modelului de memorie și a mecanismului GC al platformei Java. Ea explică apoi de ce fragmentarea (și nu GC) este principalul “gotcha!” al performanței aplicațiilor Java și de ce colectarea generațională a gunoiului și compactarea sunt în prezent abordările principale (deși nu cele mai inovatoare) pentru gestionarea fragmentării heap-ului în aplicațiile Java.

Colectarea gunoiului (GC) este procesul care urmărește să elibereze memoria ocupată care nu mai este menționată de niciun obiect Java accesibil și este o parte esențială a sistemului de gestionare dinamică a memoriei al mașinii virtuale Java (JVM). Într-un ciclu tipic de colectare a gunoiului sunt păstrate toate obiectele la care încă se face referire și, prin urmare, care pot fi accesate. Spațiul ocupat de obiectele referite anterior este eliberat și recuperat pentru a permite alocarea de noi obiecte.

Pentru a înțelege colectarea gunoiului și diferitele abordări și algoritmi GC, trebuie să știți mai întâi câteva lucruri despre modelul de memorie al platformei Java.

Garbage collection și modelul de memorie al platformei Java

Când specificați opțiunea de pornire -Xmx în linia de comandă a aplicației Java (de exemplu: java -Xmx:2g MyApp) memoria este atribuită unui proces Java. Această memorie este denumită heap Java (sau doar heap). Acesta este spațiul de adrese de memorie dedicat în care vor fi alocate toate obiectele create de programul Java (sau, uneori, de JVM). Pe măsură ce programul Java continuă să ruleze și să aloce noi obiecte, heap-ul Java (adică acel spațiu de adrese) se va umple.

În cele din urmă, heap-ul Java va fi plin, ceea ce înseamnă că un fir de alocare nu reușește să găsească o secțiune consecutivă suficient de mare de memorie liberă pentru obiectul pe care dorește să-l aloce. În acel moment, JVM stabilește că trebuie să aibă loc o colectare a gunoiului și notifică colectorul de gunoi. O colectare a gunoiului poate fi declanșată și atunci când un program Java apelează System.gc(). Utilizarea System.gc() nu garantează o colectare a gunoiului. Înainte ca orice colectare de gunoi să poată începe, un mecanism GC va determina mai întâi dacă este sigur să o pornească. Este sigur să se pornească o colectare de gunoi atunci când toate firele active ale aplicației se află într-un punct sigur care să permită acest lucru, de exemplu, pur și simplu s-a explicat că nu ar fi bine să se pornească o colectare de gunoi în mijlocul unei alocări de obiecte în curs de desfășurare sau în mijlocul executării unei secvențe de instrucțiuni optimizate ale procesorului (a se vedea articolul meu anterior despre compilatoare), deoarece s-ar putea pierde contextul și astfel s-ar putea strica rezultatele finale.

Un colector de gunoi nu ar trebui niciodată să recupereze un obiect la care se face referire în mod activ; dacă s-ar face acest lucru, s-ar încălca specificația mașinii virtuale Java. De asemenea, un garbage collector nu este obligat să colecteze imediat obiectele moarte. Obiectele moarte sunt colectate în cele din urmă în timpul ciclurilor ulterioare de colectare a gunoiului. Deși există multe moduri de implementare a colectării gunoiului, aceste două ipoteze sunt valabile pentru toate varietățile. Adevărata provocare a colectării gunoiului este de a identifica tot ceea ce este viu (încă referențiat) și de a recupera orice memorie nereferențiată, dar de a face acest lucru fără a afecta aplicațiile care rulează mai mult decât este necesar. Un garbage collector are astfel două mandate:

  1. Să elibereze rapid memoria nereferențiată pentru a satisface rata de alocare a unei aplicații, astfel încât aceasta să nu rămână fără memorie.
  2. Să recupereze memoria cu un impact minim asupra performanței (de ex, latența și randamentul) unei aplicații în curs de execuție.

Două tipuri de colectare a gunoiului

În primul articol din această serie am abordat cele două abordări principale ale colectării gunoiului, care sunt numărătoarea de referințe și colectorii de urmărire. De data aceasta voi aprofunda mai mult fiecare abordare, apoi voi prezenta câțiva dintre algoritmii utilizați pentru a implementa colectoarele de urmărire în mediile de producție.

Citește seria de optimizare a performanței JVM

  • Optimizarea performanței JVM, Partea 1: Prezentare generală
  • Optimizarea performanței JVM, Partea 2: Compilatoare

Colectori de numărare a referințelor

Colectorii de numărare a referințelor țin evidența numărului de referințe care indică fiecare obiect Java. Odată ce numărătoarea pentru un obiect devine zero, memoria poate fi imediat recuperată. Acest acces imediat la memoria recuperată este avantajul major al abordării de numărare a referințelor pentru colectarea gunoiului. Există foarte puțini costuri suplimentare în ceea ce privește păstrarea memoriei fără referințe. Cu toate acestea, menținerea la zi a tuturor numărătorii de referințe poate fi destul de costisitoare.

Principala dificultate cu colectorii de numărare a referințelor este menținerea exactă a numărătorii de referințe. O altă provocare bine cunoscută este complexitatea asociată cu manipularea structurilor circulare. Dacă două obiecte se referă unul la celălalt și niciun obiect viu nu se referă la ele, memoria lor nu va fi niciodată eliberată. Ambele obiecte vor rămâne pentru totdeauna cu o numărătoare diferită de zero. Recuperarea memoriei asociate cu structurile circulare necesită o analiză majoră, ceea ce aduce costuri suplimentare costisitoare algoritmului și, prin urmare, aplicației.

Colectoare de urmărire

Colectoarele de urmărire se bazează pe ipoteza că toate obiectele vii pot fi găsite prin urmărirea iterativă a tuturor referințelor și a referințelor ulterioare dintr-un set inițial de obiecte cunoscute ca fiind vii. Setul inițial de obiecte vii (numite obiecte rădăcină sau, pe scurt, doar rădăcini) sunt localizate prin analiza registrelor, a câmpurilor globale și a cadrelor stivei în momentul în care se declanșează o colectare de gunoi. După ce a fost identificat un set inițial de obiecte vii, colectorul de urmărire urmărește referințele acestor obiecte și le pune în coadă pentru a fi marcate ca fiind vii și, ulterior, pentru ca referințele lor să fie urmărite. Marcarea tuturor obiectelor cu referințe găsite ca fiind active înseamnă că setul activ cunoscut crește în timp. Acest proces continuă până când toate obiectele cu referințe (și, prin urmare, toate obiectele active) sunt găsite și marcate. Odată ce colectorul de urmărire a găsit toate obiectele vii, acesta va recupera memoria rămasă.

Colectorii de urmărire diferă de colectorii de numărare a referințelor prin faptul că pot gestiona structuri circulare. Capcana cu majoritatea colectorilor de urmărire este faza de marcare, care presupune o așteptare înainte de a putea recupera memoria nereferențiată.

Colectoarele de urmărire sunt cel mai frecvent utilizate pentru gestionarea memoriei în limbajele dinamice; ele sunt de departe cele mai comune pentru limbajul Java și au fost dovedite comercial în mediile de producție de mulți ani. Mă voi concentra asupra colectoarelor de urmărire pentru restul acestui articol, începând cu unii dintre algoritmii care implementează această abordare a colectării gunoiului.

Algoritmii colectoarelor de urmărire

Colectarea gunoiului prin copiere și mark-and-sweep nu sunt noi, dar sunt încă cei mai comuni doi algoritmi care implementează astăzi colectarea gunoiului prin urmărire.

Colectori de copiere

Colectorii de copiere tradiționali folosesc un spațiu de plecare și un spațiu de destinație – adică două spații de adrese definite separat din heap. În momentul colectării gunoiului, obiectele vii din zona definită ca from-space sunt copiate în următorul spațiu disponibil din zona definită ca to-space. În momentul în care toate obiectele active din spațiul “from-space” sunt eliminate, întregul spațiu “from-space” poate fi recuperat. Atunci când alocarea începe din nou, aceasta pornește de la prima locație liberă din to-space.

În implementările mai vechi ale acestui algoritm, spațiul de la și spațiul de la schimbă locurile, ceea ce înseamnă că atunci când to-space este plin, colectarea gunoiului este declanșată din nou și to-space devine to-space, așa cum se arată în figura 1.

Figura 1. O secvență tradițională de colectare a gunoiului prin copiere (click pentru mărire)

Impletările mai moderne ale algoritmului de copiere permit ca spații de adrese arbitrare din heap să fie atribuite ca to-space și from-space. În aceste cazuri, ele nu trebuie neapărat să schimbe locația una cu cealaltă; mai degrabă, fiecare devine un alt spațiu de adrese în cadrul heap-ului.

Un avantaj al colectorilor de copiere este că obiectele sunt alocate împreună strâns în to-space, eliminând complet fragmentarea. Fragmentarea este o problemă obișnuită cu care se luptă alți algoritmi de colectare a gunoiului; un aspect pe care îl voi discuta mai târziu în acest articol.

Dezavantaje ale colectorilor de copiere

Colectoarele de copiere sunt de obicei colectori de tip “stop-the-world”, ceea ce înseamnă că nicio lucrare a aplicației nu poate fi executată atâta timp cât colectarea gunoiului este în ciclu. Într-o implementare de tip stop-the-world, cu cât este mai mare zona pe care trebuie să o copiați, cu atât mai mare va fi impactul asupra performanței aplicației. Acesta este un dezavantaj pentru aplicațiile care sunt sensibile la timpul de răspuns. Cu un colector de copiere trebuie, de asemenea, să luați în considerare cel mai rău scenariu, atunci când totul este viu în spațiul from-space. Întotdeauna trebuie să lăsați o marjă de manevră suficientă pentru ca obiectele vii să fie mutate, ceea ce înseamnă că spațiul de destinație trebuie să fie suficient de mare pentru a găzdui toate obiectele din spațiul de proveniență. Algoritmul de copiere este ușor ineficient din punct de vedere al memoriei din cauza acestei constrângeri.

Colectori de marcare și măturare

Majoritatea JVM-urilor comerciale implementate în mediile de producție ale întreprinderilor rulează colectori de marcare și măturare (sau de marcare), care nu au impactul asupra performanței pe care îl au colectorii de copiere. Unii dintre cei mai cunoscuți colectori de marcare sunt CMS, G1, GenPar și DeterministicGC (a se vedea Resurse).

Un colector de marcare și măturare urmărește referințele și marchează fiecare obiect găsit cu un bit “live”. De obicei, un bit setat corespunde unei adrese sau, în unele cazuri, unui set de adrese de pe heap. De exemplu, bitul “live” poate fi stocat ca un bit în antetul obiectului, un vector de biți sau o hartă de biți.

După ce totul a fost marcat “live”, va intra în funcțiune faza de “sweep”. În cazul în care un colector are o fază de baleiaj, acesta include, în principiu, un mecanism de parcurgere din nou a heap-ului (nu doar a setului live, ci a întregii lungimi a heap-ului) pentru a localiza toate bucățile nemarcate din spațiile de adrese de memorie consecutive. Memoria nemarcată este liberă și poate fi recuperată. Colectorul leagă apoi aceste fragmente nemarcate în liste libere organizate. În cadrul unui colector de gunoi pot exista diverse liste libere – de obicei organizate în funcție de mărimea bucăților. Unele JVM-uri (cum ar fi JRockit Real Time) implementează colectori cu euristică care organizează în mod dinamic listele de mărime pe baza datelor de profilare a aplicației și a statisticilor privind dimensiunea obiectelor.

Când faza de măturare este finalizată, alocarea va începe din nou. Noile zone de alocare sunt alocate din listele libere, iar bucățile de memorie ar putea fi potrivite cu dimensiunile obiectelor, cu mediile dimensiunilor obiectelor pe ID de fir de execuție sau cu dimensiunile TLAB reglate de aplicație. Ajustarea spațiului liber mai aproape de dimensiunea a ceea ce aplicația dvs. încearcă să aloce optimizează memoria și ar putea ajuta la reducerea fragmentării.

Mai multe despre dimensiunile TLAB

Partiționarea TLAB și TLA (Thread Local Allocation Buffer sau Thread Local Area) sunt discutate în Optimizarea performanței JVM, Partea 1.

Downsides of mark-and-sweep collectors

Faza de marcare este dependentă de cantitatea de date live de pe heap, în timp ce faza de baleiaj este dependentă de dimensiunea heap-ului. Deoarece trebuie să așteptați până când atât faza de marcare, cât și cea de măturare sunt finalizate pentru a recupera memoria, acest algoritm cauzează probleme de timp de pauză pentru heap-uri mai mari și seturi de date vii mai mari.

Un mod în care puteți ajuta aplicațiile care consumă foarte multă memorie este de a utiliza opțiuni de reglare GC care să se adapteze la diferite scenarii și nevoi ale aplicațiilor. Reglarea poate, în multe cazuri, să ajute cel puțin să amâne ca oricare dintre aceste faze să devină un risc pentru aplicația dvs. sau pentru acordurile de nivel de servicii (SLA). (Un SLA specifică faptul că aplicația va respecta anumiți timpi de răspuns ai aplicației – adică latența). Cu toate acestea, tuningul pentru fiecare schimbare de sarcină și modificare a aplicației este o sarcină repetitivă, deoarece tuningul este valabil doar pentru o anumită sarcină de lucru și rată de alocare.

Implementări ale mark-and-sweep

Există cel puțin două abordări disponibile în comerț și dovedite pentru implementarea colectării mark-and-sweep. Una este abordarea paralelă și cealaltă este abordarea concurentă (sau în mare parte concurentă).

Colectoare paralele

Colectarea paralelă înseamnă că resursele alocate procesului sunt utilizate în paralel în scopul colectării gunoiului. Majoritatea colectorilor paraleli implementați comercial sunt colectori monolitici de tip “stop-the-world” — toate firele aplicației sunt oprite până când întregul ciclu de colectare a gunoiului este finalizat. Oprirea tuturor firelor de execuție permite ca toate resursele să fie utilizate în paralel în mod eficient pentru a finaliza colectarea gunoiului prin fazele de marcare și de măturare. Acest lucru duce la un nivel foarte ridicat de eficiență, ceea ce duce, de obicei, la scoruri ridicate la testele de referință privind randamentul, cum ar fi SPECjbb. Dacă randamentul este esențial pentru aplicația dumneavoastră, abordarea paralelă este o alegere excelentă.

.

Lasă un răspuns

Adresa ta de email nu va fi publicată.