A JVM teljesítményének optimalizálása, 3. rész: Szemétgyűjtés

A Java platform szemétgyűjtési mechanizmusa nagyban növeli a fejlesztők termelékenységét, de egy rosszul megvalósított szemétgyűjtő túlságosan igénybe veheti az alkalmazás erőforrásait. A JVM teljesítményoptimalizálás sorozat harmadik cikkében Eva Andreasson a Java-kezdőknek áttekintést nyújt a Java platform memóriamodelljéről és GC-mechanizmusáról. Ezután elmagyarázza, hogy miért a töredezettség (és nem a GC) a Java-alkalmazások teljesítményének fő “buktatója!”, és miért a generációs szemétgyűjtés és a tömörítés a vezető (bár nem a leginnovatívabb) megközelítések jelenleg a halom töredezettségének kezelésére a Java-alkalmazásokban.

A szemétgyűjtés (GC) az a folyamat, amelynek célja a lefoglalt memória felszabadítása, amelyre már nem hivatkozik egyetlen elérhető Java-objektum sem, és amely a Java virtuális gép (JVM) dinamikus memóriakezelő rendszerének alapvető része. Egy tipikus szemétgyűjtési ciklusban minden olyan objektum, amelyre még hivatkoznak, és így elérhetőek, megmarad. A korábban hivatkozott objektumok által elfoglalt helyet felszabadítják és visszaszerzik, hogy lehetővé tegyék az új objektumok kiosztását.

A szemétgyűjtés és a különböző GC-megközelítések és algoritmusok megértéséhez először is tudnunk kell néhány dolgot a Java platform memóriamodelljéről.

Garbage collection and the Java platform memory model

Ha a Java-alkalmazás parancssorában megadjuk a -Xmx indítási opciót (például: java -Xmx:2g MyApp), akkor a memóriát egy Java-folyamathoz rendeljük. Ezt a memóriát Java-heapnek (vagy egyszerűen csak heapnek) nevezzük. Ez az a dedikált memóriacímtartomány, ahol a Java-program (vagy néha a JVM) által létrehozott összes objektum kiosztásra kerül. Ahogy a Java-programja folyamatosan fut és új objektumokat rendel ki, a Java heap (vagyis ez a címtartomány) megtelik.

Egyszer a Java heap megtelik, ami azt jelenti, hogy a kiosztó szál nem talál elég nagy, egymást követő szabad memóriarészt az általa kiosztani kívánt objektum számára. Ekkor a JVM megállapítja, hogy szemétgyűjtésre van szükség, és értesíti a szemétgyűjtőt. A szemétgyűjtés akkor is elindulhat, ha egy Java program a System.gc() hívást hívja. A System.gc() használata nem garantálja a szemétgyűjtést. Mielőtt bármilyen szemétgyűjtés elindulna, a GC-mechanizmus először meghatározza, hogy biztonságos-e a szemétgyűjtés elindítása. Biztonságos akkor elindítani a szemétgyűjtést, ha az alkalmazás összes aktív szála olyan biztonságos ponton van, amely ezt lehetővé teszi, pl. egyszerűen elmagyarázva, hogy nem lenne jó a szemétgyűjtés elindítása egy folyamatban lévő objektumkiosztás közepén, vagy egy optimalizált CPU utasítássorozat végrehajtásának közepén (lásd a fordítókról szóló korábbi cikkemet), mivel elveszítheti a kontextust, és ezáltal elronthatja a végeredményt.

