Îmbunătățiți performanța aplicațiilor cu aceste tehnici avansate de GC

Performanța aplicațiilor este în prim-planul minții noastre, iar optimizarea Garbage Collection este un loc bun pentru a face progrese mici, dar semnificative

Collectarea automată a gunoiului (împreună cu JIT HotSpot Compiler) este una dintre cele mai avansate și mai apreciate componente ale JVM, dar mulți dezvoltatori și ingineri sunt mult mai puțin familiarizați cu Garbage Collection (GC), cu modul în care funcționează și cu impactul pe care îl are asupra performanței aplicațiilor.

În primul rând, la ce servește GC? Garbage collection este procesul de gestionare a memoriei pentru obiectele din heap. Pe măsură ce obiectele sunt alocate în heap, ele trec prin câteva faze de colectare – de obicei destul de rapid, deoarece majoritatea obiectelor din heap au o durată de viață scurtă.

Evenimentele de colectare a gunoiului conțin trei faze – marcare, ștergere și copiere/compactare. În prima fază, GC parcurge heap-ul și marchează totul fie ca obiecte vii (referențiate), fie ca obiecte nereferențiate, fie ca spațiu de memorie disponibil. Obiectele nereferite sunt apoi șterse, iar obiectele rămase sunt compactate. În cadrul colecțiilor de gunoi generaționale, obiectele „îmbătrânesc” și sunt promovate prin 3 spații în viața lor – Eden, Spațiul supraviețuitorilor și Spațiul titularizat (vechi). Această deplasare are loc, de asemenea, ca parte a fazei de compactare.

Dar destul despre asta, să trecem la partea distractivă!

Psst! Căutați o soluție pentru a îmbunătăți performanța aplicațiilor?OverOps ajută companiile să identifice nu numai când și unde au loc încetinirile, ci și de ce și cum au loc acestea. Urmăriți o demonstrație live pentru a vedea cum funcționează.

Cunoașterea Garbage Collection (GC) în Java

Unul dintre lucrurile grozave despre GC automatizat este că dezvoltatorii nu trebuie să înțeleagă cu adevărat cum funcționează. Din păcate, acest lucru înseamnă că mulți dezvoltatori NU înțeleg cum funcționează. Înțelegerea garbage collection și a numeroaselor GC-uri disponibile, este oarecum ca și cum ai cunoaște comenzile Linux CLI. Din punct de vedere tehnic, nu trebuie să le folosiți, dar cunoașterea lor și faptul că vă simțiți confortabil să le folosiți poate avea un impact semnificativ asupra productivității dumneavoastră.

La fel ca în cazul comenzilor CLI, există comenzile de bază absolute. comanda ls pentru a vizualiza o listă de dosare dintr-un dosar părinte, mv pentru a muta un fișier dintr-o locație în alta, etc. În GC, aceste tipuri de comenzi ar fi echivalente cu a ști că există mai mult de un GC din care se poate alege și că GC poate cauza probleme de performanță. Desigur, există mult mai multe de învățat (despre utilizarea CLI-ului Linux ȘI despre garbage collection).

Scopul învățării despre procesul de garbage collection din Java nu este doar pentru a începe gratuit (și plictisitor) o conversație, scopul este de a învăța cum să implementați și să mențineți în mod eficient GC-ul potrivit cu performanțe optime pentru mediul dumneavoastră specific. Cunoașterea faptului că garbage collection afectează performanța aplicației este de bază și există multe tehnici avansate pentru îmbunătățirea performanței GC și reducerea impactului acesteia asupra fiabilității aplicației.

Preocupări legate de performanța GC

Fugile de memorie –

Cunoscând structura heap și modul în care se realizează garbage collection, știm că utilizarea memoriei crește treptat până când are loc un eveniment de garbage collection și utilizarea scade din nou. Utilizarea heap-ului pentru obiectele referite rămâne de obicei constantă, astfel încât scăderea ar trebui să fie mai mult sau mai puțin la același volum.

Cu o scurgere de memorie, fiecare eveniment GC șterge o porțiune mai mică de obiecte heap (deși multe dintre obiectele rămase în urmă nu sunt utilizate), astfel încât utilizarea heap-ului va continua să crească până când memoria heap este plină și se va lansa o excepție OutOfMemoryError. Cauza acestei situații este faptul că GC marchează pentru ștergere numai obiectele nereferite. Astfel, chiar dacă un obiect referit nu mai este utilizat, acesta nu va fi șters din heap. Există câteva trucuri de codare utile pentru a preveni acest lucru, pe care le vom aborda puțin mai târziu.

