Az alkalmazások teljesítményének javítása ezekkel a fejlett GC-technikákkal

Az alkalmazások teljesítménye az első helyen áll, és a szemétgyűjtés optimalizálása jó hely az apró, de jelentős előrelépésekre

Az automatikus szemétgyűjtés (a JIT HotSpot Compilerrel együtt) a JVM egyik legfejlettebb és legértékesebb összetevője, de sok fejlesztő és mérnök sokkal kevésbé ismeri a szemétgyűjtést (GC), annak működését és az alkalmazások teljesítményére gyakorolt hatását.

Először is, mire való egyáltalán a GC? A szemétgyűjtés a halomban lévő objektumok memóriakezelési folyamata. Ahogy az objektumok a kupacba kerülnek, néhány gyűjtési fázison futnak keresztül – általában elég gyorsan, mivel a kupacban lévő objektumok többsége rövid élettartamú.

A szemétgyűjtési események három fázist tartalmaznak: jelölés, törlés és másolás/tömörítés. Az első fázisban a GC végigfut a halmon, és mindent megjelöl vagy élő (hivatkozott) objektumként, vagy nem hivatkozott objektumként, vagy szabad memóriaterületként. A nem hivatkozott objektumok ezután törlésre kerülnek, a megmaradt objektumok pedig tömörítésre kerülnek. A generációs szemétgyűjtés során az objektumok “öregednek”, és életük során 3 téren – Eden, Survivor tér és Tenured (Old) tér – lépnek előre. Ez az eltolódás szintén a tömörítési fázis részeként történik.

De elég ebből, térjünk rá a szórakoztató részre!

Psst! Megoldást keres az alkalmazások teljesítményének javítására?Az OverOps segít a vállalatoknak nemcsak azt azonosítani, hogy mikor és hol fordulnak elő lassulások, hanem azt is, hogy miért és hogyan. Nézzen meg egy élő demót, hogy lássa, hogyan működik.

A szemétgyűjtés (GC) megismerése Java-ban

Az automatizált GC egyik nagyszerű tulajdonsága, hogy a fejlesztőknek nem igazán kell érteniük, hogyan működik. Sajnos ez azt jelenti, hogy sok fejlesztő NEM érti, hogyan működik. A szemétgyűjtés és a sok rendelkezésre álló GC megértése valahogy olyan, mint a Linux CLI parancsok ismerete. Technikailag nem kell használnod őket, de az ismeretük és a kényelmes használatuk jelentős hatással lehet a termelékenységedre.

A CLI-parancsokhoz hasonlóan vannak az abszolút alapok. ls parancs a szülőmappán belüli mappák listájának megtekintéséhez, mv egy fájl egyik helyről a másikra történő áthelyezéséhez stb. A GC-ben az ilyen típusú parancsok egyenértékűek lennének azzal, ha tudnánk, hogy több GC közül választhatunk, és hogy a GC teljesítményproblémákat okozhat. Természetesen még nagyon sok mindent meg kell tanulni (a Linux CLI használatáról ÉS a szemétgyűjtésről).

A Java szemétgyűjtési folyamatának megismerésének célja nem csak a felesleges (és unalmas) beszélgetésindítás, a cél az, hogy megtanuljuk, hogyan lehet hatékonyan implementálni és fenntartani a megfelelő GC-t az adott környezetünk számára optimális teljesítménnyel. Annak ismerete, hogy a szemétgyűjtés hatással van az alkalmazás teljesítményére, alapvető, és számos fejlett technika létezik a GC teljesítményének növelésére és az alkalmazás megbízhatóságára gyakorolt hatásának csökkentésére.

GC teljesítményével kapcsolatos aggályok

Memóriaszivárgás –

A heap szerkezetének és a szemétgyűjtés végrehajtásának ismeretében tudjuk, hogy a memóriahasználat fokozatosan növekszik, amíg egy szemétgyűjtési esemény bekövetkezik, és a használat visszaesik. A hivatkozott objektumok halomkihasználtsága általában állandó marad, így a csökkenésnek nagyjából ugyanarra a mennyiségre kell esnie.

Memóriaszivárgás esetén minden GC esemény a halomobjektumok egy kisebb részét törli (bár sok hátrahagyott objektumot nem használnak), így a halomkihasználtság tovább fog nőni, amíg a halom memóriája meg nem telik, és OutOfMemoryError kivételt nem dob ki. Ennek oka, hogy a GC csak a nem hivatkozott objektumokat jelöli törlésre. Tehát még ha egy hivatkozott objektumot már nem is használnak, akkor sem törlődik a heapről. Van néhány hasznos kódolási trükk ennek megakadályozására, amivel kicsit később foglalkozunk.