A szemétgyűjtő soha nem kérhet vissza egy aktívan hivatkozott objektumot; ha ezt tenné, azzal megszegné a Java virtuális gép specifikációját. A szemétgyűjtőnek sem kell azonnal begyűjtenie a halott objektumokat. A halott objektumok végül a következő szemétgyűjtési ciklusok során kerülnek begyűjtésre. Bár a szemétgyűjtést sokféleképpen lehet megvalósítani, ez a két feltételezés minden változatra igaz. A szemétgyűjtés igazi kihívása az, hogy azonosítson minden élő (még hivatkozott) objektumot, és visszaszerezze a nem hivatkozott memóriát, de mindezt úgy, hogy ne befolyásolja a futó alkalmazásokat a szükségesnél jobban. A szemétgyűjtőnek tehát két megbízatása van:

  1. A nem hivatkozott memória gyors felszabadítása, hogy kielégítse az alkalmazás allokációs rátáját, hogy ne fogyjon ki a memória.
  2. A memória visszaszerzése a teljesítmény minimális befolyásolása mellett (pl., késleltetése és áteresztőképessége).

A szemétgyűjtés két fajtája

A sorozat első cikkében érintettem a szemétgyűjtés két fő megközelítését, a referenciaszámláló és a nyomkövető gyűjtőket. Ezúttal az egyes megközelítésekre mélyebben rátérek, majd bemutatok néhány algoritmust, amelyeket a nyomkövető gyűjtők termelési környezetben történő megvalósításához használnak.

A JVM teljesítményének optimalizálása sorozat

  • JVM teljesítményének optimalizálása, 1. rész: Áttekintés
  • JVM teljesítményoptimalizálás, 2. rész: Fordítók

Referenciaszámláló kollektorok

A referenciaszámláló kollektorok nyomon követik, hogy hány hivatkozás mutat egy-egy Java-objektumra. Amint egy objektum számlálása nullára csökken, a memória azonnal visszakövetelhető. Ez a visszanyert memóriához való azonnali hozzáférés a szemétgyűjtés referencia-számlálós megközelítésének legfőbb előnye. A nem hivatkozott memória megtartása nagyon kevés többletköltséggel jár. Az összes referenciaszámlálás naprakészen tartása azonban elég költséges lehet.

A referenciaszámláló gyűjtők fő nehézsége a referenciaszámlálás pontosságának fenntartása. Egy másik jól ismert kihívás a körkörös struktúrák kezelésével kapcsolatos bonyolultság. Ha két objektum hivatkozik egymásra, és egyetlen élő objektum sem hivatkozik rájuk, akkor a memóriájuk soha nem szabadul fel. Mindkét objektum örökre nem nulla számlálóval marad. A körkörös struktúrákhoz kapcsolódó memória visszanyerése jelentős elemzést igényel, ami költséges többletköltséget jelent az algoritmusnak, és így az alkalmazásnak is.

Követési gyűjtők

A követési gyűjtők azon a feltételezésen alapulnak, hogy az összes élő objektum megtalálható az összes hivatkozás és a későbbi hivatkozások iteratív követésével az ismert élő objektumok kezdeti halmazából. Az élő objektumok kezdeti halmazát (ezeket nevezzük gyökérobjektumoknak vagy röviden csak gyökereknek) a regiszterek, globális mezők és veremkeretek elemzésével találjuk meg a szemétgyűjtés elindításának pillanatában. A kezdeti élőhalmaz azonosítása után a nyomkövetési gyűjtő követi ezen objektumok hivatkozásait, és sorba állítja őket, hogy élőnek jelölje őket, és ezt követően nyomon kövesse a hivatkozásaikat. Az összes talált hivatkozott objektum élőnek jelölése azt jelenti, hogy az ismert élőhalmaz idővel növekszik. Ez a folyamat addig folytatódik, amíg az összes hivatkozott (és így az összes élő) objektumot megtaláljuk és megjelöljük. Amint a nyomkövető gyűjtő megtalálta az összes élő objektumot, a maradék memóriát visszaköveteli.

A nyomkövető gyűjtők abban különböznek a referenciaszámláló gyűjtőktől, hogy képesek körkörös struktúrákat kezelni. A legtöbb nyomkövető kollektor csapdája a jelölési fázis, amely várakozással jár, mielőtt a nem hivatkozott memóriát visszakövetelhetnénk.