Evenimente continue „Stop the World” –

În unele scenarii, colectarea gunoiului poate fi numită un eveniment „Stop the World” deoarece, atunci când are loc, toate firele de execuție din JVM (și, prin urmare, aplicația care rulează pe aceasta) sunt oprite pentru a permite GC să se execute. În aplicațiile sănătoase, timpul de execuție a GC este relativ scăzut și nu are un efect mare asupra performanței aplicației.

În situații suboptime, totuși, evenimentele Stop the World pot avea un impact mare asupra performanței și fiabilității unei aplicații. Dacă un eveniment GC necesită o pauză Stop the World și are nevoie de 2 secunde pentru a se executa, utilizatorul final al aplicației respective va avea o întârziere de 2 secunde, deoarece firele care rulează aplicația sunt oprite pentru a permite GC.

Când apar scurgeri de memorie, evenimentele Stop the World continue sunt, de asemenea, problematice. Deoarece la fiecare execuție a GC se curăță mai puțin spațiu de memorie heap, este nevoie de mai puțin timp pentru ca memoria rămasă să se umple. Atunci când memoria este plină, JVM declanșează un alt eveniment GC. În cele din urmă, JVM va rula evenimente Stop the World repetate, cauzând probleme majore de performanță.

CPU Usage –

Și totul se reduce la utilizarea CPU. Un simptom major al evenimentelor continue GC / Stop the World este un vârf în utilizarea CPU. GC este o operațiune grea din punct de vedere computațional și, prin urmare, poate lua mai mult decât partea sa echitabilă de putere CPU. În cazul GC-urilor care rulează fire concurente, utilizarea CPU poate fi și mai mare. Alegerea unui GC potrivit pentru aplicația dvs. va avea cel mai mare impact asupra utilizării CPU, dar există și alte modalități de a optimiza pentru o performanță mai bună în acest domeniu.

Din aceste preocupări legate de performanță în jurul garbage collection putem înțelege că, oricât de avansate ar deveni GC-urile (și devin destul de avansate), călcâiul lor lui Ahile rămâne același. Alocări redundante și imprevizibile de obiecte. Pentru a îmbunătăți performanța aplicației, nu este suficient să alegeți GC-ul potrivit. Trebuie să știm cum funcționează procesul și trebuie să ne optimizăm codul astfel încât GC-urile noastre să nu tragă resurse excesive sau să provoace pauze excesive în aplicația noastră.

Generational GC

Înainte de a ne scufunda în diferitele GC-uri Java și în impactul lor asupra performanței, este important să înțelegem elementele de bază ale garbage collection-ului generațional. Conceptul de bază al GC generațional se bazează pe ideea că, cu cât există mai mult timp o referință la un obiect în heap, cu atât este mai puțin probabil ca acesta să fie marcat pentru ștergere. Prin marcarea obiectelor cu o „vârstă” figurativă, acestea ar putea fi separate în diferite spații de stocare pentru a fi marcate de GC mai rar.

Când un obiect este alocat în heap, acesta este plasat în ceea ce se numește spațiul Eden. Acolo încep obiectele și, în cele mai multe cazuri, acolo sunt marcate pentru ștergere. Obiectele care supraviețuiesc acestei etape „sărbătoresc o zi de naștere” și sunt copiate în spațiul Supraviețuitorului. Acest proces este prezentat mai jos:

Spațiile Eden și Supraviețuitor alcătuiesc ceea ce se numește Generația tânără. Aici are loc cea mai mare parte a acțiunii. Când (Dacă) un obiect din Generația tânără atinge o anumită vârstă, este promovat în spațiul Tenured (numit și Old). Avantajul împărțirii memoriilor obiectelor în funcție de vârstă este că GC poate funcționa la niveluri diferite.

O GC Minor este o colecție care se concentrează doar pe Generația tânără, ignorând efectiv cu totul spațiul Tenured. În general, majoritatea obiectelor din Generația tânără sunt marcate pentru ștergere și un GC Major sau Complet (inclusiv Generația Veche) nu este necesar pentru a elibera memorie pe heap. Bineînțeles, un GC Major sau Complet va fi declanșat atunci când este necesar.

