Duboko razumevanje JVM mehanizma sakupljanja smeća
Setim se da je ranije postojala jedna animirana GIF slika koja je ismevala JVM mehanizam sakupljanja smeća; suština je bila u tome da je JVM mehanizam sakupljanja smeća veoma industrijalizovan, ali da, izgleda, radi uzaludan posao i da sakupljanje smeća nije temeljno (😂).

Iako C/C++ zahteva ručno oslobađanje memorije, programeri su čvrsto uvereni da će oni to obaviti temeljno. Ovoga puta ćemo stoga od početka do kraja detaljno porazgovarati o JVM mehanizmu sakupljanja smeća i videti kako zaista stoji.
Pojam sakupljanja smeća
Sakupljanje smeća (Garbage Collection, GC) je, kako mu i ime kaže, oslobađanje prostora koji zauzima smeće, čime se sprečava prepunjavanje memorije. Omogućava efikasno korišćenje raspoložive memorije, čisteći i reciklirajući objekte u memorijskom heap-u koji su mrtvi ili dugo nisu korišćeni.
Pre nego što se Java pojavila, svi su uporno pisali programe u C-u ili C++-u, pri čemu je postojala jedna velika protivrečnost: jezici poput C++-a zahtevaju neprestano otvaranje prostora pri kreiranju objekata, a zatim neprestano oslobađanje prostora kada im više ne treba — treba pisati i konstruktore i destruktore.
Konstruktor je sličan konstrukcijskoj metodi u Javi i služi za kreiranje objekata; destruktor je donekle sličan metodi finalize u Javi — može se koristiti za izvršavanje operacija čišćenja pre nego što objekat sakupi sakupljač smeća, mada se to ne preporučuje jer vreme izvršenja finalize nije određeno.
Zato je neko predložio — da li bi se mogao napisati jedan program koji bi ostvarivao ovu funkciju, tako da se pri svakom kreiranju objekta i oslobađanju memorijskog prostora taj kod ponovo koristi?
Pametnih ljudi ima u izobilju — još 1960. godine, zasnovan na MIT-ovom Lisp-u, prvi put je predložen koncept sakupljanja smeća, namenjen obradi neprestanih destrukcijskih operacija u jezicima poput C-a; Java je taj mehanizam sakupljanja smeća razvio do punog izražaja.
Lisp je funkcionalni programski jezik; sa zvaničnog sajta sam uzeo sliku da steknete utisak.

Algoritmi za utvrđivanje smeća
Pošto JVM vrši sakupljanje smeća, mora se razjasniti šta je smeće, a šta nije. Postoji nekoliko algoritama za utvrđivanje da li je neki objekat smeće; ovo je i pitanje koje se često pojavljuje na intervjuima, pa svakako treba da ga savladate.
- Algoritam brojanja referenci
- Algoritam analize dostižnosti
Algoritam brojanja referenci
Algoritam brojanja referenci (Reachability Counting) radi tako što u zaglavlju objekta rezerviše prostor za čuvanje broja koliko je taj objekat referenciran (Reference Count).
Ako taj objekat referencira neki drugi objekat, njegov brojač referenci se povećava za 1; ako se referenca na taj objekat ukloni, brojač referenci se smanjuje za 1; kada brojač referenci objekta postane 0, taj objekat će biti recikliran.
String s = new String("Chenmo Wang Er");Kreirajmo jednu nisku; sada "Chenmo Wang Er" ima jednu referencu, a to je s. Reference Count je sada 1.

Zatim postavimo s na null.
s = null;Sada broj referenci "Chenmo Wang Er" iznosi 0; u algoritmu brojanja referenci to znači da ovaj sadržaj treba reciklirati.