A nyomkövető kollektorokat leggyakrabban dinamikus nyelvek memóriakezelésére használják; a Java nyelvben messze a legelterjedtebbek, és már évek óta beváltak a kereskedelmi forgalomban, termelési környezetben. A cikk hátralévő részében a nyomkövető gyűjtőkre koncentrálok, kezdve néhány algoritmussal, amelyek a szemétgyűjtésnek ezt a megközelítését valósítják meg.

A nyomkövető gyűjtő algoritmusok

A másoló és a mark-and-sweep szemétgyűjtés nem új, de még mindig ez a két leggyakoribb algoritmus, amely a nyomkövető szemétgyűjtést manapság megvalósítja.

Másoló gyűjtők

A hagyományos másoló gyűjtők egy from-space-t és egy to-space-t használnak — vagyis a halom két külön meghatározott címterét. A szemétgyűjtéskor a from-space-ként definiált területen belüli élő objektumok átmásolódnak a to-space-ként definiált területen belüli következő szabad helyre. Amikor a from-térben lévő összes élő objektum kikerül, a teljes from-tér visszaszerezhető. Amikor a kiosztás újra kezdődik, a to-tér első szabad helyéről indul.

Az algoritmus régebbi implementációiban a from-tér és a to-tér helyet cserél, ami azt jelenti, hogy amikor a to-tér megtelt, a szemétgyűjtés ismét elindul, és a to-térből lesz a from-tér, ahogy az 1. ábrán látható.

1. ábra. Egy hagyományos másolós szemétgyűjtési szekvencia (kattintson a nagyításhoz)

A másolós algoritmus modernebb megvalósításai lehetővé teszik, hogy a halmon belül tetszőleges címtereket rendeljünk ki to-space-nek és from-space-nek. Ezekben az esetekben nem feltétlenül kell helyet cserélniük egymással, hanem mindegyik egy másik címtartomány lesz a halmon belül.

A másoló gyűjtők egyik előnye, hogy az objektumok szorosan együtt kerülnek kiosztásra a to-térben, teljesen kiküszöbölve a fragmentálódást. A fragmentáció egy gyakori probléma, amellyel más szemétgyűjtő algoritmusok küzdenek; ezt a cikk későbbi részében tárgyalni fogom.

A másoló gyűjtők hátrányai

A másoló gyűjtők általában stop-the-world gyűjtők, ami azt jelenti, hogy amíg a szemétgyűjtés ciklusban van, addig semmilyen alkalmazási munka nem hajtható végre. Egy stop-the-world implementációban minél nagyobb a másolandó terület, annál nagyobb hatással lesz az alkalmazás teljesítményére. Ez hátrányos a válaszidőre érzékeny alkalmazások esetében. A másolásgyűjtővel figyelembe kell vennie a legrosszabb forgatókönyvet is, amikor minden él a from-térben. Mindig elegendő mozgásteret kell hagyni az élő objektumok mozgatásához, ami azt jelenti, hogy a to-térnek elég nagynak kell lennie ahhoz, hogy a from-térben mindent elférjen. A másoló algoritmus e megkötés miatt kissé memória-hiányos.

Mark-and-sweep gyűjtők

A legtöbb kereskedelmi JVM-en, amelyeket vállalati termelési környezetben telepítenek, mark-and-sweep (vagy jelölő) gyűjtők futnak, amelyeknek nincs akkora teljesítményhatásuk, mint a másoló gyűjtőknek. A leghíresebb jelölő kollektorok közül néhány a CMS, a G1, a GenPar és a DeterministicGC (lásd a Forrásokat).

A mark-and-sweep kollektor nyomon követi a hivatkozásokat, és minden egyes talált objektumot “élő” bittel jelöl. Általában egy beállított bit egy címnek vagy bizonyos esetekben egy címkészletnek felel meg a halomban. Az élő bit tárolható például az objektum fejlécében lévő bitként, bitvektorként vagy bittérképként.

