Blog Dla Programistów C#/.NET

Zarządzanie pamięcią w .NET: jak działa Garbage Collector i jak pisać kod, który go nie męczy

czwartek, 16 lipca 2026 Tagi: C#/.NETProgramowanie

W .NET pamięcią zarządza za Ciebie Garbage Collector (GC). Cyklicznie odnajduje obiekty, których już nie używasz, i zwalnia zajmowaną przez nie pamięć. Brzmi jak wygoda, o której nie trzeba myśleć, i przez większość czasu faktycznie tak jest.

Problem pojawia się dopiero wtedy, gdy aplikacja musi działać naprawdę szybko: przy dużym ruchu, w gorących ścieżkach kodu czy w usługach serwerowych. Nieświadome tworzenie tysięcy krótkotrwałych obiektów potrafi zamienić się w częste pauzy na sprzątanie, skoki zużycia CPU i nieprzewidywalne opóźnienia. A to już bezpośrednio uderza w wydajność i skalowalność.

W tym artykule wyjaśniam, jak działa GC w .NET - tryby pracy, generacje i Large Object Heap, a następnie pokazuję konkretne, poparte kodem techniki, dzięki którym ograniczysz liczbę alokacji i odciążysz kolektor. To wiedza przydatna zarówno dla programisty piszącego gorący fragment kodu, jak i dla lidera technicznego, który chce rozumieć, jak decyzje projektowe przekładają się na zużycie zasobów.

Zarządzanie pamięcią w .NET: jak działa Garbage Collector i jak pisać kod, który go nie męczy

Jak działa GC w .NET


Generacyjny GC

.NET używa generacyjnego garbage collectora, co oznacza, że dzieli zarządzaną stertę na 3 generacje obiektów: 0, 1 i 2.

Nowo utworzone obiekty trafiają do generacji 0 - przeznaczonej dla obiektów krótkotrwałych i sprzątanej najczęściej. Większość obiektów zostaje zwolniona już podczas kolekcji gen0 i nigdy nie "awansuje" wyżej. Jeśli obiekt przetrwa kilka kolekcji, zostaje promowany do generacji 1, a potem do generacji 2, w której lądują obiekty najdłużej żyjące.

Sens tego podziału jest prosty: dzięki generacjom GC może posprzątać tylko fragment sterty (najczęściej samo gen0) zamiast za każdym razem przeczesywać całą pamięć. To właśnie sprawia, że typowe odśmiecanie krótkotrwałych obiektów jest szybkie. Pełna kolekcja generacji 2, obejmująca całą stertę, jest znacznie kosztowniejsza i uruchamiana dopiero wtedy, gdy sprzątnięcie młodszych generacji nie wystarcza.

Large Object Heap (LOH)


Szczególnym przypadkiem są duże obiekty. Wszystko, co przekracza ok. 85 000 bajtów, trafia na wydzieloną stertę dużych obiektów - Large Object Heap. LOH bywa nieformalnie nazywany "generacją 3", choć technicznie zbierany jest razem z generacją 2 podczas pełnego odśmiecania.

Kluczowy szczegół: ze względów wydajnościowych GC domyślnie nie kompaktuje LOH, nie chce kopiować w pamięci wielkich bloków danych. Duże obiekty nie są więc przesuwane, a to sprzyja fragmentacji: po wielu alokacjach i zwolnieniach dużych segmentów w pamięci zostają "dziury", których nie da się łatwo wypełnić. .NET pozwala wymusić kompaktowanie LOH na żądanie, ale domyślnie jest ono wyłączone.

Wniosek praktyczny: ograniczaj tworzenie dużych obiektów, a tam, gdzie się nie da - reużywaj duże bufory zamiast alokować je wciąż od nowa (o tym niżej).

Tryby pracy GC: Workstation vs Server


Środowisko uruchomieniowe CLR oferuje 2 tryby pracy GC, dobierane do charakteru aplikacji:

Workstation GC - domyślny dla aplikacji desktopowych i większości procesów uruchamianych lokalnie. Kolekcja działa na wątku aplikacji, który ją wywołał, z normalnym priorytetem, więc musi konkurować o CPU z resztą kodu. Taki model stawia na responsywność (istotną np. w aplikacjach z GUI) kosztem pełnego wykorzystania rdzeni.

Server GC - przeznaczony dla aplikacji serwerowych i wysokoprzepustowych, takich jak ASP.NET. Uruchamia osobny, wysokopriorytetowy wątek odśmiecania na każdym logicznym procesorze i tworzy dla każdego z nich osobną stertę (własne SOH i LOH). Dzięki temu kolekcje przebiegają równolegle i znacznie szybciej na maszynach wielordzeniowych.

Server GC ma jednak swoją cenę - zużywa więcej pamięci i CPU. Gdy na jednej maszynie działa wiele instancji aplikacji, może dojść do sytuacji, w której ich wątki GC wzajemnie się blokują (np. 12 procesów po jednym wątku GC na rdzeń, walczących o 4 fizyczne CPU). W takich środowiskach czasem lepiej zostać przy Workstation GC.

