Garbage collection (dále jen GC) v Microsoft .NET Frameworku kompletně osvobozuje každého programátora od starostí kdy uvolnit paměťové prostředky apod. Pokud čtete tento článek, chete určitě porozumět, jak to vlastně funguje. V první části si povíme, jak GC alokuje a kontroluje objekty, detailně si popíšeme, jak pracují jeho algoritmy. Budeme také mluvit o tom, jak GC čistí paměť.
Implementovat dobrou správu paměti pro vaše aplikace může být těžší, než se zdá. Mnohdy to také odvádí pozornost od reálných problémů, které máte řešit. Nebylo by krásné mít nějaký mechanismus, který by se staral o správu paměti místo vás? Naštěstí existuje v platformě .NET Framework garbage collection (GC).
Každý program používá své objekty zcela odlišně – jako buffery, síťové připojení, databázové spojení atd. Ve skutečnosti je v objektově orientovaném prostředí každý objekt dostupný pomocí typů. Pro manipulaci s kterýmkoliv objektem je třeba alokovat paměť. Zde jsou jednotlivé kroky nutné k zpřístupnění objektů:
- Alokovat paměť pro objekt reprezentující třídu
- Inicializovat tuto paměť a nastavit výchozí stav objektu, aby mohl být použitelný
- Použít objekt pomocí členů instance třídy (opakovat dle potřeby)
- Uvést objekt do stavu jeho zániku
- Uvolnit paměť
Tento zdánlivě jednoduchý příklad je nejčastějším zdrojem softwarových chyb. Konec konců, kolikrát jste zapomněli uvolnit paměť, kterou jste již dále vůbec nepotřebovali?
Tato chyba je horší než většina jiných softwarových chyb, protože jaké důsledky budou mít a kdy se projeví, se nedá předvídat. U ostatních chyb zpozorujete špatné chování aplikace a lehce najdete řešení. Ale tyto chyby způsobí „netěsnost“ objektů (nekontrolovaná spotřeba paměti) a rozpad objektů (destabilizaci). Naštěstí je zde několik nástrojů (např. Správce úloh), které jsou navrženy pro pomoc vývojářům nalézat tyto chyby.
Jak jsem již řekl dříve, GC oprostí programátora od starostí s pamětí. On však ale neví o třídách reprezentovaných objekty vůbec nic. Znamená to, že GC nemůže vědět, jak provést krok 4. – uvést objekt do stavu zániku. V .NET Frameworku programátoři píší tento kód v metodách Close, Dispose nebo Finalize, které budou popsány později. Taky se dozvíme, že GC může zjistit, kdy tyto metody volat automaticky.
Samozřejmě že ne každý objekt požaduje nějaký proces úklidu. Např. objekt Rectangle může být kompletně smazán destrukcí členů left, right, width a height. Na druhou stranu objekt reprezentující síťové spojení bude určitě před destrukcí požadovat nějakou rutinu. Nyní si však vysvětlíme, jak GC alokuje paměť a jak inicializuje nové objekty.
Alokace paměti pro objekty
Microsoft .NET Framework common language runtime (CLR) požaduje, aby všechny objekty byly alokovány na řízené hromadě (managed heap). Je to podobné jako C-runtime hromada až na to, že objekty jsou v řízené hromadě mazány automaticky, jakmile je žádná aplikace nepotřebuje. To vyvolává otázku: jak může řízená hromada vědět, zda-li již není určitý objekt používán v aplikaci?
GC dnes používá několik algoritmů. Každý čistící algoritmus v jednotlivých prostředích se stará o co nejlepší výkonnost. Tento článek se soustředí na GC algoritmy, které jsou v CLR používány.
Po spuštění procesu runtime rezervuje sousední volnou oblast paměti. Tento blok paměti je řízená hromada. Tato hromada udržuje ukazatel, který budu dále nazývat NextObjPtr. Tento ukazatel označuje místo, kde na hromadě bude další objekt alokován. NextObjPtr je nejprve nastaven na vlastní adresu tohoto bloku paměti.
Aplikace vytvoří objekt pomocí operátoru new. Tento operátor se nejprve ujistí, že se nový objekt na hromadu vůbec vejde. Jestliže ano, NextObjPtr pak ukazuje na tento objekt, zavolá se jeho konstruktor a operátor new vrací adresu nově vytvořeného objektu.
V této fázi je NextObjPtr inkrementován, takže ukazuje na místo, kde bude umístěn další objekt. Obrázek ukazuje, že hromada obsahuje tři objekty: A, B a C. Další objekt bude umístěn tam, kde ukazuje NextObjPtr ukazatel (ihned za objekt C).
Nyní se podívejme, jak alokuje C-runtime hromada paměť. Alokace zde představuje procházení kolekce datových struktur. Jakmile je nalezen dostatečně velký blok paměti, rozdělí se a příslušný ukazatel v kolekci musí být upraven. V případě řízené hromady alokace objektu zahrnuje přidání hodnoty k ukazateli – to je mnohem rychlejší. Ve skutečnosti je alokace v řízené hromadě o trochu rychlejší než alokace paměti v zásobníku.
Ve skutečnosti je alokace v řízené hromadě o trochu rychlejší než alokace paměti v zásobníku.
Až doteď vše vyznělo, jako by byla řízená hromada vylepšenou a jednodušší C-runtime hromadou. Samozřejmě že řízená hromada by takto vypadala za předpokladu, že by měla k dispozici nekonečné množství paměťového prostoru. Tento předpoklad je (bez pochyb) směšný, takže zde musí existovat mechanismus, který by tento předpoklad zrealizoval. Tento mechanismus se nazývá garbage collector. Pojďme se podívat, jak funguje.
Když aplikace volá operátor new k vytvoření nového objektu, nemusí být v řízené hromadě dostatek místa. Hromada to detekuje tak, že přičte velikost nového objektu k ukazateli NextObjPtr. Pokud pak ukazuje za hranici hromady, znamená to, že je plná, a musí se spustit proces čištění (collection).
Ve skutečnosti se proces čištění spouští, když je generace 0 úplně plná. Ve zkratce, generace je mechanismus implementovaný GC ke zlepšení výkonu. Myšlenka je taková, že nově vytvořené objekty se stanou členy mladé generace a starší objekty vytvořené dříve patří do staré generace. Rozdělení objektů do generací dovoluje GC spustit proces čištění v určité generaci místo všech objektů v řízené hromadě.
Garbage Collection algoritmy
GC prověřuje, zda-li v hromadě není jiný objekt, který by byl déle nepoužívaný žádnou aplikací. Když takový objekt existuje, pak je paměť používaná tímto objektem uvolněna (když není na hromadě více paměti, operátor new vygeneruje výjimku OutOfMemoryException). Jak ale může GC vědět, jestli aplikace objekt používá, nebo ne? Jak si jistě dokážete představit, odpověď není lehká.
Každá aplikace má kolekci rootů (roots). Rooty označují oblasti paměti, které ukazují na objekty v řízené hromadě nebo jsou nastaveny na hodnotu null. Navíc je každý ukazatel na lokální objekt (např. parametr) také součástí rootu aplikace. Dokonce každý CPU registr obsahující ukazatel na objekt z řízené hromady je také součástí rootu aplikace. Seznam aktivních rootů je spravován JIT kompilátorem a CLR, samozřejmě je také přístupný pro algoritmy GC.
Když se GC spouští, vytvoří si předpoklad, že všechny objekty na řízené hromadě jsou smetí. Jinými slovy, předpokládá, že žádná root aplikace neukazuje na žádný objekt v hromadě. Nyní GC začne procházet rooty a sestavuje si diagram o všech objektech dosažitelných z rootů. Například může lokalizovat globální proměnnou, která ukazuje na objekt z řízené hromady. Obrázek níže zobrazuje hromadu s několika alokovanými objekty, kde rooty aplikace ukazují pouze na objekty A, C, D a F. Všechny tyto objekty se staly součástí diagramu. Když vznikne objekt D, collector si zapamatuje, že tento objekt ukazuje na objekt H, a objekt H je přidán do diagramu. Collector pak pokračuje v rekurzivním procházením všech dosažitelných objektů.
Jakmile je část tohoto diagramu kompletní, GC zkontroluje další root a prochází objekty znovu. Prochází objekt za objektem. Když se pokusí přidat do diagramu již přidaný objekt, jeho cesta kolekcí se zastaví. Toto slouží ke dvěma účelům. Zaprvé, významně to zvyšuje výkonnost, protože GC nemusí procházet přes celou kolekci více než jednou. Zadruhé, zabraňuje to vzniku nekonečné smyčky z důvodu možnosti výskytu kruhově propojených ukazatelů na objekty.
Po prověření všech rootů GC diagram obsahuje seznam všech objektů, které jsou z aplikace dostupné, takže jakýkoliv objekt, který není v diagramu, není z aplikace dostupný. Jsou pak považovány za smetí. Nyní garbage collector lineárně prochází celou řízenou hromadou, vyhledává jednotlivé bloky nepotřebných objektů (nyní považované za volné místo). Garbage collector pak posunuje v paměti používané objekty směrem dolů (používá standardní funkci memcpy, kterou jistě znáte), odstraňuje všechny již nepotřebné objekty. Samozřejmě že přesouvání objektů v paměti znehodnotí všechny ukazatele. Takže garbage collector musí změnit všechny rooty aplikace na správné adresy v novém místě v paměti. Navíc mnoho objektů obsahuje reference na jiné objekty, takže i ty musí garbage collector upravit. Obrázek ukazuje, jak vypadá řízená hromada po procesu čištění.
Nyní je ukazatel NextObjPtr nastaven za poslední objekt. V této chvíli se operátor new zkusí zavolat unovu.
Jak můžete vidět, GC způsobuje docela hodně velké zatížení systému, což je hlavní nevýhoda při používání řízené hromady. Ale mějte na paměti, že garbage collector se spustí, pouze když se hromada zaplní, do té chvíle je rychlejší než C-runtime hromada. Samotný garbage collector podporuje několik optimalizačních mechanismů, které významně zlepší jeho výkonnost.
Mějte na paměti, že garbage collector se spustí, pouze když se hromada zaplní, do té chvíle je rychlejší než C-runtime hromada.
Je zde několik věcí, které byste si nyní měli zapamatovat. Již nikdy nemusíte implementovat žádný kód pro správu paměti. A vzpomeňte si na chyby, které byly probírány dříve – již neexistují. Zaprvé, není způsob, jak by mohlo dojít k propustnosti objektů, protože jakýkoliv objekt nedostupný z aplikačních rootů může být kdykoliv odstraněn. Zadruhé, není možné se dostat k objektu po tom, co byl odstraněn, protože zdroj není možné odstranit, dokud je z aplikace dostupný.
Když je ale GC tak skvělý, proč nemůže fungovat v ANSI C++? Důvod je ten, že garbage collector musí identifikovat aplikační rooty a musí zde nalézt veškeré ukazatele. V tom je problém, protože C++ umožňuje přetypování ukazatele z jednoho typu na druhý a není zde žádná cesta, jak zjistit, který ukazatel kam ukazuje. V CLR řízená hromada zná vždy typ objektu a používá metadata k ujištění, které členy ukazují na jiný objekt.
Finalizace
Garbage Collector dále poskytuje možnost finalizace, což je způsob, jak poskytnout objektu prostor po sobě uklidit.
Zde něco málo o tom, jak finalizace probíhá: když garbage collector detekuje, že objekt je nepotřebný, zavolá metodu tohoto objektu zvanou Finalize (jestliže ji implementuje) a paměť používaní objektem je vyčištěna. Pro příklad uvažujme, že máme následující třídu:
public class BaseObj {
public BaseObj() {
}
protected override void Finalize() {
// Zde je kód pro čistění...
// Například: uzavřít soubor nebo připojení...
Console.WriteLine("V metodě Finalize");
}
}
Nyní vytvoříme instanci této třídy:
BaseObj bo = new BaseObj();
Někdy v budoucnu garbage collector bude detekovat, že se tento objekt stal nepotřebným. Když se to stane, podívá se, zdali implementuje metodu Finalize, a zavolá ji. V našem případě se pak vypíše text V metodě Finalize a paměť objektu se vyčistí.
Mnoho vývojářů pracujících s C++ si ihned představí spojení mezi destruktorem a metodou Finalize. Proto je nutné si zapamatovat, že finalizace objektu a destruktory jsou úplně rozdílné, a pokud přemýšlíte o finalizaci, bude lepší zapomenout na vše, co víte o destruktorech.
Je nutné si zapamatovat, že finalizace objektu a destruktory jsou úplně rozdílné, a pokud přemýšlíte o finalizaci, bude lepší zapomenout na vše, co víte o destruktorech.
Když vytváříte třídu, bude nejlepší vůbec nepoužívat metodu Finalize. Zde je několik důvodů proč:
- Objekty implementující metodu Finalize mají tendenci podporovat starší generace, což zabraňuje rychlejšímu uvolnění paměti, a objekt pak zůstává v paměti mnohem déle.
- Alokace trvá delší dobu.
- Metoda Finalize musí být volána u každého objektu, který ji implementuje. Pokud máte např. pole čítající 10 000 objektů, metoda bude volána 10 000krát. To má za následek velký výkonnostní deficit.
- Finalizovatelné objekty mohou odkazovat na jiné (nefinalizovatelné) objekty, a tím neúměrně prodlužují dobu jejich existence v paměti.
- Nemáte kontrolu nad tím, kdy přesně bude metoda Finalize spuštěna.
Pokud se již rozhodnete pro implementaci metody Finalize, ujistěte se, že kód v ní obsažený je maximálně rychlý. Vyhnete se tak blokování metody spolu se synchronizací vláken apod. Dále je třeba si uvědomit, že pokud vyvoláte v metodě Finalize výjimku, systém bude předpokládat, že metoda je ukončena, a pokračuje voláním jiných metod.
Když kompilátor generuje kód konstruktoru, automaticky do něj vkládá volání konstruktoru základní třídy. Podobná situace nastane, když C++ kompilátor generuje kód destruktoru, kde také vloží volání destruktoru základní třídy. Jak jsem již řekl dříve, metody Finalize jsou odlišné od destruktorů. Kompilátor nemá žádné speciální informace o metodě Finalize, takže kompilátor nevkládá volání metody Finalize základní třídy. Pokud to vyžadujete, musíte volat explicitně metodu Finalize sami:
public class BaseObj {
public BaseObj() {
}
protected override void Finalize() {
Console.WriteLine("V metodě Finalize.");
base.Finalize(); // Volání Finalize základní třídy
}
}
Zapamatujte si, že byste metodu Finalize základní třídy měli volat jako poslední v metodě Finalize potomka základní třídy. To způsobí, že objekt bude existovat tak dlouho, jak to bude možné. Pokud voláte metodu Finalize častěji, syntaxe C# vám usnadní práci. Kód v C#
class MyObject {
~MyObject() {
•••
}
}
způsobí, že kompilátor vygeneruje tento kód:
class MyObject {
protected override void Finalize() {
•••
base.Finalize();
}
}
Znamená to, že syntaxe pro definici destruktoru je v C# a C++ stejná.
Jak funguje finalizace interně
Na první pohled vypadá finalizace přímočaře: vytvoříte objekt a když je objekt odstraněn, metoda Finalize tohoto objektu je volána. Tak jednoduché to však není.
Když aplikace vytvoří nový objekt, operátor new alokuje paměť na hromadě. Jestliže třída objektu implementuje metodu Finalize, pak je ukazatel na tento objekt umístěn do finalizační fronty. Finalizační fronta je interní datová struktura kontrolovaná garbage collectorem. Každá položka ve frontě odkazuje na objekt obsahující metodu Finalize.
Obrázek níže zobrazuje hromadu obsahující několik objektů. Některé objekty jsou dosažitelné přes aplikační rooty, některé ne. Když jsou objekty C, E, F, I a J vytvořeny, systém detekuje, že tyto objekty obsahují metodu Finalize, a ukazatele na tyto objekty jsou vloženy do finalizační fronty.
Když se GC spustí, objekty B, E, G, H, I a J jsou označeny za odpad. Garbage collector projde finalizační frontu a hledá ukazatele na tyto objekty. Jestliže ukazatel najde, odstraní ho z finalizační fronty a přiřadí ho do uvolňovací fronty. Uvolňovací fronta je další interní datová struktura kontrolovaná garbage collectorem. Každá položka v uvolňovací frontě odkazuje na objekt, který je připraven volat metodu Finalize.
Po čistění vypadá hromada jako na obrázku níže. Zde vydíte paměť obsazenou objekty B, G. Objekt H byl odstraněn, protože neobsahoval metodu Finalize. Dále tedy paměť obsahuje objekty E, I. Objekt J nebyl doposud odstraněn, protože jeho metoda Finalize nebyla volána.
Existuje speciální vlákno určené pouze k volání Finalize metod. Jestliže je uvolňovací fronta prázdná, vlákno je zastaveno. Když ale do fronty přibude nová položka, vlákno je spuštěno, odstraní záznamy z uvolňovací fronty a volá metody Finalize. Z toho důvodu byste neměli do finalizační metody umisťovat žádný kód pro manipulaci s vlákny.
Oživení objektů
Celý koncept finalizace je fascinující. Je zde ale víc věcí, které bych chtěl popsat. Jak jste poznali, v předchozí sekci, když aplikace již nepřistupuje k živému objektu, garbage collector ho považuje za mrtvý. Pokud však objekt vyžaduje finalizaci, je považován za živý, dokud není finalizován. Pak je definitivně mrtev. Jinými slovy, objekt požadující finalizaci umře, žije a pak umře znovu. Jedná se o velmi zajímavý fenomén zvaný oživení. Oživení, jak již název napovídá, dovoluje objektu se „oživit“.
Takže jsem popsal podobu oživení. Když garbage collector vloží ukazatel na objekt do uvolňovací fronty, objekt je dosažitelný z rootu a je živý. Eventuálně je volána metoda Finalize, žádný root neukazuje na objekt – objekt je navždy mrtev. Ale co když metoda Finalize spustí kód, který vloží ukazatel do objektu v globální či statické proměnné?
public class BaseObj {
protected override void Finalize() {
Application.ObjHolder = this;
}
}
class Application {
static public Object ObjHolder; // Defaultně na null
•••
}
Když se tato metoda spustí, ukazatel na objekt je uložen do aplikačních rootů a objekt se stane dosažitelným. Objekt je nyní oživen a garbage collector ho nebude považovat za smetí. Aplikace může nyní zcela volně manipulovat s objektem, ale je třeba si uvědomit, že byl již finalizován. Také si zapamatujte: jestliže BaseObj obsahuje člen odkazující na jiný objekt (přímo či nepřímo), všechny objekty budou oživeny, protože jsou dosažitelné z aplikačních rootů. Dávejte si ale pozor na to, že některé objekty již mohly být finalizovány. Ve skutečnosti, když navrhujete svou vlastní třídu, objekty z této třídy mohou být finalizovány nebo oživeny totálně mimo vaši kontrolu. Implementujte svůj kód tak, aby to vyřešil co nejelegantněji. Často to znamená, že objekt obsahuje přiznak typu Boolean indikující, zdali byl objekt finalizován, či nikoliv. Když je pak volána metoda tohoto již finalizovaného objektu, můžete vyvolat výjimku. Nyní, když jiná část aplikace nastaví Application.ObjHolder na null, je objekt nedosažitelný. Garbage collector pak považuje objekt za smetí a odstraní ho. To už ale nebude volána metoda Finalize, protože neexistuje žádný ukazatel na tento objekt v uvolňovací frontě.
Doporučuji se vyvarovat použití oživení, jak je to jen možné. Když už ale lidé oživení používají, obvykle chtějí, aby jejich objekt po sobě uklidil pokaždé, když „umírá“. Tohoto lze docílit voláním metody ReRegisterForFinalize třídy GC, která vyžaduje jeden parametr: ukazatel na objekt.
public class BaseObj {
protected override void Finalize() {
Application.ObjHolder = this;
GC.ReRegisterForFinalize(this);
}
}
Metoda Finalize volá metodu ReRegisterForFinalize, která přidá adresu specifického objektu (this) na konec finalizační fronty. Když pak garbage collector detekuje, že je objekt nedosažitelný, díky novému záznamu ve finalizační frontě může volat metodu Finalize. Výše uvedený příklad ukazuje, jak vytvořit objekt, který se permanentně oživuje a nikdy nezemře.
Ujistěte se, že metodu ReRegisterForFinalize voláte jen jednou za oživení. Kdyby byla volána vícekrát, docházelo by k opakovanému volání stejné metody Finalize.
Nutíme objekt po sobě uklidit
Jestli chcete, můžete definovat objekt, který nebude vyžadovat žádné čistění. Bohužel, u mnoho objektů to není možné. Pro tyto objekty musíte implementovat metodu Finalize. Je také doporučeno implementovat metodu, která dovolí uživateli objektu explicitně odstranit objekt, kdy chce. Konvencí je, že by se tyto metody měly nazývat Close nebo Dispose.
Metodu Close můžete používat hlavně u objektů k uzavření například souboru. Na druhou stranu, metodu Dispose můžete použít, když daný objekt již nebudete používat. Například když chcete odstranit objekt System.Drawing.Brush, zavoláte jeho metodu Dispose. Pak již objekt nemůže být používán. Volání jeho metody nebo manipulace s ním pak vyvolá výjimku. Když chcete pracovat s dalším objektem typu Brush, musíte volat konstruktor operátorem new.
Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework
http://msdn.microsoft.com/msdnmag/issues/1100/GCI/