Algoritam brojanja referenci raspoređuje sakupljanje smeća tokom celog izvršavanja aplikacije, umesto da ga koncentriše u trenutku sakupljanja. Zato sakupljanje smeća zasnovano na brojanju referenci ne spada, u strogom smislu, u mehanizam sakupljanja smeća tipa "Stop-The-World" (o tome ćemo detaljnije govoriti kasnije).
Algoritam brojanja referenci naizgled deluje savršeno, ali u stvari ima jedan veliki problem — ne može da reši pitanje kružnih zavisnosti. Pogledajmo sledeći kod.
public class ReferenceCountingGC {
public Object instance; // svojstvo objekta, služi za čuvanje reference na drugi ReferenceCountingGC objekat
public ReferenceCountingGC(String name) {
// konstrukcijska metoda
}
public static void testGC() {
// kreiranje dva ReferenceCountingGC objekta
ReferenceCountingGC a = new ReferenceCountingGC("Chenmo Wang Er");
ReferenceCountingGC b = new ReferenceCountingGC("Chenmo Wang San");
// učiniti da se a i b međusobno referenciraju
a.instance = b;
b.instance = a;
// postaviti a i b na null
a = null;
b = null;
// ova pozicija je okidač za sakupljanje smeća
}
}U kodu su kreirana dva ReferenceCountingGC objekta, a i b.

Zatim ih učinimo da se međusobno referenciraju. Nakon toga postavimo reference na ta dva objekta na null; u teoriji će oni biti reciklirani od strane sakupljača smeća. Ali pošto međusobno referenciraju jedan drugog, njihov brojač referenci nikada neće biti 0, te ih algoritam brojanja referenci nikada neće moći da prijavi GC sakupljaču na recikliranje.

Algoritam analize dostižnosti
Osnovna ideja algoritma analize dostižnosti (Reachability Analysis) jeste da se pođe od GC Roots kao početnih tačaka, zatim vrši pretraga nadole; putanja kojom pretraga prolazi naziva se Reference Chain (lanac referenci). Kada između nekog objekta i GC Roots ne postoji ni jedna povezana referenca, odnosno kada je iz GC Roots do tog čvora objekta nemoguće doći, to dokazuje da taj objekat podleže sakupljanju smeća.

Algoritmom dostižnosti uspešno je rešen problem koji algoritam brojanja referenci nije mogao — "kružna zavisnost": samo ako ne možete da uspostavite direktnu ili indirektnu vezu sa GC Root, sistem će vas proglasiti objektom podobnim za recikliranje.
- Preporučeno štivo: Šta su GC Roots?
- Preporučeno štivo: Takozvani "GC roots" velikana R
Takozvani GC Roots jesu skup referenci koje moraju biti aktivne — to nisu objekti, već polazne tačke prilikom izvršavanja programa i izvor svakog lanca referenci. U Javi, GC Roots obuhvataju sledeće:
- Reference u steku virtuelne mašine (argumenti metoda, lokalne promenljive itd.)
- JNI reference u steku nativnih metoda
- Statitčke promenljive klasa
- Konstante u bazenu konstanti u toku rada (tip String ili Class)
Možete se setiti JVM oblasti podataka u toku rada o kojoj smo ranije govorili; povežite to i biće vam znatno lakše da razumete.

1. Reference u steku virtuelne mašine (argumenti metoda, lokalne promenljive itd.)
Pogledajmo sledeći kod:
public class StackReference {
public void greet() {
Object localVar = new Object(); // localVar je lokalna promenljiva koja se nalazi u steku virtuelne mašine
System.out.println(localVar.toString());
}
public static void main(String[] args) {
new StackReference().greet();
}
}U metodi greet, localVar je lokalna promenljiva koja se nalazi u steku virtuelne mašine i može se smatrati GC Roots.
Tokom izvršavanja metode greet, objekat koji referencira localVar je aktivan, jer je dostižan iz GC Roots.
Kada se izvršavanje metode greet završi, opseg localVar ističe; Object objekat na koji ukazuje localVar više ne referencira nijedan GC Roots (pod pretpostavkom da na njega ne ukazuju druge reference), pa će biti podoban da bude sakupljen kao smeće 😁.
2. JNI reference u steku nativnih metoda
Java preko JNI-ja (Java Native Interface) pruža mehanizam koji Java kodu dozvoljava pozivanje nativnog koda (najčešće koda pisanog u C-u ili C++-u).
Kada se poziva Java metoda, virtuelna mašina kreira okvir steka i gura ga na stek virtuelne mašine; kada poziva nativnu metodu, virtuelna mašina preko dinamičkog linkovanja direktno poziva zadatu nativnu metodu.