Un truc rapid pentru optimizarea funcționării GC pe baza acestui lucru este de a ajusta dimensiunile zonelor heap pentru a se potrivi cel mai bine nevoilor aplicațiilor dumneavoastră.

Tipuri de colectoare

Există multe GC-uri disponibile din care puteți alege și, deși G1 a devenit GC implicit în Java 9, acesta a fost inițial destinat să înlocuiască colectorul CMS, care este Low Pause, astfel încât aplicațiile care rulează cu colectoare Throughput pot fi mai potrivite să rămână cu colectorul lor actual. Înțelegerea diferențelor operaționale și a diferențelor în ceea ce privește impactul asupra performanței pentru colectoarele de gunoi Java este încă importantă.

Colectoare Throughput

Mai bine pentru aplicațiile care trebuie să fie optimizate pentru un randament ridicat și care pot negocia o latență mai mare pentru a o obține.

Serial –

Colectorul serial este cel mai simplu și cel pe care este cel mai puțin probabil să îl folosiți, deoarece este conceput în principal pentru medii cu un singur fir (de exemplu, 32 de biți sau Windows) și pentru heaps mici. Acest colector poate scala pe verticală utilizarea memoriei în JVM, dar necesită mai multe GC majore/ complete pentru a elibera resursele heap neutilizate. Acest lucru cauzează pauze frecvente de tip Stop the World, ceea ce îl descalifică, din toate punctele de vedere, pentru a fi utilizat în mediile orientate către utilizator.

Parallel –

După cum îl descrie numele său, acest GC utilizează mai multe fire care rulează în paralel pentru a scana și compacta heap-ul. Deși GC paralel utilizează mai multe fire de execuție pentru colectarea gunoiului, acesta pune în continuare în pauză toate firele de execuție ale aplicației în timpul rulării. Colectorul Parallel este cel mai potrivit pentru aplicațiile care trebuie optimizate pentru cel mai bun randament și care pot tolera o latență mai mare în schimb.

Colectori cu pauză redusă

Majoritatea aplicațiilor care se adresează utilizatorilor vor necesita un GC cu pauză redusă, astfel încât experiența utilizatorului să nu fie afectată de pauzele lungi sau frecvente. Aceste GC-uri au ca scop optimizarea capacității de reacție (timp/eveniment) și o performanță puternică pe termen scurt.

Concurrent Mark Sweep (CMS) –

Similar cu colectorul Parallel, colectorul Concurrent Mark Sweep (CMS) utilizează mai multe fire de execuție pentru a marca și a mătura (elimina) obiectele nereferite. Cu toate acestea, acest GC inițiază evenimente de tip „Stop the World” numai în două cazuri specifice:

(1) atunci când se inițializează marcarea inițială a rădăcinilor (obiecte din vechea generație la care se poate ajunge din punctele de intrare ale firelor de execuție sau din variabilele statice) sau orice referințe din metoda main(), și alte câteva

(2) atunci când aplicația a schimbat starea heap-ului în timp ce algoritmul era în execuție concurentă, forțându-l să se întoarcă și să facă unele retușuri finale pentru a se asigura că are obiectele corecte marcate

G1 –

Colectorul Garbage first (cunoscut în mod obișnuit sub numele de G1) utilizează mai multe fire de execuție în fundal pentru a scana heap-ul pe care îl împarte în regiuni. Acesta funcționează scanând mai întâi acele regiuni care conțin cele mai multe obiecte de gunoi, ceea ce îi dă numele (Garbage first).

Această strategie reduce șansele ca heap-ul să fie epuizat înainte ca firele de fundal să fi terminat de scanat obiectele nefolosite, caz în care colectorul ar trebui să oprească aplicația. Un alt avantaj pentru colectorul G1 este că compactează heap-ul din mers, lucru pe care colectorul CMS îl face doar în timpul colecțiilor complete Stop the World.

Îmbunătățirea performanței GC

Performanța aplicației este direct influențată de frecvența și durata colecțiilor de gunoi, ceea ce înseamnă că optimizarea procesului GC se face prin reducerea acestor parametri. Există două modalități principale de a face acest lucru. În primul rând, prin ajustarea dimensiunilor heap-ului generațiilor tinere și vechi și, în al doilea rând, prin reducerea ratei de alocare și promovare a obiectelor.

