Podstawy Garbage Collection
W środowisku .NET Garbage Collector działa jako automatyczny manager pamięci. Pozwala on nam programować bez ręcznego alokowania i zwalniania pamięci, jak miało to miejsce np. w języku C. Głównymi zadaniami Garbage Collectora są:- umożliwienie programiście pracy bez potrzeby zwalniania przez niego pamięci ręcznie
- zwalnianie pamięci zajmowanej przez obiekty, które już nie są używane
- alokowanie obiektów na stercie pamięci w sposób efektywny (więcej o stercie tutaj [under construction]) - dokonywanie defragmentacji pamięci
- zapewnianie bezpieczeństwa pamięci poprzez dbanie o to, by jeden obiekt nie mógł czytać pamięci zajmowanej przez inny obiekt
Garbage Collection ma miejsce gdy występuje któraś z poniższych okoliczności:
- w systemie zaczyna brakować wolnej pamięci fizycznej. Proces może zostać powiadomiony o takim zdarzeniu przez system operacyjny
- ilość pamięci zajmowana przez obiekty na stercie zarządzanej przekracza dopuszczalny poziom. Wartość ta jest dynamicznie dostosowywana przez Garbage Collector w czasie działania procesu. Więcej o stercie zarządzanej poczytać można poniżej
- ręcznie zawołana zostaje metoda GC.Collect
Sterta zarządzana
Każdy proces ma przypisaną swoją stertę pamięci. Wątki w ramach jednego procesu dzielą się jedną stertą.
Sterta zarządzana, jak każdy rodzaj pamięci, narażona jest na fragmentację. Wyobraźmy sobie, że sterta ma 32 bity. Można by ją wtedy zobrazować jako tablicę z 32-ma polami, w których zapisany może być bit 0 lub 1.
Kiedy tablica jest pusta, możemy zapisać w niej 32 bity danych. To znaczy, że zmieści się do niej obiekt nawet o rozmiarze 32 bity.
Jednak wyobraźmy sobie, że wpisaliśmy do niej cztery obiekty o rozmiarze 8 bitów. W tym momencie pamięć jest całkowicie zajęta.
Jednak po zwolnieniu zasobów drugiego i czwartego obiektu, pozostaniemy z pamięcią, która wolne ma bity 8-15 i 24-31. Mimo, że mamy wolne 16 bitów, nie możemy zapisać do tablicy 16-bitowego obiektu, ponieważ nie ma w niej wolnych szesnastu bitów z rzędu. Jeśli chcemy móc zapisać do niej obiekt 16-bitowy, musimy przesunąć zajętą pamięć na początek tablicy, zwalniając tym samym jej koniec. Wtedy dwa stare, ośmiobitowe obiekty zajmą początek pierwszą połowę tablicy, a w drugiej połowie zmieści się obiekt szesnastobitowy.
Na szczęście nie musimy sami zajmować się tym problemem. Garbage Collector, po każdym zwolnieniu pamięci, sam dba o to, by pamięć została zdefragmentowana.
Algorytm Mark & Sweep
Garbage Collector bazuje na algorytmie Mark & Sweep - a więc "oznacz i zmieć".Algorytm ten bazuje na dwóch fazach:
1) W fazie "mark" oznaczamy te obiekty ze sterty, do których można dojść przez referencję zaczynając w "korzeniu". Wyobraźmy sobie, że mamy zmienną lokalną someObject. Obiekt ten zawiera referencję do otherObject1 oraz do otherObject2. Każdy z nich zawiera swoje własne referencje do kolejnych obiektów, one do swoich i tak dalej... Algorytm działa oczywiście rekurencyjnie.
A zatem dla każdego korzenia:
a) Jeśli nie jest jeszcze oznaczony jako marked - oznaczamy go
b) Dla wszystkich obiektów, do których posiada on referencję rekurencyjnie wołamy algorytm, traktując każdy z obiektów jako korzeń
Korzeniami mogą być:
- pola statyczne
- parametry metod
- zmienne lokalne
- rejestry CPU
a) jeśli jest oznaczony, to kasujemy oznaczenie (by przy kolejnym przejściu algorytmu móc na nowo oznaczać używane obiekty)
b) jeśli nie jest, to znaczy że nie kieruje do niego żadna referencja. Możemy bezpiecznie zwolnić zajmowaną pamięć
Należy to jeszcze wspomnieć o tak zwanych słabych referencjach. Są to referencje do obiektów, które Garbage Collector zignoruje w czasie oznaczania obiektów jako używane lub nie. Jeśli więc do danego obiektu będą istniały tylko słabe referencje, zostanie on sprzątnięty przez Garbage Collector. W C# do tworzenia słabych referencji służy klasa WeakReference. W klasie tej istnieje property "IsAlive" mówiące, czy obiekt do którego kieruje referencja został już sprzątnięty przez Garbage Collector, czy jeszcze nie.
var someObject = new object(); var waekReferenceToObject = new WeakReference(someObject);
Generacje obiektów
Nie da się określić, jak często Garbage Collector "rusza do akcji". Zależy to od wielu czynników: czy mamy wolne zasoby procesora, jak bardzo zajęta jest pamięć itd. Niezależnie od tego, mało optymalnym byłoby, gdyby Garbage Collector za każdym razem sprawdzał każdy obiekt w aplikacji pod kątem tego, czy można bezpiecznie zwolnić jego zasoby.Więcej sensu ma przyjęcie następującego założenia: jeśli obiekt był sprawdzany już kilka razy, i za każdym razem istniały do niego referencje, to najprawdopodobniej jest to obiekt o długim czasie życia. Istnieje spora szansa, że za kolejnym sprawdzeniem znów oznaczymy go jako obiekt z istniejącymi referencjami, i po raz kolejny nie będziemy zwalniać jego zasobów. Lepiej więc nie sprawdzać tego obiektu za każdym razem, gdy pracuje Garbage Collector. Lepiej przyjrzeć mu się co pięć czy dziesięć iteracji.
Bazując na takim założeniu przyjęto, że obiekty w stercie zarządzanej podzielone zostaną na generacje:
- generacja 0 - należą do niej obiekty o krótkim czasie życia. Przykładem może być zmienna lokalna w funkcji. Zaraz po stworzeniu takiego obiektu zostaje on oznaczony jako należący do generacji 0. Istnieje duża szansa, że zostanie on usunięty już za pierwszym przejściem Garbage Collectora. Jeśli jednak tak się nie stanie, obiekt zostanie oznaczony jako należący do generacji 1. Większość obiektów z generacji 0 nie dożywa do "awansu" do generacji 1. Garbage Collector najczęściej zajmuje się tą grupą obiektów.
- generacja 1 - obiekty o średnim czasie życia. Analogicznie jak działo się to w generacji 0 - jeśli "przeżyją" przejścia Garbage Collectora, "awansują" do generacji 2. Garbage Collector zajmuje się tą grupą rzadziej, niż generacją 0
- generacja 2 - obiekty o długim czasie życia - przetrwały już co najmniej dwa przejścia Garbage Collectora. Przykładem mogą być pola statyczne w klasach. Garbage Collector najrzadziej sprawdza, czy w tej grupie można zwolnić zasoby któregoś obiektu
Rozważmy taką sytuację: Garbage Collector przyjął wstepne założenie, że dla generacji 2 przeznaczy tyle pamięci, by zmieściło się w niej 100 obiektów (jest to oczywiście uproszczenie, bo obiekty mają różne rozmiary). Po jakimś czasie Garbage Collector widzi, że w generacji 2 zaczyna kończyć się pamięć - zostało już miejsce tylko na 10 obiektów, zaś w pamięci zapisanych jest już 90. Podejmuje więc decyzję o zastosowaniu wobec tej grupy obiektów algorytmu Mark&Sweep. Okazuje się jednak, że tylko dla pięciu obiektów została zwolniona pamięć. Nie jest to zbyt dobra sytuacja - algorytm Mark&Sweep jest pracochłonny, a udało nam się zrobić miejsce tylko na 5 nowych obiektów. Najprawdopodobniej wkrótce znów zacznie nam brakować miejsca, i po raz kolejny będziemy musieli "odpalić" algorytm, i być może znów nie zwolnimy wiele pamięci...
W takich okolicznościach Garbage Collector może podjąć decyzję o zaalokowaniu większej ilości pamięci dla generacji 2. Ma to sens - skoro udało się zwolnić tylko 5 obiektów, to być może obiekty z tej grupy żyją dłużej, niż nam się wydawało, i nie ma sensu tak często próbować zwalniać zajmowanej przez nie pamięci.
Duże obiekty
Warto tutaj wspomnieć, że Garbage Collector posiada optymalizację dotyczącą dużych obiektów (powyżej 85 00 bajtów - wartość ta może się oczywiście różnić pomiędzy poszczególnymi implementacjami Garbage Collectora). Obiekty takie traktowane są w nieco inny sposób niż mniejsze:- zostają umieszczone na specjalnej stercie dla dużych obiektów - Large Objects Heap. Należy ona do generacji 2
- po zwolnieniu zasobów przez Garbage Collector, obiekty te nie są przesuwane w pamięci - dla tak dużych obiektów jest to zbyt czasochłonna operacja. Mówimy, że są "przypięte" (pinned)
Kiedy w praktyce powinniśmy o tym pamiętać? Przede wszystkich przeprowadzając operacje na dużych stringach. String w C# jest immutable. A zatem za każdym razem, kiedy zmieniamy istniejący string, de facto tworzony jest nowy obiekt. W przypadku dużych stringów i przeprowadzania na nich częstych operacji, możemy tworzyć w krótkim czasie setki nowych, dużych obiektów. Zawsze używajmy StringBuildera, kiedy intensywnie modyfikujemy stringi.
W ten sposób możemy się przekonać, że duże obiekty faktycznie traktowane są inaczej:
class Program { static void Main(string[] args) { byte smallData = new byte(); Console.WriteLine(GC.GetGeneration(smallData)); byte[] bigData = new byte[90000]; Console.WriteLine(GC.GetGeneration(bigData)); Console.ReadKey(); } }
Wynikiem działania tego programu będzie:
0 2
Destruktory
W C# destruktory działają nieco inaczej niż np. w C++. Kiedy programista pisze destruktor, kompilator podmienia go na nadpisanie metody Finalize z klasy Object. Możemy to sobie wyobrazić w ten sposób:Taki kod:
public class SomeClass { ~SomeClass() { Console.WriteLine("Wywołuje się destruktor!"); } }
Zostanie zastąpiony przez:
public class SomeClass { protected override void Finalize() { try { Console.WriteLine("Wywołuje się destruktor!"); } finally { base.Finalize(); } } }
Uwaga: próba ręcznego nadpisania metody Finalize zakończy się błędem kompilacji:
public class SomeClass { protected override void Finalize() //błąd kompilacji { Console.WriteLine("Wywołuje się destruktor!"); } }
Jak widzimy, ciało napisanego przez nas destruktora zostaje owrapowane w blok try-finally. Dzięki temu niezależnie od tego, czy w bloku try zostanie rzucony wyjątek czy nie, zawoła się metoda Finalize z klasy Object.
Czym jest zatem metoda Finalize? Jest to metoda, która zostanie zawołana gdy obiekt będzie czyszczony przez Garbage Collector. Musimy tutaj pamiętać o ważnej rzeczy: nie wiemy, kiedy Garbage Collector będzie "sprzątał" nieużywany obiekt.
Co powinno się znaleźć w destruktorze? Skoro Garbage Collector dba o zwalnianie zarządzanych zasobów, naszym zadaniem jest zwolnienie tych niezarządzanych: połączeń z bazą, strumieni plikowych itd.
Istnieje jednak inna metoda, która służy do zwalniania zasobów, kiedy obiekt przestaje być używany. Metodą tą jest Dispose.
Metoda Dispose i interfejs IDisposable
Jeśli obiekt klasy ma dostęp do zasobów niezarządzanych, lub z innych powodów chcemy po sobie "posprzątać" w momencie gdy obiekt przestanie być używany, powinniśmy skorzystać z interfejsu IDisposable. Kiedy chcemy skorzystać z obiektu implementującego IDisposable, powinniśmy użyć wygodnej składni z użyciem słowa kluczowego using. Przyjrzyjmy się przykładowi użycia interfejsu IDisposable:public interface IFile { string Read(); void Close(); } public class File : IFile { public string Read() { return "Dawno, dawno temu..."; } public void Close() { Console.WriteLine("Zamykam plik."); } } public class FileReader : IDisposable { private IFile _file; public FileReader(IFile file) { _file = file; } public string ReadFile() { return _file.Read(); } public void Dispose() { _file.Close(); } } class Program { static void Main(string[] args) { using (var fileReader = new FileReader(new File())) { Console.WriteLine(fileReader.ReadFile()); } Console.ReadKey(); } }
Wynikiem działania tego programu będzie:
Dawno, dawno temu... Zamykam plik.
Pozostaje nam zadać sobie trudne pytanie: czy powinniśmy korzystać z destruktorów, czy z interfejsu IDisposable. Odpowiedź brzmi: praktycznie zawsze, o ile nie zachodzą bardzo nietypowe okoliczności, powinniśmy korzystać z interfejsu IDisposable. Głównym agrumentem za tym jest fakt, że w przypadku IDisposable sami kontrolujemy, kiedy zasoby zostaną zwolnione. Z metodą Finalize nigdy nie mamy co do tego pewności. Dociekliwym polecam ten wątek na StackOverflow.
W tym poście jako źródła posłużyły mi:
- https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
- https://msdn.microsoft.com/pl-pl/library/garbage-collector-cz-1.aspx
- https://stackoverflow.com/questions/339063/what-is-the-difference-between-using-idisposable-vs-a-destructor-in-c
Komentarze
Prześlij komentarz