JNI reference su reference kreirane u Java nativnom interfejsu (JNI) kodu; te reference mogu ukazivati na objekte u Java heap-u.
// pretpostavljena JNI metoda
public native void nativeMethod();
// pretpostavljena nativna metoda implementirana u C/C++-u
/*
* Class: NativeExample
* Method: nativeMethod
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_NativeExample_nativeMethod(JNIEnv *env, jobject thisObj) {
jobject localRef = (*env)->NewObject(env, ...); // kreiranje JNI reference u steku nativnih metoda
// Java objekat na koji ukazuje localRef aktivan je tokom izvršavanja nativne metode
}U nativnom (C/C++) kodu, localRef je JNI referenca na Java objekat; ona Java objekat održava aktivnim tokom izvršavanja nativne metode i može se smatrati GC Roots.
Kada se JNI metoda završi, osim ako ta referenca nije globalna (Global Reference), objekat na koji ukazuje biće sakupljen kao smeće (pod pretpostavkom da ga nigde drugde više ne referencira).
3. Statičke promenljive klasa
Pogledajmo sledeći kod:
public class StaticFieldReference {
private static Object staticVar = new Object(); // statitčka promenljiva klase
public static void main(String[] args) {
System.out.println(staticVar.toString());
}
}U klasi StaticFieldReference, staticVar referencira jedan Object objekat; ova referenca se čuva u metaprostoru i može se smatrati GC Roots.
Sve dok klasa StaticFieldReference nije istovarena, objekat koji referencira staticVar neće biti sakupljen kao smeće. Ako klasa StaticFieldReference bude istovarena (to se obično dešava kada njen class loader bude sakupljen kao smeće), onda će i objekat koji referencira staticVar biti podoban za sakupljanje smeća (ako na njega ne ukazuju druge reference).
4. Konstante u bazenu konstanti u toku rada
Pogledajmo ovaj kod:
public class ConstantPoolReference {
public static final String CONSTANT_STRING = "Hello, World"; // konstanta, nalazi se u bazenu konstanti u toku rada
public static final Class<?> CONSTANT_CLASS = Object.class; // konstanta tipa Class
public static void main(String[] args) {
System.out.println(CONSTANT_STRING);
System.out.println(CONSTANT_CLASS.getName());
}
}U klasi ConstantPoolReference, CONSTANT_STRING i CONSTANT_CLASS se kao konstante čuvaju u bazenu konstanti u toku rada i mogu se koristiti kao GC Roots.
Objekti koje referenciraju te konstante (niska "Hello, World" i Class objekat Object.class) nalaze se u bazenu konstanti; sve dok klasa ConstantPoolReference, koja sadrži te konstante, nije istovarena, ovi objekti neće biti sakupljeni kao smeće.
Stop The World
"Stop The World" je važan koncept u Java sakupljanju smeća. Tokom sakupljanja smeća, JVM pauzira sve korisničke niti; ta pauza se naziva događaj "Stop The World".
Glavni razlog za to jeste sprečavanje da korisničke niti, tokom sakupljanja smeća, menjaju objekte u heap-u, zbog čega bi sakupljač smeća bio onemogućen da ih tačno sakupi.
Treba napomenuti da "Stop The World" događaj utiče na performanse Java aplikacije. Ako je vreme pauze predugo, vreme odziva aplikacije se produžava; za aplikacije sa visokim zahtevima po pitanju realnog vremena, kao što su transakcioni sistemi, serveri za igre i sl., to je neprihvatljivo.
Zato prilikom izbora i podešavanja sakupljača smeća treba razmotriti njegovo vreme pauze. Neke Java sakupljače smeća, poput G1 i ZGC, nastoje maksimalno da smanje vreme "Stop The World"; kroz konkurentno sakupljanje smeća poboljšavaju performanse odziva aplikacije.
Ukratko, "Stop The World" jeste izazov sa kojim se u Java sakupljanju smeća moramo suočiti; cilj je pronalaženje ravnoteže između efikasnog korišćenja memorije i performansi odziva aplikacije.
Algoritmi sakupljanja smeća
Nakon što se utvrdi koje smeće može biti reciklirano, zadatak sakupljača smeća jeste da izvrši sakupljanje smeća, ali tu se nameće pitanje: kako sakupljanje smeća obaviti efikasno. Pošto JVM specifikacija ne daje eksplicitne smernice za implementaciju sakupljača smeća, virtuelne mašine različitih proizvođača mogu koristiti različite pristupe. Ovde ćemo razmotriti nekoliko uobičajenih algoritama sakupljanja smeća.
Algoritam označavanje-brisanje
Algoritam označavanje-brisanje (Mark-Sweep) jeste najosnovniji algoritam sakupljanja smeća; sastoji se iz dva dela: prvo se objekti u memorijskoj oblasti označe, te se izdvoje oni podobni za recikliranje (pomoću gore pomenute metode analize dostižnosti), a zatim se to smeće izdvoji i očisti.

Kao na gornjoj slici, očišćeno smeće postaje slobodan prostor koji se može koristiti, čekajući da bude ponovo iskorišćen. Logika je jasna i lako se sprovodi, ali postoji jedan veliki problem — fragmentacija memorije. Previše fragmenata može dovesti do toga da tokom rada programa, prilikom dodele većih objekata, zbog nemogućnosti pronalaska dovoljno neprekidne memorije, bude prerano pokrenut novi krug sakupljanja smeća.
Algoritam kopiranja
Algoritam kopiranja (Copying) se razvio iz algoritma označavanje-brisanje, s ciljem rešavanja problema fragmentacije memorije tog algoritma. Raspoloživu memoriju po kapacitetu deli na dva jednaka dela; svaki put se koristi samo jedan deo.
Kada se memorija u tom delu potroši, preživele objekte treba kopirati na onaj drugi deo, a zatim se već iskorišćen memorijski prostor odjednom očisti. Na taj način se obezbeđuje neprekidnost memorije — logika je jasna, a izvršavanje efikasno.

Ali algoritam kopiranja ima i jedan očigledan problem: zar to znači da moj veliki četvorosoban stan od 190 kvadrata mogu koristiti samo kao dvosoban od 90 kvadrata? Cena je zaista previsoka.
Algoritam označavanje-sređivanje
Algoritam označavanje-sređivanje (Mark-Compact): proces označavanja je i dalje isti kao kod algoritma označavanje-brisanje, ali sledeći korak nije direktno čišćenje objekata podobnih za recikliranje, već se svi preživeli objekti pomeraju ka jednom kraju, a zatim se očisti memorijska oblast izvan te granice.

Algoritam označavanje-sređivanje s jedne strane nadograđuje algoritam označavanje-brisanje, rešavajući problem fragmentacije memorije, a s druge strane izbegava nedostatak algoritma kopiranja koji može iskoristiti samo polovinu memorijske oblasti. Deluje savršeno, ali promene memorije su učestalije — potrebno je srediti adrese referenci svih preživelih objekata, pa je po pitanju efikasnosti znatno lošiji od algoritma kopiranja.
Generacioni algoritam sakupljanja
Generacioni algoritam sakupljanja (Generational Collection) strogo govoreći nije jedna ideja ili teorija, već kombinacija koja spaja gore navedene tri osnovne algoritamske ideje i primenjuje različite algoritme u različitim situacijama.
Na osnovu razlika u životnom ciklusu objekata, memorija se deli u nekoliko oblasti; najčešće se Java heap deli na mladu i staru generaciju, čime se za svaku generaciju može primeniti najpogodniji algoritam sakupljanja u skladu sa njenim karakteristikama.

U mladoj generaciji, pri svakom sakupljanju smeća uočava se da velika većina objekata strada, a samo mali broj preživi — zato se bira algoritam kopiranja, pri čemu je dovoljno platiti mali trošak kopiranja preživelih objekata da bi se sakupljanje završilo.
U staroj generaciji, zbog visoke stope preživljavanja objekata i odsustva dodatnog prostora za garantovanje dodele, moraju se koristiti algoritam označavanje-brisanje ili algoritam označavanje-sređivanje radi recikliranja.
Mlada i stara generacija
Heap je najveća memorijska oblast u JVM-u i glavna oblast kojom upravlja sakupljač smeća.

Heap se uglavnom deli na dve oblasti: mladu i staru generaciju; mlada generacija se dalje deli na Eden oblast i Survivor oblast, pri čemu se Survivor oblast još deli na From i To.
Eden oblast
Prema ranijim istraživanjima kompanije IBM, blizu 98% objekata je kratkog veka; s obzirom na tu situaciju, u većini slučajeva objekti se dodeljuju u Eden oblasti mlade generacije. Kada Eden oblast više nema dovoljno prostora za dodelu, JVM pokreće jedan Minor GC; Minor GC je učestaliji od Major GC-a, a brzina recikliranja je veća.
Nakon Minor GC-a, većina objekata u Eden oblasti će biti reciklirana, a oni preživeli objekti koji ne podležu recikliranju preći će u From oblast Survivor-a; ako From oblast nije dovoljna, ulaze direktno u To oblast.
Survivor oblast
Survivor oblast je svojevrsni bafer između Eden oblasti i Old oblasti, slično žutom svetlu na semaforu.
1. Zašto je potrebna Survivor oblast?
Zar nije samo u pitanju prelaz iz mlade u staru generaciju? Zar ne bi bilo dovoljno da Eden prelazi direktno u Old, zašto toliko komplikacija?
Da nema Survivor oblasti, pri svakom Minor GC-u Eden oblasti, preživeli objekti bi bili slati u staru generaciju, koja bi se vrlo brzo popunila. A mnogi objekti, iako prežive jedan Minor GC, u stvari neće živeti još dugo — možda će biti očišćeni pri drugom ili trećem putu.
U tom slučaju, premeštanje u staru generaciju očigledno nije mudra odluka.
Zato smisao postojanja Survivor-a jeste smanjenje broja objekata koji se šalju u staru generaciju, a time i smanjenje učestalosti Major GC-a. Survivor-ovo predselektovanje garantuje da će samo objekti koji prežive 16 Minor GC-a i još uvek budu u mladoj generaciji biti poslati u staru generaciju.
2. Zašto se Survivor oblast deli na dva dela?
Najveća korist od postavljanja dve Survivor oblasti jeste rešavanje fragmentacije memorije. Pretpostavimo najpre šta bi se desilo da postoji samo jedna Survivor oblast.
Nakon izvršenja Minor GC-a, Eden oblast se prazni, a preživeli objekti se smeštaju u Survivor oblast; međutim, među objektima koji su se ranije nalazili u Survivor oblasti, neki bi možda takođe trebalo da budu očišćeni. E sad, kako ih očistiti?
U takvoj situaciji možemo samo primeniti označavanje-brisanje, a znamo da je najveći problem označavanja-brisanja fragmentacija memorije; u mladoj generaciji, gde objekti često stradaju, primena označavanja-brisanja neminovno će dovesti do ozbiljne fragmentacije memorije.
Ali pošto Survivor ima dve oblasti, pri svakom Minor GC-u preživeli objekti iz prethodne Eden oblasti i From oblasti kopiraju se u To oblast. Pri drugom Minor GC-u, From i To zamene uloge: tada se preživeli objekti iz Eden oblasti i To oblasti kopiraju u From oblast, i tako u krug.
Najveća prednost ovog mehanizma jeste da tokom celog procesa uvek jedan Survivor prostor ostaje prazan, a drugi, neprazan Survivor prostor je bez fragmenata.
Zašto onda Survivor ne bude podeljen na još više delova? Na primer na tri, četiri, pet?
Očigledno, ako se Survivor oblast dalje deli, svaki deo bi bio relativno mali, što lako može dovesti do prepunjavanja Survivor oblasti; dve Survivor oblasti su verovatno optimalno rešenje nakon odmeravanja.
Old oblast
Stara generacija zauzima 2/3 prostora heap memorije; čisti se samo prilikom Major GC-a, pri čemu svaki GC okida "Stop-The-World". Što je memorija veća, to je i STW vreme duže, pa memorija ne mora nužno biti što veća.
Pošto algoritam kopiranja u staroj generaciji, gde je stopa preživljavanja objekata visoka, izvodi mnogo operacija kopiranja i ima nisku efikasnost, u staroj generaciji se ovde primenjuje algoritam označavanje-sređivanje.
Pored navedenog, pod mehanizmom garantovanja memorije, objekti koji ne mogu biti smešteni ulaze direktno u staru generaciju; i u sledećim situacijama objekti takođe ulaze u staru generaciju.
1. Veliki objekti
Veliki objekti se odnose na objekte kojima je potrebno mnogo neprekidnog memorijskog prostora; takvi objekti, bez obzira na to da li su "kratkog veka", ulaze direktno u staru generaciju. Ovo se prvenstveno radi radi izbegavanja obimnog kopiranja memorije između Eden oblasti i dve Survivor oblasti. Kada vaš sistem ima mnogo "kratkovečnih" velikih objekata, obratite pažnju.
2. Dugovečni objekti
Virtuelna mašina za svaki objekat definiše brojač starosti objekta (Age). U normalnim okolnostima objekti se stalno pomeraju između From i To oblasti Survivor-a; svaki put kada objekat u Survivor oblasti preživi jedan Minor GC, njegova starost se povećava za 1 godinu. Kada starost dostigne 15 godina, biće prebačen u staru generaciju. Naravno, ovu vrednost 15 JVM podržava i posebno podešavanje -XX:MaxTenuringThreshold=10.
Podrazumevani prag možete videti pomoću java -XX:+PrintFlagsFinal -version | grep MaxTenuringThreshold.

3. Dinamička starost objekata
JVM ne zahteva strogo da starost objekta mora dostići 15 godina kako bi bio smešten u staru generaciju; ako ukupna veličina objekata neke starosne grupe i starijih u Survivor prostoru premašuje polovinu Survivor prostora, onda će svi objekti te i starijih starosnih grupa pri sledećem sakupljanju smeća biti unapređeni u staru generaciju, bez čekanja na "punoletstvo".
To je pomalo slično balansiranju opterećenja — round-robin je jedna vrsta balansiranja opterećenja koja garantuje da svaki mašina dobije jednaki broj zahteva. Deluje izbalansirano, ali hardver svake mašine je drugačiji, kao i zdravstveno stanje, pa možemo prilagoditi algoritam balansiranja opterećenja na osnovu broja zahteva koje prima svaka mašina, vremena odziva itd.
Ovaj mehanizam dinamičkog prilagođavanja pomaže u optimizaciji korišćenja memorije i smanjenju učestalosti sakupljanja smeća, naročito kod aplikacija koje obrađuju veliki broj objekata kratkog životnog ciklusa.
Rezime
U ovom članku smo od početka do kraja obradili JVM mehanizam sakupljanja smeća, uključujući pojam sakupljanja smeća, algoritme za utvrđivanje smeća, algoritme sakupljanja smeća, Stop The World, mladu i staru generaciju itd.
- Referenca 1: Još jednom od početka do kraja o Java sakupljanju smeća
- Referenca 2: Detaljno o Java mehanizmu sakupljanja smeća
- Referenca 3: Tri glavna algoritma sakupljanja smeća
