Zużycie pamięci to jedna z głównych przyczyn spadków wydajności aplikacji .NET. W większości przypadków problem nie wynika z samego języka, lecz z nieświadomego tworzenia obiektów i niekontrolowanych referencji. C# korzysta z automatycznego Garbage Collectora, ale to nie zwalnia programisty z odpowiedzialności. Dobrze napisana aplikacja może zużywać nawet 3–5 razy mniej RAM bez zmiany funkcjonalności.
Jak działa zarządzanie pamięcią w .NET
Środowisko CLR przydziela pamięć na stercie zarządzanej. Obiekty trafiają do generacji 0, 1 lub 2. Najczęściej tworzone elementy trafiają do Gen0 i są czyszczone bardzo szybko. Problem zaczyna się, gdy obiekty „żyją” za długo i przechodzą do Gen2, gdzie odśmiecanie jest znacznie kosztowniejsze.
Generacje pamięci i ich znaczenie
Gen0 obejmuje obiekty krótkotrwałe, np. wyniki metod.
Gen1 zawiera obiekty o średnim czasie życia.
Gen2 przechowuje duże lub długo istniejące dane.
Im więcej danych w Gen2, tym większe pauzy aplikacji. W systemach webowych potrafi to powodować opóźnienia sięgające setek milisekund.
Unikanie nadmiernej alokacji obiektów
Najczęstszy błąd polega na tworzeniu nowych obiektów w każdej iteracji pętli. Dotyczy to szczególnie łańcuchów znaków. Każde takie utworzenie generuje nową alokację pamięci.
Zamiast tworzyć nowe wartości tekstowe, należy używać mechanizmu buforowania znaków — czyli jednego obiektu, który stopniowo buduje wynik. Dzięki temu liczba alokacji spada z tysięcy do pojedynczych operacji.
IDisposable i zwalnianie zasobów niezarządzanych
Garbage Collector usuwa tylko pamięć zarządzaną. Nie zwalnia uchwytów systemowych takich jak pliki, połączenia sieciowe czy połączenia z bazą danych.
Dlatego obiekty korzystające z zasobów systemowych muszą być zamykane ręcznie. Najbezpieczniej stosować blok kontekstowy, który gwarantuje zamknięcie zasobu natychmiast po zakończeniu pracy.
Brak zwolnienia zasobu powoduje wycieki pamięci widoczne mimo poprawnego działania GC.
Unikanie wycieków pamięci przez zdarzenia
Częstym problemem jest sytuacja, gdy jeden obiekt zapisuje się na zdarzenie drugiego. Jeśli obiekt nasłuchujący powinien zostać usunięty, ale źródło zdarzenia nadal istnieje, pamięć nie zostanie zwolniona.
Rozwiązaniem jest ręczne wypisanie się ze zdarzenia lub zastosowanie mechanizmu słabych referencji. Bez tego aplikacja stopniowo zwiększa zużycie RAM.
Struktury zamiast klas (tam gdzie ma sens)
Struktury przechowywane są bezpośrednio w pamięci stosu i nie podlegają pracy Garbage Collectora. Sprawdzają się w przypadku małych, niezmiennych danych tworzonych bardzo często, jak współrzędne czy kolory.
Nie należy jednak stosować ich dla dużych obiektów, ponieważ ich kopiowanie może być kosztowniejsze niż alokacja klasy.
Buforowanie i pooling obiektów
Tworzenie kosztownych obiektów za każdym razem zwiększa obciążenie GC. Lepszym rozwiązaniem jest ich ponowne używanie. W .NET istnieje mechanizm współdzielonej puli buforów, który pozwala wypożyczyć fragment pamięci, użyć go, a następnie oddać do ponownego wykorzystania.
W aplikacjach sieciowych zmniejsza to liczbę alokacji nawet o 80%.
Monitorowanie pamięci
Do analizy zużycia pamięci wykorzystuje się narzędzia diagnostyczne platformy .NET oraz profilery pamięci. Najważniejsze wskaźniki to liczba pełnych czyszczeń GC oraz liczba alokacji dużych obiektów.
Duże obiekty i Large Object Heap
Obiekty przekraczające około 85 KB trafiają do specjalnego obszaru pamięci. Jest on rzadziej porządkowany i podatny na fragmentację.
Aby ograniczyć problem, należy dzielić duże dane na mniejsze fragmenty, stosować strumieniowanie oraz ponowne wykorzystanie buforów.
Efektywne zarządzanie pamięcią w C# polega nie na ręcznym usuwaniu obiektów, lecz na kontrolowaniu ich cyklu życia. Największe korzyści przynosi ograniczenie alokacji, poprawne zamykanie zasobów i unikanie zbędnych referencji. W praktyce pozwala to znacząco zmniejszyć zużycie pamięci i wyeliminować nagłe zatrzymania aplikacji bez zmiany algorytmów.