W obu trybach dostępny jest background GC - kolekcje generacji 2 mogą wtedy odbywać się w tle, na osobnym wątku, minimalizując pauzy typu stop-the-world.

Tryb ustawisz w pliku projektu:

<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

Co uruchamia GC?

Kolektor sam decyduje, kiedy posprzątać stertę. Najczęstsze wyzwalacze to: mało dostępnej pamięci RAM, przekroczenie wewnętrznego progu rozmiaru sterty dla danej generacji oraz jawne wywołanie GC.Collect() (odradzane w zwykłym kodzie, kolektor niemal zawsze wie lepiej).

Zależność jest prosta: im więcej i im szybciej alokujesz, tym więcej pracy ma GC. Wysokie tempo alokacji (tzw. duża presja na GC) oznacza częstsze pauzy, większe zużycie CPU, a przez fragmentację nierzadko także wyższe zużycie pamięci. Dlatego najskuteczniejsza optymalizacja to zwyczajnie alokować mniej.

Zanim zaczniesz optymalizować: zmierz


Zanim przepiszesz połowę kodu na Span<T>, jedna zasada: najpierw zmierz, potem optymalizuj. Alokacje to problem tylko tam, gdzie realnie kosztują. Zwykle w gorących ścieżkach wykonywanych tysiące razy na sekundę. W kodzie uruchamianym raz na starcie aplikacji walka o pojedyncze alokacje to strata czasu (i czytelności).

Przydatne narzędzia:
BenchmarkDotNet - mikrobenchmarki z diagnostyką [MemoryDiagnoser], która pokazuje liczbę bajtów i kolekcji GC per operacja.
dotnet-counters - podgląd metryk GC działającej aplikacji na żywo (tempo alokacji, liczba kolekcji per generacja, rozmiar sterty).
dotnet-trace / profiler w Visual Studio - do znalezienia miejsc, które faktycznie generują najwięcej śmieci.

Dopiero z danymi w ręku warto sięgać po poniższe techniki.

Praktyczne techniki ograniczania alokacji


1. Unikaj zbędnych kopii - sięgaj po Span<T>

Span<T> pozwala operować na fragmentach tablic, bajtów, a nawet stringów bez dodatkowych alokacji. Zamiast tworzyć nowy wycinek, tworzysz "okno" na istniejący blok pamięci.

Klasyczny przykład to Substring(), który zawsze alokuje nowy string na stercie:

/* Alokuje nowy string przy każdym wywołaniu */
string domena = email.Substring(email.IndexOf('@') + 1);

/* Zero alokacji - tylko widok na istniejący string */
ReadOnlySpan<char> domena = email.AsSpan(email.IndexOf('@') + 1);

Dla małych, tymczasowych buforów świetnie sprawdza się też stackalloc, który alokuje pamięć na stosie, całkowicie omijając GC:

Span<byte> bufor = stackalloc byte[256]; /* na stosie, poza zasięgiem GC */
/* ... praca na buforze bez ani jednej alokacji na stercie */

W gorących sekcjach kodu Span/Memory zamiast kopiowania danych to zwykle czysty zysk wydajnościowy.

2. Reużywaj obiekty - pule i buforowanie

Tworzenie i porzucanie wielu obiektów tego samego typu to prosta droga do wysokiej presji na GC. Zamiast tego - wypożyczaj i zwracaj.

.NET udostępnia gotową pulę tablic przez ArrayPool<T>. Zamiast alokować nową tablicę na każde żądanie, wypożyczasz ją z puli i zwracasz po użyciu:

byte[] bufor = ArrayPool<byte>.Shared.Rent(1024);
try
{
/* Uwaga: wypożyczona tablica może być WIĘKSZA niż 1024 */
/* korzystaj tylko z tylu bajtów, ile faktycznie potrzebujesz. */
int odczytano = strumien.Read(bufor, 0, 1024);
/* ... */
}
finally
{
ArrayPool<byte>.Shared.Return(bufor);
}

W aplikacjach o dużym ruchu sieciowym lub intensywnym I/O potrafi to drastycznie obniżyć częstotliwość odśmiecania. W typowych scenariuszach przejście z alokacji "na żądanie" na pulowanie potrafi zredukować liczbę kolekcji GC o kilkadziesiąt procent i wygładzić skoki opóźnień.

Analogicznie działa Microsoft.Extensions.ObjectPool dla kosztownych w utworzeniu obiektów (parsery, bufory, StringBuilder). Jeden warunek: zawsze resetuj stan obiektu przed zwróceniem do puli, żeby nie przenosić "brudnych" danych między użyciami. Reużywanie dużych struktur ma bonus - ogranicza fragmentację LOH, bo przestajesz nieustannie przydzielać i zwalniać wielkie segmenty.

3. Używaj struct dla małych, często używanych danych