În ceea ce privește ajustarea dimensiunilor heap-ului, nu este atât de simplu pe cât ne-am putea aștepta. Concluzia logică ar fi că mărirea mărimii heap-ului ar scădea frecvența GC în timp ce ar crește durata, iar scăderea mărimii heap-ului ar scădea durata GC în timp ce ar crește frecvența.

De fapt, însă, durata unui GC minor depinde nu de mărimea heap-ului, ci de numărul de obiecte care supraviețuiesc colecției. Aceasta înseamnă că, pentru aplicațiile care creează în principal obiecte cu durată de viață scurtă, creșterea dimensiunii generației tinere poate reduce de fapt atât durata cât și frecvența GC. Cu toate acestea, în cazul în care creșterea dimensiunii generației tinere va duce la o creștere semnificativă a obiectelor care trebuie copiate în spațiile de supraviețuire, pauzele GC vor dura mai mult, ceea ce va duce la creșterea latenței.

3 sfaturi pentru scrierea codului GC-eficient

Tip #1: Prevădeți capacitățile colecțiilor –

Toate colecțiile Java standard, precum și majoritatea implementărilor personalizate și extinse (cum ar fi Trove și Guava de la Google), utilizează array-uri subiacente (fie bazate pe primitive, fie pe obiecte). Deoarece array-urile sunt imuabile ca mărime odată ce sunt alocate, adăugarea de elemente la o colecție poate, în multe cazuri, să determine ca un array subiacent vechi să fie eliminat în favoarea unui array nou alocat mai mare.

Majoritatea implementărilor de colecții încearcă să optimizeze acest proces de realocare și să îl mențină la un minim amortizat, chiar dacă nu este furnizată dimensiunea așteptată a colecției. Cu toate acestea, cele mai bune rezultate pot fi obținute prin furnizarea colecției cu dimensiunea sa așteptată la construcție.

Tip #2: Procesați fluxurile direct –

Când procesați fluxuri de date, cum ar fi datele citite din fișiere sau datele descărcate prin rețea, de exemplu, este foarte frecvent să vedeți ceva de genul:

Rețeaua de octeți rezultată ar putea fi apoi analizată într-un document XML, un obiect JSON sau un mesaj Protocol Buffer, pentru a numi câteva opțiuni populare.

Când avem de-a face cu fișiere mari sau cu fișiere de dimensiuni imprevizibile, aceasta este în mod evident o idee proastă, deoarece ne expune la OutOfMemoryErrors în cazul în care JVM nu poate aloca de fapt un buffer de dimensiunea întregului fișier.

O modalitate mai bună de a aborda acest lucru este de a utiliza InputStream-ul corespunzător (FileInputStream în acest caz) și de a-l introduce direct în parser, fără a citi mai întâi întregul lucru într-un array de octeți. Toate bibliotecile majore expun API-uri pentru a analiza fluxurile direct, de exemplu:

Tip #3: Folosiți obiecte imuabile –

Imutabilitatea are multe avantaje. Unul căruia rareori i se acordă atenția pe care o merită este efectul său asupra colectării gunoiului.

Un obiect imuabil este un obiect ale cărui câmpuri (și în mod specific câmpurile non-primitive în cazul nostru) nu pot fi modificate după ce obiectul a fost construit.

Imutabilitatea implică faptul că toate obiectele la care face referire un container imuabil au fost create înainte ca construcția containerului să se finalizeze. În termeni GC: Containerul este cel puțin la fel de tânăr ca cea mai tânără referință pe care o deține. Acest lucru înseamnă că atunci când efectuează cicluri de colectare a gunoiului pe generații tinere, GC poate sări peste obiectele imuabile care se află în generații mai vechi, deoarece știe cu siguranță că acestea nu pot face referință la nimic din generația care este colectată.

Mai puține obiecte de scanat înseamnă mai puține pagini de memorie de scanat, iar mai puține pagini de memorie de scanat înseamnă cicluri GC mai scurte, ceea ce înseamnă pauze GC mai scurte și un randament general mai bun.

Pentru mai multe sfaturi și exemple detaliate, consultați această postare care acoperă tactici aprofundate pentru scrierea unui cod mai eficient din punct de vedere al memoriei.

*** Mulțumiri uriașe lui Amit Hurvitz de la echipa R&D de la OverOps pentru pasiunea și intuiția sa care au intrat în această postare!

Lasă un comentariu