Jeśli miał ktoś do czynienia np. z CPP z pewnością kojarzy pojęcie destruktora. Jest to metoda, wywoływana w momencie zwalniania obiektu z pamięci (przeciwieństwo konstruktora). Zarówno w CPP jak w C#, nazwa destruktora stanowi ‘~’ plus nazwa klasy. Przykład:
class MyClass
{
MyClass(){}// standardowy konstruktor
~ MyClass()
{
// obiekt zwalniany z pamieci
}
}
Jednak sam sposób działania destruktorów w C# jest całkowicie inny od CPP. Przede wszystkim destruktor to nic innego jak metoda Finalize:
protected override void Finalize()
{
try
{
// ciało destruktora
}
finally
{
base.Finalize();
}
}
Na poziomie kompilacji, generowana jest metoda Finialize (zamiast destruktora). Generalnie Finalize to wirtualna metoda typu protected, w której możemy umieścić wszelkie zwalnianie zasobów (głównie niezarządzanych). Czy użyjemy składni destruktora czy bezpośrednio przeładujemy Finalize nie ma to żadnego znaczenia.
Wszystko byłoby fajne, mamy w końcu miejsce w którym możemy zwalniać zasoby. Klasy z destruktorem\Finalize są jednak DUŻO trudniejsze do usunięcia z pamięci ponieważ:
-
Gdy seria obiektów nie ma Finalize wtedy GC po prostu czyści pamięci od początkowego adresu aż do końca. W przypadku Finalize musi po kolei wywoływać Finalize i zwalniać pamięć – nie da się tego zrobić w jednym batch’u.
-
Istnieje duża szansa, że obiekt z Finalize dostanie promocję do GEN1 lub nawet GEN2 (o generacjach pisałem w poprzednich postach ). Generalnie zwolnienie pamięci z GEN1 jest dużo wolniejsze niż z GEN0. A dlaczego to może się zdarzyć?
Przede wszystkim GC oprócz listy obiektów zawiera również specjalną kolejkę (finalization queue), która zawiera wskaźniki do obiektów zawierających metodę finalize. Załóżmy, że wywołana jest metoda GC.Collect. GC sprawdza, które obiekty są nieosiągalne. Załóżmy również, że obiekt zawierający Finalize jest teraz nieosiągalny. GC przeszukuje finalization queue i dodaje dany obiekt do następnej kolejki nazwanej freachable queue (finalization reacheable). Jeśli zwalniany obiekt nie znajdowałby się w finalization queue (ponieważ nie ma destruktora) wtedy po prostu standardowo zostałby zwolniony. W tym przypadku został jednak przeniesiony do freachable queue i z tego względu z powrotem jest widziany jako obiekt osiągalny – nie został w końcu jeszcze zwolniony. I to jest właśnie moment, kiedy GC może ponownie przeszukać GEN0 i awansować obiekt do następnej generacji – ponieważ obiekty w freachable queue traktowane są jako osiągalne.
Zatem domyślnie wszystkie obiekty z destruktorem (a właściwie ich wskaźniki) umieszczane są w finalization queue. Jeśli staną się one nieosiągalne wtedy przesuwamy je do freachable queue i w tym momencie znów widziane są jako osiągalne. Co dalej? Do dyspozycji jest osobny wątek, który wywołuje metodę Finalize po kolei dla wszystkich obiektów w freachable queue. Po wywołaniu Finalize, obiekt może zostać zwolniony już całkowicie z pamięci. Ale to pokazuje, ze potrzebujemy przynajmniej dwóch GC.Collect aby usunąć obiekt z pamięci: najpierw aby przenieść go do freachable queue , potem czekamy aż osobny wątek wywoła Finalize a dopiero wtedy, następna kolekcja (GC.Collect) zwolni obiekt całkowicie. Ponadto bardzo jest prawdopodobne, że w międzyczasie obiekt zostanie awansowany do następnej generacji, co spowoduje znaczący spadek wydajności.
Umieszczenie destruktora powoduje zatem dość istotny spadek wydajności. Dużo lepszą praktyką jest użycie interfejsu IDisposable o którym pisałem tutaj. W następny poście napiszemy kotiki programik, który pokaże, że faktycznie destruktory powodują problemy.