Folyamatos “Stop the World” események –

A szemétgyűjtést bizonyos esetekben Stop the World eseménynek is nevezhetjük, mert amikor bekövetkezik, a JVM összes szála (és így a rajta futó alkalmazás is) leáll, hogy a GC végrehajtódhasson. Egészséges alkalmazásokban a GC végrehajtási ideje viszonylag alacsony, és nincs nagy hatással az alkalmazás teljesítményére.

A szuboptimális helyzetekben azonban a Stop the World események nagyban befolyásolhatják az alkalmazás teljesítményét és megbízhatóságát. Ha egy GC esemény Stop the World szünetet igényel, és 2 másodpercig tart a végrehajtása, akkor az adott alkalmazás végfelhasználója 2 másodperces késedelmet tapasztal, mivel az alkalmazást futtató szálak leállnak a GC engedélyezése érdekében.

A memóriaszivárgás esetén a folyamatos Stop the World események szintén problémásak. Mivel a GC minden egyes végrehajtásával kevesebb heap memóriaterület kerül kiürítésre, kevesebb időbe telik, amíg a maradék memória feltöltődik. Amikor a memória megtelt, a JVM újabb GC eseményt indít. Végül a JVM ismételt Stop the World eseményeket fog futtatni, ami komoly teljesítményproblémákat okoz.

CPU-használat –

Az egész a CPU-használathoz vezet. A folyamatos GC / Stop the World események egyik fő tünete a CPU használat kiugrása. A GC egy számításigényes művelet, és így a kelleténél több CPU teljesítményt vehet igénybe. A párhuzamos szálakat futtató GC-k esetében a CPU-használat még magasabb lehet. A megfelelő GC kiválasztása az alkalmazáshoz a legnagyobb hatással lesz a CPU-használatra, de vannak más módszerek is a jobb teljesítmény optimalizálására ezen a területen.

A szemétgyűjtéssel kapcsolatos teljesítménybeli aggályokból megérthetjük, hogy bármennyire is fejlettek a GC-k (és egyre fejlettebbek), az akhillészi sarkuk ugyanaz marad. Redundáns és kiszámíthatatlan objektum allokációk. Az alkalmazás teljesítményének javításához nem elég a megfelelő GC kiválasztása. Tudnunk kell, hogyan működik a folyamat, és úgy kell optimalizálnunk a kódunkat, hogy a GC-k ne vonjanak el túlzott erőforrásokat, és ne okozzanak túlzott szüneteket az alkalmazásunkban.

Generációs GC

Mielőtt belemerülnénk a különböző Java GC-kbe és azok teljesítményre gyakorolt hatásába, fontos, hogy megértsük a generációs szemétgyűjtés alapjait. A generációs GC alapkoncepciója azon az elképzelésen alapul, hogy minél tovább létezik egy objektumra való hivatkozás a halomban, annál kisebb a valószínűsége annak, hogy törlésre kerüljön megjelölésre. Az objektumok képletes “életkorral” való megjelölésével különböző tárolóterekbe lehet őket szétválasztani, hogy a GC ritkábban jelölje meg őket.

Amikor egy objektumot a halomra allokálnak, az úgynevezett Eden-térbe kerül. Ez az a hely, ahonnan az objektumok elindulnak, és a legtöbb esetben ez az a hely, ahol törlésre megjelölik őket. Azok az objektumok, amelyek túlélik ezt a szakaszt, “születésnapot ünnepelnek”, és átmásolódnak a Survivor térbe. Ez a folyamat az alábbiakban látható:

Az Eden és a Survivor terek alkotják az úgynevezett Fiatal generációt. Itt zajlik az akció nagy része. Amikor (Ha) egy objektum a Fiatal Generációban elér egy bizonyos életkort, átkerül a Tenured (más néven Old) térbe. Az objektummemóriák kor alapján történő felosztásának előnye, hogy a GC különböző szinteken működhet.

A Minor GC egy olyan gyűjtemény, amely csak a Young Generationre összpontosít, és gyakorlatilag teljesen figyelmen kívül hagyja a Tenured teret. Általában a Young Generationben lévő objektumok többsége törlésre van jelölve, és egy Major vagy Full GC (beleértve az Old Generationt is) nem szükséges a memória felszabadításához a heapon. Természetesen Major vagy Full GC-t indítunk, ha szükséges.