Typy wartościowe (struct) zwykle żyją na stosie albo wewnątrz innych obiektów, a nie na zarządzanej stercie, więc nie obciążają bezpośrednio GC. Dla drobnych, niezmiennych danych to potrafi wyeliminować mnóstwo krótkotrwałych alokacji:

/* Klasa: każdy punkt to osobny obiekt na stercie */
public class PunktClass { public double X; public double Y; }

/* Struktura: żyje na stosie lub w tablicy - bez presji na GC */
public readonly struct Punkt
{
public Punkt(double x, double y) => (X, Y) = (x, y);
public double X { get; }
public double Y { get; }
}

Tablica miliona Punkt to jeden ciągły blok pamięci, zero miliona osobnych obiektów do posprzątania. Ale uważaj na 2 pułapki:
Duże struktury (kilkadziesiąt bajtów i więcej) są kosztowne w kopiowaniu, wtedy przewaga znika.
Boxing (rzutowanie struktury na object lub interfejs) tworzy obiekt na stercie i niweczy cały zysk.

4. Uważaj na ukryte alokacje w "wygodnym" kodzie

Najczęstsze śmieci to te, których nie widać wprost. Wygodny, czytelny kod potrafi pod spodem generować sporo obiektów tymczasowych.

LINQ alokuje delegaty, iteratory i kolekcje, a każde ToList() tworzy nową listę:

/* Alokuje delegat, iterator i nową listę */
var wynik = kolekcja.Where(x => x.Aktywny).Select(x => x.Id).ToList();

/* W gorącej ścieżce: prosta pętla, zero pośrednich alokacji */
var wynik = new List<int>();
foreach (var x in kolekcja)
if (x.Aktywny)
wynik.Add(x.Id);


Konkatenacja stringów w pętli tworzy nowy string przy każdej iteracji (stringi są niezmienne):

/* Każda iteracja alokuje nowy string */
string s = "";
foreach (var cz in czesci) s += cz;

/* Jeden bufor, rośnie w miarę potrzeb */
var sb = new StringBuilder();
foreach (var cz in czesci) sb.Append(cz);
string s = sb.ToString();

Dochodzą do tego domknięcia (closures) - lambda przechwytująca zmienną z zewnątrz również tworzy dodatkowy obiekt na stercie.

Zasada dla gorących ścieżek: preferuj proste pętle for/foreach zamiast kaskad LINQ, StringBuilder zamiast +, i sięgaj po Span<T> oraz pulowanie tam, gdzie to możliwe. Nie chodzi o to, by wygnać LINQ z całego projektu - chodzi o to, by nie było go w kodzie wykonywanym miliony razy na sekundę.

Podsumowanie


Świadome zarządzanie pamięcią w .NET to jedna z tych rzeczy, które odróżniają kod "działający" od kodu naprawdę wydajnego i skalowalnego. Zrozumienie, jak GC sprząta obiekty - generacje, LOH, tryby pracy - pozwala podejmować lepsze decyzje na każdym poziomie: od doboru typów danych, przez wzorce projektowe, po konfigurację środowiska.

Warto zapamiętać jedno: każda alokacja ma swój koszt. Jeśli kod generuje tony krótkotrwałych obiektów, zapłacisz za to częstszym odśmiecaniem i wyższym zużyciem CPU. Ale nie chodzi o to, by walczyć z każdym bajtem - chodzi o to, by wiedzieć, gdzie ta walka się opłaca. Zmierz, znajdź gorące ścieżki, a potem zastosuj opisane techniki: Span<T>, pule obiektów, struktury, eliminację ukrytych alokacji. Efekt? Płynniejsze działanie, mniejsze zużycie zasobów i aplikacja, która lepiej znosi obciążenie.

Chcesz więcej?


Jeśli takie treści - konkretne, praktyczne i wyciągnięte z realnych projektów - są dla Ciebie wartościowe, to zapraszam Cię na moją listę VIP.

To miejsce dla programistów .NET, którzy chcą wejść na wyższy poziom. Dzielę się tam wskazówkami o wydajności, głębszymi analizami i materiałami, które często trafiają do subskrybentów wcześniej, niż pojawią się na blogu, a spora część nie pojawia się wcale. Bez spamu i bez lania wody: sam konkret, który pomaga pisać szybszy i lepszy kod.

Dołącz do listy VIP → modestprogrammer.pl/vip

Autor artykułu:
Kazimierz Szpin
Kazimierz Szpin
CTO & Founder - FindSolution.pl
Programista C#/.NET. Specjalizuje się w Blazor, ASP.NET Core, ASP.NET MVC, ASP.NET Web API, WPF oraz Windows Forms.
Autor bloga ModestProgrammer.pl
Dodaj komentarz
© Copyright 2026 modestprogrammer.pl | Sztuczna Inteligencja | Regulamin | Polityka prywatności. Design by Kazimierz Szpin. Wszelkie prawa zastrzeżone.