A Java memóriakezelése a beépített szemétgyűjtéssel a nyelv egyik legszebb vívmánya. Lehetővé teszi a fejlesztők számára, hogy új objektumokat hozzanak létre anélkül, hogy kifejezetten a memória ki- és visszahelyezésével foglalkoznának, mivel a szemétgyűjtő automatikusan visszaköveteli az újrafelhasználásra szánt memóriát. Ez gyorsabb fejlesztést tesz lehetővé kevesebb forráskóddal, miközben kiküszöböli a memóriaszivárgásokat és más memóriával kapcsolatos problémákat. Legalábbis elméletben.
A Java szemétgyűjtése a jelek szerint túl jól működik, túl sok objektumot hoz létre és távolít el. A legtöbb memóriakezelési probléma megoldódik, de gyakran komoly teljesítményproblémák kialakulásának árán. A szemétgyűjtés mindenféle helyzethez alkalmazkodóvá tétele egy összetett és nehezen optimalizálható rendszerhez vezetett. A szemétgyűjtés megértéséhez először is meg kell értenünk, hogyan működik a memóriakezelés a Java virtuális gépben (JVM).
Hogyan működik valójában a Java szemétgyűjtés
Sokan azt hiszik, hogy a szemétgyűjtés összegyűjti és kidobja a halott objektumokat. A valóságban a Java szemétgyűjtés éppen az ellenkezőjét teszi! Az élő objektumokat követi, minden mást pedig szemétnek jelöl. Mint látni fogod, ez az alapvető félreértés számos teljesítményproblémához vezethet.
Kezdjük a heap-pal, amely a memória dinamikus kiosztásra használt területe. A legtöbb konfigurációban az operációs rendszer előre kiosztja a heapet, amelyet a program futása közben a JVM kezel. Ennek van néhány fontos következménye:
- Az objektum létrehozása gyorsabb, mivel nem kell minden egyes objektumhoz globális szinkronizációt végezni az operációs rendszerrel. Egy allokáció egyszerűen egy memóriatömb bizonyos részét igényli, és az eltolásmutatót előre mozgatja (lásd a 2.1. ábrát). A következő allokáció ezen az eltoláson kezdődik, és a tömb következő részét igényli.
- Amikor egy objektumot már nem használnak, a szemétgyűjtő visszaköveteli az alapul szolgáló memóriát, és újra felhasználja a jövőbeli objektumallokációhoz. Ez azt jelenti, hogy nincs explicit törlés, és az operációs rendszer nem kap vissza memóriát.
2.1. ábra: Az új objektumok egyszerűen a használt heap végén allokálódnak.
Minden objektum a JVM által kezelt heap-területen allokálódik. Minden olyan elemet így kezelünk, amelyet a fejlesztő használ, beleértve az osztályobjektumokat, a statikus változókat és még magát a kódot is. Amíg egy objektumra hivatkoznak, a JVM élőnek tekinti azt. Amint egy objektumra már nem hivatkoznak, és ezért az alkalmazáskód számára nem elérhető, a szemétgyűjtő eltávolítja azt, és visszaköveteli a nem használt memóriát. Bármilyen egyszerűen hangzik is ez, felvet egy kérdést: mi az első hivatkozás a fában?
Garbage-gyűjtemény gyökerei – minden objektumfa forrása
Minden objektumfának rendelkeznie kell egy vagy több gyökérobjektummal. Amíg az alkalmazás el tudja érni ezeket a gyökereket, addig az egész fa elérhető. De mikor tekinthetők elérhetőnek ezek a gyökérobjektumok? A szemétgyűjtő gyökereknek (GC-gyökerek; lásd a 2.2. ábrát) nevezett speciális objektumok mindig elérhetőek, és így minden olyan objektum is elérhető, amelynek a saját gyökerénél van egy szemétgyűjtő gyökér.
A GC-gyökereknek négy fajtája van a Java-ban:
- A helyi változókat egy szál verem tartja életben. Ez nem egy valódi objektum virtuális hivatkozása, ezért nem látható. Minden értelemben a helyi változók GC gyökerek.
- Az aktív Java szálak mindig élő objektumnak tekintendők, és ezért GC gyökerek. Ez különösen fontos a szálak helyi változói esetében.
- A statikus változókra az osztályaik hivatkoznak. Ez a tény teszi őket de facto GC gyökerekké. Maguk az osztályok garbage-gyűjtésre kerülhetnek, ami eltávolítaná az összes hivatkozott statikus változót. Ez különösen fontos, ha alkalmazáskiszolgálókat, OSGi konténereket vagy általában osztálybetöltőket használunk. Az ezzel kapcsolatos problémákat a Problémaminták fejezetben tárgyaljuk.
- A JNI-hivatkozások olyan Java-objektumok, amelyeket a natív kód egy JNI-hívás részeként hozott létre. Az így létrehozott objektumokat speciálisan kezelik, mivel a JVM nem tudja, hogy a natív kód hivatkozik-e rá vagy sem. Az ilyen objektumok a GC-gyökér egy nagyon speciális formáját képviselik, amelyet az alábbi, Problémaminták című részben fogunk részletesebben megvizsgálni.
2.2. ábra: A GC gyökerek olyan objektumok, amelyekre maguk is hivatkoznak a JVM által, és így megakadályozzák, hogy minden más objektumot szemétgyűjtés alá vonjanak.
Ezért egy egyszerű Java alkalmazás a következő GC gyökerekkel rendelkezik:
- Lokális változók a main metódusban
- A főszál
- A fő osztály statikus változói
Szemét megjelölése és kisöprése
A JVM annak megállapítására, hogy mely objektumok nincsenek már használatban, időközönként lefuttatja a nagyon találóan mark-and-sweep algoritmusnak nevezett algoritmust. Mint azt sejthetjük, ez egy egyszerű, kétlépéses folyamat:
- Az algoritmus végigjárja az összes objektumreferenciát, kezdve a GC gyökerekkel, és minden talált objektumot élőnek jelöl.
- A jelzett objektumok által nem elfoglalt összes heap-memória visszaszerzésre kerül. Egyszerűen szabadnak van jelölve, lényegében mentesül a nem használt objektumoktól.
A szemétgyűjtés célja, hogy megszüntesse a klasszikus memóriaszivárgások okát: az elérhetetlen, de nem törölt objektumokat a memóriában. Ez azonban csak az eredeti értelemben vett memóriaszivárgásokra működik. Lehetséges, hogy vannak olyan nem használt objektumok, amelyek még mindig elérhetők egy alkalmazás számára, mert a fejlesztő egyszerűen elfelejtette dereferenciázni őket. Az ilyen objektumokat nem lehet szemétbe gyűjteni. Még rosszabb, hogy az ilyen logikai memóriaszivárgást semmilyen szoftver nem képes észlelni (lásd a 2.3. ábrát). Még a legjobb elemző szoftverek is csak a gyanús objektumokat tudják kiemelni. A memóriaszivárgás elemzését a memóriahasználat és a szemétgyűjtés teljesítményre gyakorolt hatásának elemzése című részben fogjuk megvizsgálni.