Egy gyors trükk a GC működésének optimalizálására ez alapján, hogy a heap területek méretét az alkalmazások igényeihez legjobban igazítjuk.

Kollektortípusok

Egyre többféle GC közül választhatunk, és bár a G1 lett az alapértelmezett GC a Java 9-ben, eredetileg a CMS kollektor helyettesítésére szánták, amely Low Pause, így az Throughput kollektorral futó alkalmazásoknak jobb lehet, ha maradnak a jelenlegi kollektoruknál. A Java szemétgyűjtők működési különbségeinek és teljesítményre gyakorolt hatásának megértése továbbra is fontos.

Átbocsátási gyűjtők

Jobb az olyan alkalmazások számára, amelyeket nagy átbocsátási teljesítményre kell optimalizálni, és ennek eléréséhez nagyobb késleltetést tudnak felajánlani.

Soros –

A soros gyűjtő a legegyszerűbb, és az, amit a legkevésbé valószínű, hogy használni fogsz, mivel elsősorban egyszálas környezetekre (pl. 32 bites vagy Windows) és kis heapekre tervezték. Ez a gyűjtő képes vertikálisan skálázni a memóriahasználatot a JVM-ben, de több Major/Full GC-t igényel a kihasználatlan heap erőforrások felszabadításához. Ez gyakori Stop the World szüneteket okoz, ami minden értelemben kizárja a felhasználói környezetben való használatot.

Parallel –

Amint a neve is mutatja, ez a GC több párhuzamosan futó szálat használ a heap átvizsgálására és tömörítésére. Bár a Parallel GC több szálat használ a szemétgyűjtéshez, mégis szünetelteti az összes alkalmazásszálat futás közben. A párhuzamos gyűjtő olyan alkalmazások számára a legalkalmasabb, amelyeket a legjobb átviteli teljesítményre kell optimalizálni, és cserébe nagyobb késleltetést is elviselnek.

Az alacsony szünetű gyűjtők

A legtöbb felhasználóval szembenéző alkalmazásnak alacsony szünetű GC-re lesz szüksége, hogy a felhasználói élményt ne befolyásolják a hosszú vagy gyakori szünetek. Ezek a GC-k a reakciókészség (idő/esemény) és az erős rövid távú teljesítmény optimalizálásáról szólnak.

Concurrent Mark Sweep (CMS) –

A Parallel gyűjtőhöz hasonlóan a Concurrent Mark Sweep (CMS) gyűjtő is több szálat használ a nem hivatkozott objektumok jelölésére és söprésére (eltávolítására). Ez a GC azonban csak két konkrét esetben indít Stop the World eseményeket:

(1) a gyökerek (a régi generációban lévő, szál belépési pontokból vagy statikus változókból elérhető objektumok) vagy a main() metódusból származó bármely hivatkozás kezdeti jelölésének inicializálásakor, és még néhányszor

(2) amikor az alkalmazás megváltoztatta a heap állapotát, miközben az algoritmus párhuzamosan futott, arra kényszerítve, hogy visszamenjen és elvégezzen néhány utolsó simítást, hogy megbizonyosodjon arról, hogy a megfelelő objektumokat jelölte

G1 –

A Garbage first collector (közismert nevén G1) több háttérszálat használ a heap átvizsgálására, amelyet régiókra oszt. Úgy működik, hogy először azokat a régiókat vizsgálja át, amelyek a legtöbb szemetes objektumot tartalmazzák, innen kapta a nevét (Garbage first).

Ez a stratégia csökkenti annak az esélyét, hogy a halom kimerüljön, mielőtt a háttérszálak befejeznék a nem használt objektumok átvizsgálását, ebben az esetben a gyűjtőnek le kellene állítania az alkalmazást. A G1 gyűjtő másik előnye, hogy menet közben tömöríti a heapet, amit a CMS gyűjtő csak a teljes Stop the World gyűjtések során végez.

A GC teljesítményének javítása

Az alkalmazás teljesítményét közvetlenül befolyásolja a szemétgyűjtések gyakorisága és időtartama, vagyis a GC folyamat optimalizálása ezen metrikák csökkentésével történik. Ennek két fő módja van. Egyrészt a fiatal és a régi generációk halomméreteinek beállításával, másrészt az objektumkiosztás és -promóció sebességének csökkentésével.

