Garbage collection to jedna z najważniejszych innowacji we współczesnym projektowaniu języków programowania, która zautomatyzowała zarządzanie pamięcią i wyeliminowała całe klasy błędów.
- Podstawowe pojęcia i rozwój historyczny garbage collection
- Podstawowe algorytmy i strategie garbage collection
- Oznaczanie i czyszczenie – klasyczna podstawa
- Zliczanie referencji – deterministyczna dealokacja
- Śledzące garbage collection – współczesny standard
- Kolekcja kopiująca – kompaktowanie przez relokację
- Kolekcja generacyjna – wykorzystanie czasu życia obiektów
- Garbage collection w Javie i maszynie wirtualnej Javy
- Podstawowe mechanizmy i strategie GC w JVM
- Kolektor szeregowy
- Kolektor równoległy
- Kolektor G1 – zarządzanie partycjonowaną stertą
- Kolektor Concurrent Mark Sweep (CMS)
- Nowoczesne kolektory niskiej latencji – Shenandoah i ZGC
- Garbage collection w Pythonie
- Garbage collection w JavaScripcie
- Garbage collection w Go
- Garbage collection w .NET i C#
- Garbage collection w Ruby
- Oznaczanie i czyszczenie z pauzami stop-the-world
- Trójkolorowe oznaczanie i inkrementalne GC
- Bariery zapisu w Ruby
- Garbage collection w Ruście – alternatywny paradygmat
- Porównanie podejść w popularnych językach
- Charakterystyka wydajności i strategie optymalizacji
- Zaawansowane funkcje i mechanizmy GC
- Wnioski i dobre praktyki w zakresie garbage collection
Zamiast wymagać od programistów ręcznej alokacji i dealokacji pamięci, garbage collection identyfikuje i odzyskuje pamięć, do której program nie ma już dostępu. Niniejszy artykuł omawia, jak GC działa w wielu językach i na różnych platformach, analizując algorytmy, charakterystyki wydajnościowe oraz praktyczne implementacje.
W skrócie, GC wnosi do procesu tworzenia oprogramowania:
- eliminację błędów takich jak wiszące wskaźniki, podwójne zwolnienia i nieintencjonalne wycieki,
- większą produktywność dzięki mniej złożonemu zarządzaniu pamięcią,
- bezpieczniejsze uruchomienie i prostszą architekturę aplikacji,
- łatwiejszą konserwację i rozwój kodu w dłuższym horyzoncie,
- koszt w postaci dodatkowego zużycia CPU i pamięci w czasie działania.
Podstawowe pojęcia i rozwój historyczny garbage collection
Garbage collection pojawiło się jako koncepcja na długo przed tym, zanim stało się powszechne we współczesnym programowaniu. Amerykański informatyk John McCarthy wynalazł garbage collection około 1959 roku, aby uprościć ręczne zarządzanie pamięcią w LISP.
Podstawowa zasada wszystkich systemów GC jest prosta: obiekt nie jest już potrzebny, jeśli program nie może już do niego dotrzeć w przyszłości. Na tej podstawie powstały różne algorytmy z odmiennymi kompromisami między prostotą, efektywnością pamięciową, czasem pauz i przepustowością.
Garbage collection odciąża programistę od ręcznego zwalniania pamięci, zwiększa bezpieczeństwo kodu i produktywność, ale wymaga dodatkowych zasobów obliczeniowych. Potwierdzają to badania: publikacja z 2005 roku wykazała, że GC potrzebuje ok. pięciokrotnie więcej pamięci, aby zrekompensować narzut i dorównać idealizowanemu ręcznemu zarządzaniu pamięcią.
Podstawowe algorytmy i strategie garbage collection
Aby uchwycić kluczowe podejścia do GC, warto mieć w pamięci ten skrócony przegląd:
- mark-and-sweep – śledzenie osiągalności i wymiecenie nieoznaczonych obiektów;
- zliczanie referencji – licznik odwołań do obiektu i natychmiastowa dealokacja przy zerze;
- kopiujący GC – przenoszenie żywych obiektów do nowej przestrzeni i kompaktowanie;
- generacyjny GC – podział sterty na młode i stare generacje według czasu życia;
- współbieżny/inkrementalny GC – dzielenie pracy na kroki współdziałające z mutatorem;
- oznaczanie trójkolorowe – technika ułatwiająca inkrementalne i współbieżne zbieranie.
Oznaczanie i czyszczenie – klasyczna podstawa
Algorytm mark-and-sweep to jedno z najważniejszych i najpowszechniej używanych podejść. Uznaje obiekt za niepotrzebny, gdy jest nieosiągalny od korzeni i działa w dwóch fazach: oznaczania (mark) i czyszczenia (sweep).
W praktyce przebieg mark-and-sweep to:
- przejście od „zbioru korzeni” i oznaczenie wszystkich osiągalnych obiektów jako żywych,
- przeskanowanie sterty i zwolnienie obiektów nieoznaczonych,
- opcjonalne skompaktowanie pamięci w celu ograniczenia fragmentacji,
- wyczyszczenie znaczników i przygotowanie do kolejnego cyklu.
Elegancja mark-and-sweep tkwi w prostocie i poprawności: poprawnie radzi sobie z cyklami referencji.
Zliczanie referencji – deterministyczna dealokacja
Zliczanie referencji polega na utrzymywaniu licznika odwołań; obiekt z licznikiem równym zeru może być natychmiast zniszczony. Daje to przewidywalne, deterministyczne sprzątanie.
Najważniejsze zalety zliczania referencji to:
- deterministyczne zwalnianie zasobów dokładnie w momencie zaniku ostatniej referencji,
- korzystna lokalność pamięciowa dzięki działaniu w pobliżu cache CPU,
- prosty model mentalny i łatwość integracji z zasobami zewnętrznymi.
Istotne ograniczenia obejmują:
- brak radzenia sobie z cyklami bez dodatkowego wykrywania,
- kosztowne operacje atomowe w środowiskach wielowątkowych,
- dodatkowy narzut pamięci na liczniki i metadane.
Śledzące garbage collection – współczesny standard
Śledzące GC jest dziś najpowszechniejsze: wyznacza żywe obiekty przez analizę osiągalności od korzeni, a resztę zbiera.
Istotnym usprawnieniem jest trójkolorowe oznaczanie (biały, szary, czarny), które umożliwia inkrementalne i współbieżne GC przy zachowaniu inwariantu poprawności.
Kolekcja kopiująca – kompaktowanie przez relokację
Kolektor kopiujący zamiast „wymiatać” martwe obiekty, kopiuje żywe do nowego obszaru, co eliminuje fragmentację i upraszcza alokację do prostego wskaźnika przesuwającego. Wymaga jednak dodatkowej przestrzeni docelowej o rozmiarze zbliżonym do źródłowej.
Kolekcja generacyjna – wykorzystanie czasu życia obiektów
Większość obiektów „umiera” młodo, dlatego sterta dzielona jest na generacje. Nowe obiekty trafiają do młodej generacji i są zbierane częściej, a długowieczne promowane są do starszych obszarów.
Garbage collection w Javie i maszynie wirtualnej Javy
JVM oferuje kilka kolektorów dopasowanych do różnych obciążeń, od maksymalnej przepustowości po ultraniską latencję.
Podstawowe mechanizmy i strategie GC w JVM
JVM musi zwykle uzyskać wyłączny dostęp do sterty podczas części faz GC („stop-the-world”). Najpierw oznacza obiekty osiągalne, a następnie odzyskuje pamięć nieoznaczonych i często kompaktuje stertę.
Najważniejszym czynnikiem wydajności GC jest dostępna pamięć. Rozmiary sterty ustawia się opcjami -Xms (początkowa) i -Xmx (maksymalna). Ustawienie tych wartości równo stabilizuje zachowanie, kosztem adaptacyjności.
JVM stosuje mark-and-compact, a gdy brakuje dużych ciągłych bloków, przemieszcza obiekty, aby skonsolidować wolną przestrzeń i uniknąć fragmentacji.
Kolektor szeregowy
Serial GC działa jednowątkowo i zatrzymuje wszystkie wątki aplikacji na czas sprzątania. Dobrze sprawdza się w małych aplikacjach, ale przy większych stertach może powodować długie pauzy.
Kolektor równoległy
Parallel GC rozkłada pracę GC na wiele wątków, redukując pauzy względem Serial GC. Stosuje zbieranie młodej generacji znacznie częściej niż pełne zbiory i używa równoważenia obciążenia między wątkami.
Kolektor G1 – zarządzanie partycjonowaną stertą
G1 dzieli stertę na regiony stałego rozmiaru i skupia się na regionach „najbogatszych w śmieci”. Ma współbieżne oznaczanie, ale kluczowe operacje ewakuacji są nadal STW. To kompromis między latencją a przepustowością.
Kolektor Concurrent Mark Sweep (CMS)
CMS wykonywał znaczną część oznaczania współbieżnie, ale został usunięty od Java 14 ze względu na złożoność i gorszą przewidywalność wyników.
Nowoczesne kolektory niskiej latencji – Shenandoah i ZGC
Shenandoah utrzymuje pauzy poniżej ~10 ms dzięki współbieżnej ewakuacji obiektów i barierom odczytu.
ZGC minimalizuje pauzy do zakresu poniżej milisekundy i od JDK 21 wspiera tryb generacyjny, łącząc niską latencję z lepszą efektywnością pamięci.
Dla szybkiego porównania najważniejszych kolektorów JVM warto spojrzeć na poniższe zestawienie:
| Kolektor | Równoległość / współbieżność | Cel główny | Charakter pauz | Typowe zastosowania |
|---|---|---|---|---|
| Serial GC | jednowątkowy | prostota | dłuższe STW | małe aplikacje, środowiska z ograniczonymi zasobami |
| Parallel GC | wielowątkowy (STW) | przepustowość | krótsze, ale nadal STW | zadania wsadowe, serwery obliczeniowe |
| G1 | wielowątkowy + częściowo współbieżny | zbalansowana latencja/przepustowość | kontrolowane STW | usługi ogólnego przeznaczenia, duże sterty |
| Shenandoah | współbieżny | niska latencja | ~poniżej 10 ms | systemy interaktywne, trading, API czasu rzeczywistego (miękkiego) |
| ZGC | współbieżny | ultraniska latencja | < 1 ms | aplikacje wrażliwe na pauzy, duże sterty (setki GB) |
Garbage collection w Pythonie
Python łączy zliczanie referencji z kolektorem cykli, aby obsłużyć obiekty w cyklicznych grafach.
Zliczanie referencji jako mechanizm podstawowy
Każdy obiekt utrzymuje licznik referencji; gdy spada on do zera, obiekt jest natychmiast dealokowany. Wbudowany moduł gc wykrywa i sprząta cykle, których samo zliczanie nie zdołałoby odzyskać.
Generacyjne garbage collection w Pythonie
Python organizuje obiekty w trzy generacje, a zbieranie wyzwalane jest progami różnicy alokacji i dealokacji (domyślnie (2000, 10, 10)).
Role poszczególnych generacji są następujące:
- generacja 0 – nowe obiekty, zbierana najczęściej,
- generacja 1 – obiekty, które przetrwały jedno zbieranie,
- generacja 2 – obiekty długowieczne, zbierane najrzadziej.
Deweloper może wymusić zbieranie przez gc.collect(), co bywa pomocne w szczytach alokacji lub przy kontrolowaniu momentu sprzątania.
Garbage collection w JavaScripcie
Wszystkie nowoczesne silniki JS używają śledzących GC, zwykle wariantu mark-and-sweep (często generacyjnego i inkrementalnego).
Oznaczanie i czyszczenie w silnikach JavaScript
Silnik oznacza obiekty osiągalne od korzeni (np. obiektu globalnego) i wymiecie nieoznaczone. Dzięki temu cykle referencji nie powodują wycieków, w przeciwieństwie do prostego zliczania referencji.
Obsługa cyklicznych referencji i słabych odwołań
JavaScript udostępnia mechanizmy słabych odwołań, które współgrają z GC:
- WeakMap – mapa ze słabymi kluczami (obiektami), umożliwiająca odzysk pamięci mimo obecności wpisu;
- WeakSet – zbiór przechowujący obiekty słabo, z podobną semantyką do WeakMap;
- FinalizationRegistry – rejestr umożliwiający otrzymanie powiadomienia po zebraniu obiektu.
Garbage collection w Go
Go kładzie nacisk na współbieżne śledzenie i minimalizację pauz, utrzymując dobrą przepustowość.
Równoległy śledzący GC w Go
Go stosuje mark-sweep z fazami współbieżnymi. Zmienna środowiskowa GOGC kontroluje częstotliwość zbierania w funkcji wzrostu sterty (np. 50 oznacza zbieranie po wzroście o 50%).
Alokacja i pule obiektów w Go
sync.Pool umożliwia recykling obiektów, ograniczając presję alokacyjną i obciążenie GC. Najlepiej sprawdza się dla krótkożyjących, często używanych struktur.
Garbage collection w .NET i C#
Garbage collector platformy .NET to kompaktujący kolektor generacyjny z trybami nastawionymi na przepustowość (Server) lub latencję (Workstation).
Kolekcja generacyjna w .NET
Sterta .NET jest podzielona na generacje 0, 1 i 2. Większość obiektów jest odzyskiwana w generacji 0, co skraca typowe pauzy.
Role generacji w .NET można streścić tak:
- generacja 0 – obiekty bardzo krótkotrwałe, zbierane najczęściej,
- generacja 1 – bufor dla obiektów, które przetrwały gen 0,
- generacja 2 – obiekty długowieczne, zbierane rzadko (major GC).
Alokacja pamięci i kompaktowanie
GC w .NET zwalnia pamięć obiektów nieosiągalnych na podstawie analizy korzeni (stosy, statyki, uchwyty, rejestry) i kompaktuje żywe obiekty, poprawiając lokalność i ograniczając fragmentację.
Garbage collection w Ruby
Ruby przeszedł istotną ewolucję w GC, szczególnie od wersji 2.1 (generacyjny) i 2.2 (inkrementalny).
Oznaczanie i czyszczenie z pauzami stop-the-world
Ruby zatrzymuje program na czas GC (STW), co upraszcza zachowanie i gwarantuje spójność grafu obiektów podczas sprzątania.
Faza „mark” oznacza obiekty żywe, a faza „sweep” zwraca nieoznaczone na listę wolnych. To szybkie i przewidywalne podejście.
Trójkolorowe oznaczanie i inkrementalne GC
Ruby używa trójkolorowego mark-and-sweep i generacyjnego GC. Obiekty przetrwałe są promowane do „starych” i sprawdzane rzadziej. Inkrementalne zbieranie dzieli pracę na krótsze, częstsze przebiegi, poprawiając odczuwalną latencję.
Bariery zapisu w Ruby
Bariery zapisu rejestrują modyfikacje referencji w trakcie GC, co pozwala zachować poprawność oznaczania przy inkrementalnych przebiegach.
Garbage collection w Ruście – alternatywny paradygmat
Rust nie używa GC — opiera się na modelu własności i regułach pożyczania (borrow), zapewniając deterministyczne zwalnianie zasobów (RAII) bez narzutu czasu wykonania GC.
Własność jako alternatywa dla GC
Własność pozwala kompilatorowi wyznaczyć moment zwolnienia zasobów; po wyjściu obiektu z zakresu destruktor (cecha Drop) automatycznie sprząta.
Borrow checker jako bezpieczeństwo pamięci w czasie kompilacji
Borrow checker zapobiega błędom pamięci na etapie kompilacji. Gwarantuje między innymi:
- brak użycia po zwolnieniu (use-after-free),
- brak podwójnego zwalniania (double free),
- brak wyścigów danych w bezpiecznym kodzie.
Porównanie podejść w popularnych językach
Poniższa tabela syntetyzuje różnice między wybranymi platformami pod kątem mechanizmów GC i celów projektowych:
| Język / Platforma | Podstawowy mechanizm GC | Obsługa cykli | Tryb pracy | Cel latencji | Cechy szczególne |
|---|---|---|---|---|---|
| Java (HotSpot) | generacyjny, regionowy (G1), współbieżny (Shenandoah, ZGC) | tak (śledzenie osiągalności) | STW + współbieżne fazy | od zbalansowanej do ultraniskiej | barierki odczytu/zapisu, rozbudowane strojenie |
| Python (CPython) | zliczanie referencji + detekcja cykli | tak (moduł gc) | okresowe STW | umiarkowana | deterministyczne sprzątanie przy refcount=0 |
| JavaScript (V8, SpiderMonkey) | mark-and-sweep, generacyjny, inkrementalny | tak | inkrementalny + STW w etapach | niska / interaktywna | WeakMap/WeakSet, FinalizationRegistry |
| Go | współbieżny mark-sweep (trójkolorowy) | tak | współbieżny | niska | GOGC steruje częstotliwością GC |
| .NET | generacyjny, kompaktujący | tak | STW + współbieżne oznaczanie | zbalansowana | tryby Server/Workstation, LOH |
| Ruby (MRI) | trójkolorowy mark-and-sweep, generacyjny | tak | STW + inkrementalny | umiarkowana / niższa dzięki inkrementacji | bariery zapisu, krótsze częstsze przebiegi |
| Rust | brak GC (własność, borrow checker) | nie dotyczy | deterministyczne RAII | deterministyczna | sprzątanie bez narzutu czasu wykonania GC |
Charakterystyka wydajności i strategie optymalizacji
Pauzy STW i całkowity „GC time” determinują odczuwaną latencję i przepustowość aplikacji. Intuicyjne „zawsze zmniejszaj narzut GC” nie zawsze działa — współbieżne wątki GC mogą konkurować o CPU z aplikacją, przez co wzrost współbieżności GC potrafi paradoksalnie spowalniać program.
Fragmentacja pamięci i kompaktowanie
Kompaktujące i kopiujące kolektory ograniczają fragmentację, podczas gdy niekompaktujące mogą cierpieć na jej wzrost. W najgorszym przypadku dodatkowa pamięć rośnie zgodnie z granicami Robsona. Nowoczesne techniki, jak MESH dla C/C++, używają pamięci wirtualnej do przemieszczania obiektów przy zachowaniu adresów, łamiąc praktycznie granice fragmentacji.
Strojenie i konfiguracja rozmiaru sterty
Maksymalny rozmiar sterty musi być mniejszy niż fizyczna RAM, aby uniknąć thrashingu. Większa sterta zmniejsza częstotliwość GC, ale może zwiększać czas pojedynczych pauz i zużycie pamięci. Jeśli pauzy nie są problemem, warto zwiększyć rozmiar sterty ponad domyślne ustawienia. Ustawienie -Xms równego -Xmx zwiększa przewidywalność.
Zaawansowane funkcje i mechanizmy GC
Bariery zapisu i remembered sets
Bariery zapisu rejestrują modyfikacje referencji i oznaczają „karty” pamięci, dzięki czemu nie trzeba skanować całej starej generacji podczas młodych zbierań. Mimo narzutu na operacje zapisu, zwykle przynosi to wyraźny zysk przepustowości.
Finalizatory i zwalnianie zasobów
Finalizatory uruchamia GC, więc programista nie kontroluje momentu ich wykonania. Zaleca się wzorzec jawnego zwalniania (np. Dispose), pozostawiając finalizator jako mechanizm awaryjny.
Słabe referencje i typy odwołań
Różne typy referencji pozwalają wpływać na zachowanie GC: strong utrzymują obiekt przy życiu, weak nie blokują zbierania, a soft pozwalają utrzymać obiekty do czasu presji pamięci (np. w cache’ach).
Wnioski i dobre praktyki w zakresie garbage collection
Nie istnieje jedno najlepsze podejście do GC — wybór zależy od obciążenia, wymagań latencji i dostępnej pamięci. Dla przepustowości sprawdzają się Parallel lub G1; dla niskiej latencji — Shenandoah lub ZGC; dla deterministyczności — model własności (Rust) lub wzorce RAII.
Projektuj z myślą o generacyjności: ograniczaj tworzenie krótkotrwałych obiektów, dbaj o to, by referencje długowieczne nie wskazywały na obiekty młode, oraz stosuj pule obiektów tam, gdzie rzeczywiście zwiększają współczynnik ponownego użycia.
Nowoczesne kolektory rozwijają techniki współbieżne, inkrementalne i regionowe, aby minimalizować pauzy przy zachowaniu wysokiej przepustowości. Zrozumienie ich mechaniki pozwala pisać kod, który współpracuje, a nie walczy, z automatycznym zarządzaniem pamięcią.
