Różne środowiska uruchomieniowe, w tym także CLR platformy .NET, wykorzystują mechanizm automatycznego zarządzania pamięcią, określany powszechnie jako "odśmiecanie pamięci" (garbage collection). Podstawowym celem zastosowania garbage collection była chęć uwolnienia dewelopera od obowiązku ciągłego myślenia w kategorii skończoności zasobów o przydziale i zwalnianiu dostępnej pamięci. Wpisuje się to oczywiście w ogólną koncepcję tworzenia oprogramowania wg podejścia RAD (Rapid Application Development). Zgodnie z założeniami, w środowisku .NET alokacją i zwalnianiem pamięci zajmuje się specjalny mechanizm - tzw. garbage collector (GC), a programista powinien skupić się na funkcjonalności aplikacji. Nie oznacza to jednak, że możemy zupełnie zapomnieć o kwestiach dotyczących pamięci. GC funkcjonuje w oparciu o zestaw reguł, na podstawie których podejmuje określone działania. Naturalnie reguły te nie są równie efektywne w każdej z możliwych sytuacji, dlatego właśnie świadomy deweloper powinien nauczyć się tak pisać swój kod, żeby w pośredni sposób wpływać na przewidywalne zachowania garbage collectora.
Chciałbym zwrócić uwagę na kilka kwestii związanych z automatycznym zarządzaniem pamięcią na platformie .NET. Tematyka jest bardzo szeroka i wieloaspektowa, dlatego poniższe informacje mogą stanowić jedynie wstęp do bardziej dogłębnej analizy tematu.
Pamięć w sensie funkcjonalnym podzielić możemy na stos (stack) i stertę zarządzaną (managed heap). Typy bezpośrednie (value types, czyli np. zmienne w metodzie) alokowane są na stosie. GC nie ma z nimi za wiele pracy - po opuszczeniu metody pamięć jest w prosty i natychmiastowy sposób zwalniana. Wszystko komplikuje się w przypadku typów referencyjnych, które alokowane są na stercie. Każde utworzenie nowego obiektu powoduje zarezerwowanie określonego obszaru pamięci na stercie (przesuwany jest także wskaźnik alokacji
NextObjPtr, wskazujący następny dostępny adres). O ile moment tworzenia obiektu jest jednoznaczny i trywialny do określenia, o tyle identyfikacja obiektów już niepotrzebnych to działanie o wiele bardziej złożone. Zadanie to GC realizuje poprzez cykliczne sprawdzanie, które obiekty nie są osiągalne z żadnego zasięgu w programie. Odbywa się to na zasadzie wstępnej negacji - początkowo wszystkie obiekty uznawane są za martwe, następnie GC z listy śmierci usuwane te, które w danym momencie mogą być osiągnięte przez kod aplikacji. Pozostałe obiekty przeznaczone są do unicestwienia. Podczas okresowej aktualizacji sterty GC zwalnia bloki pamięci odnoszące się do martwych obiektów. Dodatkowo scala pozostałe bloki tak, by nie pozostawały między nimi żadne wolne przestrzenie. Wiąże się to oczywiście z koniecznością aktualizacji wskaźnika alokacji i koniecznością przeadresowania wszystkich referencji do obiektów, gdyż po migracji bloków zmianie ulegają adresy pamięci. Operacje wykonywane przez GC są dla wnętrzności naszego programu dość rewolucyjne i kosztowne pod względem wydajności. Podczas działania GC może wystąpić chwilowa niepoprawność referencji, pamięć musi więc pozostawać niedostępna dla aplikacji, co jest realizowane przez wstrzymanie wszystkich jej wątków. Ze względu na opisaną charakterystykę pracy i kosztowność operacji, GC uruchamiany jest zasadniczo dopiero w sytuacji braku pamięci na stercie.
Algorytm garbage collection działa w rzeczywistości w nieco bardziej skomplikowany sposób, uwzględniając m.in. tzw. generacje. Możliwe jest także wymuszone uruchomienie GC z kodu aplikacji (
System.GC.Collect()). Wymienione kwestie, jak też inne szczegóły wykraczają jednak poza zakres tego powierzchownego artykułu - zainteresowanych zapraszam do zapoznania się z linkami zebranymi poniżej.
Trzeba pamiętać, że GC zajmuje się
zwalnianiem pamięci zajmowanej przez martwe obiekty, a
nie zwalnianiem wszystkich zasobów przez te obiekty wykorzystywanych. Sami więc musimy zadbać o zwolnienia zasobów niezarządzanych (plikowych, sieciowych, sprzętowych), np. otwartego połączenia do bazy danych. Nie chodzi tutaj wyłącznie o tzw. finalizację (zaimplementowanie finalizatora klasy), która w wielu sytuacjach może okazać się mało efektywna. Lepszym wyjściem będzie oprogramowanie metod Close() i Dispose(), czy stosowanie bloku using.
Ciekawe możliwości oferują tzw. słabe referencje (weak references). Nie jest to pomysł ani nowy, ani specyficzny wyłącznie dla platformy .NET. Słabe referencje spotykamy również w innych technologiach z mechanizmem typu garbage collection (jak Java czy Perl).
Dla jednoznaczności warto uściślić, że silna referencja do obiektu (strong reference) występuje w sytuacji, kiedy obiekt może być osiągnięty z kodu aplikacji. W sytuacji silnej (zwykłej) referencji obiekt jest więc poza zainteresowanie GC. Słaba referencja natomiast to swoisty kompromis - obiekt jest co prawda dostępny dla aplikacji (która może uzyskać do niego silną referencję), ale w każdym momencie GC może go unicestwić.
Pozornie może się wydawać, że nie ma to wielkiego sensu. Są jednak sytuacje, w których opisany kompromis jest całkiem akceptowalny. Słabe referencje świetnie nadają się do obiektów, które zajmują duży obszar pamięci, ale mogą być łatwo odtworzone. Wprowadza to swoistą elastyczność obsługi do naszej aplikacji. Kiedy zabraknie pamięci, obiekt ze słabą referencją zostanie unicestwiony przez GC i na stercie przybędzie potrzebnej w danym momencie wolnej przestrzeni. Jeżeli obiekt będzie potrzebny ponownie, zostanie w prosty sposób zrekonstruowany. W sytuacji dostatecznej ilości wolnej przestrzeni na stercie, obiekt nie zostanie zniszczony, aplikacja będzie więc go mogła użyć natychmiast po wykorzystaniu słabej referencji.
Słabych referencji nie należy stosować przy małych obiektach jak też nie są one remedium na wszystkie problemy wydajnościowe aplikacji - w każdym razie na pewno nie zastąpią rozsądnego i przemyślanego cacheowania.
Więcej informacji:
MSDN: Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework
MSDN: Garbage Collection—Part 2: Automatic Memory Management in the Microsoft .NET Framework
MSDN: Using Weak References