A halomméretek beállítása nem olyan egyszerű, mint azt várnánk. A logikus következtetés az lenne, hogy a halom méretének növelése csökkenti a GC gyakoriságát, miközben növeli az időtartamot, és a halom méretének csökkentése csökkenti a GC időtartamát, miközben növeli a gyakoriságot.

A tény azonban az, hogy a Minor GC időtartama nem a halom méretétől függ, hanem a gyűjtést túlélő objektumok számától. Ez azt jelenti, hogy a többnyire rövid életű objektumokat létrehozó alkalmazások esetében a fiatal generáció méretének növelése valójában csökkentheti mind a GC időtartamát, mind a gyakoriságát. Ha azonban a fiatal generáció méretének növelése a túlélő helyekre másolandó objektumok számának jelentős növekedéséhez vezet, a GC szünetek hosszabb ideig tartanak, ami megnövekedett késleltetéshez vezet.

3 tipp a GC-hatékony kód írásához

1. tipp: Előre jelezze a gyűjteménykapacitásokat –

A Java összes szabványos gyűjteménye, valamint a legtöbb egyéni és kiterjesztett implementáció (például a Trove és a Google Guava) mögöttes (primitív- vagy objektumalapú) tömböket használ. Mivel a tömbök mérete a kiosztás után megváltoztathatatlan, az elemek hozzáadása egy gyűjteményhez sok esetben azt eredményezheti, hogy egy régi mögöttes tömböt el kell dobni egy nagyobb, újonnan kiosztott tömb javára.

A legtöbb gyűjtemény implementáció megpróbálja optimalizálni ezt az újrakiosztási folyamatot, és az amortizált minimumon tartani, még akkor is, ha a gyűjtemény várható mérete nincs megadva. A legjobb eredményt azonban akkor érhetjük el, ha a gyűjteménynek a létrehozásakor megadjuk a várható méretét.

Tipp #2: Folyamok közvetlen feldolgozása –

Az adatfolyamok, például a fájlokból beolvasott vagy a hálózaton keresztül letöltött adatok feldolgozása során nagyon gyakran találkozunk valami hasonlóval:

A kapott byte tömböt ezután XML dokumentumba, JSON objektumba vagy Protocol Buffer üzenetbe lehet elemezni, hogy csak néhány népszerű lehetőséget említsünk.

Ha nagy vagy kiszámíthatatlan méretű fájlokkal van dolgunk, ez nyilvánvalóan rossz ötlet, mivel OutOfMemoryErrors-nak tesz ki minket abban az esetben, ha a JVM valójában nem tud az egész fájl méretének megfelelő puffert allokálni.

Jobb megoldás, ha a megfelelő InputStream-et (ebben az esetben FileInputStream-et) használjuk, és közvetlenül az elemzőbe tápláljuk, anélkül, hogy először az egészet egy byte tömbbe olvasnánk. Minden nagyobb könyvtár kiállít API-t a streamek közvetlen elemzésére, például:

Tipp #3: Használj megváltoztathatatlan objektumokat –

A megváltoztathatatlanságnak számos előnye van. Az egyik, amelyre ritkán fordítanak kellő figyelmet, a szemétgyűjtésre gyakorolt hatása.

A megváltoztathatatlan objektum olyan objektum, amelynek mezői (esetünkben különösen a nem primitív mezők) nem módosíthatók az objektum felépítése után.

A megváltoztathatóság azt jelenti, hogy egy megváltoztathatatlan tároló által hivatkozott összes objektum már létrejött, mielőtt a tároló felépítése befejeződik. GC kifejezéssel élve: A konténer legalább olyan fiatal, mint a legfiatalabb hivatkozás, amit tartalmaz. Ez azt jelenti, hogy a fiatal generációkon végzett szemétgyűjtési ciklusok során a GC kihagyhatja a régebbi generációkban található megváltoztathatatlan objektumokat, mivel biztosan tudja, hogy azok nem hivatkozhatnak semmire a begyűjtendő generációban.

A kevesebb vizsgálandó objektum kevesebb vizsgálandó memóriaoldalt jelent, a kevesebb vizsgálandó memóriaoldal pedig rövidebb GC-ciklusokat jelent, ami rövidebb GC-szüneteket és jobb általános teljesítményt jelent.

További tippekért és részletes példákért olvassa el ezt a bejegyzést, amely a memóriahatékonyabb kód írásának mélyreható taktikáiról szól.

*** Hatalmas köszönet Amit Hurvitznak az OverOps R&D csapatából a szenvedélyéért és a rálátásáért, amely ebbe a bejegyzésbe került!

Szólj hozzá!