Amikor már mindent élőnek jelöltünk, a sweep fázis lép életbe. Ha egy gyűjtőnek van sweep-fázisa, akkor az alapvetően tartalmaz valamilyen mechanizmust a halom újbóli bejárására (nem csak az élőhalmazon, hanem a teljes halom hosszán), hogy megkeresse az összes nem jelölt darabot az egymást követő memóriacímterekben. A nem jelölt memória szabad és visszakövetelhető. A gyűjtő ezután ezeket a jelöletlen darabokat szervezett szabadlistákba kapcsolja össze. A szemétgyűjtőben különböző szabadlisták lehetnek — általában darabméretek szerint szervezve. Egyes JVM-ek (például a JRockit Real Time) heurisztikával rendelkező gyűjtőket implementálnak, amelyek dinamikusan méret szerinti listákat állítanak össze az alkalmazás profilozási adatai és az objektumméret-statisztikák alapján.

Amikor a söprési fázis befejeződik, az allokáció újra kezdődik. Az új allokációs területek a szabad listákból kerülnek kiosztásra, és a memóriakockák megfeleltethetők az objektumméreteknek, a szálazonosítónkénti objektumméret-átlagoknak vagy az alkalmazással hangolt TLAB-méreteknek. A szabad területek pontosabb illesztése annak méretéhez, amit az alkalmazás ki akar osztani, optimalizálja a memóriát, és segíthet csökkenteni a töredezettséget.

Bővebben a TLAB méretekről

A TLAB és a TLA (Thread Local Allocation Buffer vagy Thread Local Area) partícionálásról a JVM teljesítményoptimalizálás, 1. rész szól.

A mark-and-sweep gyűjtők hátrányai

A mark fázis a halomban lévő élő adatok mennyiségétől, míg a sweep fázis a halom méretétől függ. Mivel a memória visszaszerzéséhez meg kell várni, amíg a mark és a sweep fázis is befejeződik, ez az algoritmus nagyobb halmok és nagyobb élő adathalmazok esetén szünetidő-problémákat okoz.

Az egyik módja annak, hogy a nagy memóriaigényű alkalmazásoknak segítsen, a GC-tuning beállítások használata, amelyek alkalmazkodnak a különböző alkalmazási forgatókönyvekhez és igényekhez. A tuning sok esetben segíthet abban, hogy legalább elhalassza bármelyik fázist attól, hogy az alkalmazás vagy a szolgáltatási szintű megállapodások (SLA) szempontjából kockázatot jelentsen. (Egy SLA azt írja elő, hogy az alkalmazás teljesít bizonyos alkalmazási válaszidőket — azaz a késleltetést.) A minden terhelésváltozásra és alkalmazásmódosításra történő hangolás azonban ismétlődő feladat, mivel a hangolás csak egy adott munkaterhelésre és kiosztási arányra érvényes.

A mark-and-sweep gyűjtés megvalósítása

A mark-and-sweep gyűjtés megvalósítására legalább két kereskedelmi forgalomban elérhető és bevált megközelítés létezik. Az egyik a párhuzamos megközelítés, a másik pedig az egyidejű (vagy többnyire egyidejű) megközelítés.

Párhuzamos gyűjtők

A párhuzamos gyűjtés azt jelenti, hogy a folyamathoz rendelt erőforrásokat párhuzamosan használják a szemétgyűjtéshez. A legtöbb kereskedelmi forgalomban megvalósított párhuzamos gyűjtő monolitikus stop-the-world gyűjtő — az alkalmazás minden szála leáll, amíg a teljes szemétgyűjtési ciklus be nem fejeződik. Az összes szál leállítása lehetővé teszi az összes erőforrás hatékony párhuzamos használatát a szemétgyűjtés befejezéséhez a jelölési és a söprési fázisokon keresztül. Ez nagyon magas szintű hatékonyságot eredményez, ami általában magas pontszámokat eredményez az olyan teljesítmény-összehasonlító mérőszámokon, mint a SPECjbb. Ha az átbocsátási teljesítmény alapvető fontosságú az alkalmazás számára, a párhuzamos megközelítés kiváló választás.

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.