Optymalizacja wydajności JVM, część 3: Garbage collection

Mechanizm garbage collection platformy Java znacznie zwiększa produktywność programistów, ale źle zaimplementowany garbage collector może nadmiernie zużywać zasoby aplikacji. W tym trzecim artykule z serii optymalizacji wydajności JVM Eva Andreasson przedstawia początkującym użytkownikom Javy przegląd modelu pamięci platformy Java i mechanizmu GC. Następnie wyjaśnia, dlaczego fragmentacja (a nie GC) jest główną “wpadką” wydajności aplikacji Java i dlaczego generacyjne zbieranie śmieci i kompakcja są obecnie wiodącymi (choć nie najbardziej innowacyjnymi) podejściami do zarządzania fragmentacją sterty w aplikacjach Java.

Zbieranie śmieci (GC) jest procesem, którego celem jest zwolnienie zajętej pamięci, do której nie odwołuje się już żaden osiągalny obiekt Java, i jest istotną częścią systemu dynamicznego zarządzania pamięcią maszyny wirtualnej Java (JVM). W typowym cyklu odśmiecania zachowywane są wszystkie obiekty, które nadal są referencjonowane, a więc osiągalne. Przestrzeń zajmowana przez poprzednio odwoływane obiekty jest zwalniana i odzyskiwana, aby umożliwić alokację nowych obiektów.

Aby zrozumieć garbage collection oraz różne podejścia i algorytmy GC, musisz najpierw wiedzieć kilka rzeczy o modelu pamięci platformy Java.

Garbage collection i model pamięci platformy Java

Gdy określisz opcję uruchamiania -Xmx w wierszu poleceń swojej aplikacji Java (na przykład: java -Xmx:2g MyApp), pamięć jest przydzielana procesowi Java. Pamięć ta jest określana jako sterta Javy (lub po prostu sterta). Jest to dedykowana przestrzeń adresowa pamięci, gdzie wszystkie obiekty tworzone przez twój program Java (lub czasami JVM) będą alokowane. Ponieważ twój program Java działa i przydziela nowe obiekty, sterta Javy (czyli ta przestrzeń adresowa) będzie się zapełniać.

W końcu sterta Javy będzie pełna, co oznacza, że wątek przydzielający nie jest w stanie znaleźć wystarczająco dużej kolejnej sekcji wolnej pamięci dla obiektu, który chce przydzielić. W tym momencie JVM stwierdza, że musi nastąpić odśmiecanie i powiadamia o tym garbage collector. Odśmiecanie może być również wywołane, gdy program Java wywoła System.gc(). Użycie System.gc() nie gwarantuje odśmiecania. Zanim rozpocznie się jakiekolwiek odśmiecanie, mechanizm GC najpierw określi, czy jego uruchomienie jest bezpieczne. Bezpiecznie jest rozpocząć odśmiecanie, gdy wszystkie aktywne wątki aplikacji znajdują się w bezpiecznym punkcie, który na to pozwala, np. po prostu wyjaśniono, że źle byłoby rozpocząć odśmiecanie w środku trwającej alokacji obiektu lub w środku wykonywania sekwencji zoptymalizowanych instrukcji procesora (zobacz mój poprzedni artykuł o kompilatorach), ponieważ można stracić kontekst, a tym samym zepsuć wyniki końcowe.

Odśmiecanie nigdy nie powinno odzyskiwać obiektu, do którego aktywnie się odwołano; aby to zrobić, złamałoby to specyfikację maszyny wirtualnej Java. Śmieciarz nie jest również zobowiązany do natychmiastowego zbierania martwych obiektów. Martwe obiekty są ostatecznie zbierane podczas kolejnych cykli odśmiecania. Chociaż istnieje wiele sposobów implementacji odśmiecania, te dwa założenia są prawdziwe dla wszystkich odmian. Prawdziwym wyzwaniem dla garbage collectora jest zidentyfikowanie wszystkiego, co jest żywe (wciąż ma odniesienie) i odzyskanie pamięci bez odniesienia, ale bez wpływania na działające aplikacje bardziej niż to konieczne. Garbage collector ma zatem dwa mandaty:

  1. Szybko zwolnić pamięć bez odniesienia w celu zaspokojenia tempa alokacji aplikacji, aby nie zabrakło jej pamięci.
  2. Odzyskiwać pamięć przy minimalnym wpływie na wydajność (np, opóźnienia i przepustowość) działającej aplikacji.

Dwa rodzaje zbierania śmieci

W pierwszym artykule z tej serii poruszyłem dwa główne podejścia do zbierania śmieci, którymi są liczenie referencji i kolektory śledzące. Tym razem bardziej zagłębię się w każde z tych podejść, a następnie przedstawię niektóre z algorytmów używanych do implementacji kolektorów śledzących w środowiskach produkcyjnych.

Przeczytaj serię optymalizacji wydajności JVM

  • Optymalizacja wydajności JVM, Część 1: Przegląd
  • Optymalizacja wydajności JVM, część 2: Kompilatory

Kolektory zliczające referencje

Kolektory zliczające referencje śledzą, ile referencji wskazuje na każdy obiekt Javy. Gdy licznik dla obiektu osiągnie zero, pamięć może być natychmiast odzyskana. Ten natychmiastowy dostęp do odzyskanej pamięci jest główną zaletą podejścia do zbierania śmieci opartego na zliczaniu referencji. Jest bardzo mały narzut, jeśli chodzi o trzymanie się pamięci bez odniesień. Utrzymanie wszystkich zliczeń referencyjnych na bieżąco może być jednak dość kosztowne.

Główną trudnością w przypadku kolektorów zliczających odniesienia jest utrzymanie dokładności zliczeń referencyjnych. Innym dobrze znanym wyzwaniem jest złożoność związana z obsługą struktur kołowych. Jeśli dwa obiekty odwołują się do siebie nawzajem i żaden żywy obiekt nie odwołuje się do nich, ich pamięć nigdy nie zostanie zwolniona. Oba obiekty na zawsze pozostaną z niezerową liczbą. Odzyskanie pamięci związanej ze strukturami kołowymi wymaga poważnej analizy, co powoduje kosztowny narzut na algorytm, a tym samym na aplikację.

Kolektory śledzące

Kolektory śledzące opierają się na założeniu, że wszystkie żywe obiekty można znaleźć poprzez iteracyjne śledzenie wszystkich referencji i kolejnych referencji z początkowego zbioru obiektów, o których wiadomo, że są żywe. Początkowy zestaw żywych obiektów (nazywanych w skrócie obiektami głównymi lub po prostu korzeniami) jest lokalizowany poprzez analizę rejestrów, pól globalnych i ramek stosu w momencie, gdy wywoływane jest odśmiecanie. Po zidentyfikowaniu początkowego zbioru żywych obiektów, kolektor śledzi odwołania do tych obiektów i ustawia je w kolejce do oznaczenia jako żywe, a następnie do śledzenia ich odwołań. Oznaczenie wszystkich znalezionych obiektów z referencjami jako żywe oznacza, że znany zbiór żywy zwiększa się w czasie. Proces ten trwa do momentu, gdy wszystkie obiekty z referencjami (a więc wszystkie żywe) zostaną znalezione i oznaczone. Gdy kolektor śledzący znajdzie wszystkie żywe obiekty, odzyska pozostałą pamięć.

Kolektory śledzące różnią się od kolektorów zliczających odniesienia tym, że mogą obsługiwać struktury kołowe. Problemem większości kolektorów śledzących jest faza znakowania, która wymaga oczekiwania, zanim będzie można odzyskać pamięć bez odniesienia.

Kolektory śledzące są najczęściej używane do zarządzania pamięcią w językach dynamicznych; są one zdecydowanie najbardziej powszechne dla języka Java i od wielu lat są komercyjnie sprawdzone w środowiskach produkcyjnych. Skupię się na kolektorach śledzących w pozostałej części tego artykułu, zaczynając od niektórych algorytmów implementujących to podejście do zbierania śmieci.

Algorytmy kolektorów śledzących

Kopiowanie i mark-and-sweep garbage collection nie są nowe, ale nadal są to dwa najbardziej powszechne algorytmy implementujące dziś zbieranie śmieci śledzących.

Kolektory kopiujące

Tradycyjne kolektory kopiujące używają przestrzeni od (from-space) i przestrzeni do (to-space) – czyli dwóch oddzielnie zdefiniowanych przestrzeni adresowych sterty. W momencie zbierania śmieci, żywe obiekty znajdujące się w obszarze zdefiniowanym jako from-space są kopiowane do następnej dostępnej przestrzeni w obszarze zdefiniowanym jako to-space. Gdy wszystkie żywe obiekty w przestrzeni from zostaną wyprowadzone, cała przestrzeń from może zostać odzyskana. Gdy alokacja rozpoczyna się ponownie, zaczyna się od pierwszej wolnej lokalizacji w przestrzeni to.

W starszych implementacjach tego algorytmu przestrzeń from i to-space zamieniają się miejscami, co oznacza, że gdy przestrzeń to-space jest pełna, zbieranie śmieci jest uruchamiane ponownie i przestrzeń to-space staje się przestrzenią from-space, jak pokazano na rysunku 1.

Rysunek 1. Tradycyjna sekwencja odśmiecania z kopiowaniem (kliknij, aby powiększyć)

Nowocześniejsze implementacje algorytmu kopiowania pozwalają na przypisanie arbitralnych przestrzeni adresowych w obrębie sterty jako to-space i from-space. W tych przypadkach nie muszą one koniecznie zamieniać się lokalizacjami; raczej każda z nich staje się kolejną przestrzenią adresową w obrębie sterty.

Jedną z zalet kolektorów kopiujących jest to, że obiekty są przydzielane razem ściśle w przestrzeni to-space, całkowicie eliminując fragmentację. Fragmentacja jest częstym problemem, z którym zmagają się inne algorytmy zbierania śmieci; coś, co omówię w dalszej części tego artykułu.

Wady kolektorów kopiujących

Kolektory kopiujące są zazwyczaj kolektorami typu stop-the-world, co oznacza, że żadna praca aplikacji nie może być wykonywana tak długo, jak długo trwa cykl zbierania śmieci. W implementacji stop-the-world, im większy jest obszar, który trzeba skopiować, tym większy będzie wpływ na wydajność aplikacji. Jest to wada dla aplikacji, które są wrażliwe na czas odpowiedzi. W przypadku kolektora kopiującego musisz również rozważyć najgorszy scenariusz, gdy wszystko jest na żywo w przestrzeni from. Zawsze musisz zostawić wystarczająco dużo miejsca na przeniesienie żywych obiektów, co oznacza, że to-space musi być wystarczająco duży, aby pomieścić wszystko w from-space. Algorytm kopiowania jest nieco nieefektywny pamięciowo z powodu tego ograniczenia.

Kolektory mark-and-sweep

Większość komercyjnych maszyn JVM wdrożonych w środowiskach produkcyjnych przedsiębiorstw uruchamia kolektory mark-and-sweep (lub markowanie), które nie mają takiego wpływu na wydajność jak kolektory kopiowania. Niektóre z najbardziej znanych kolektorów znakowania to CMS, G1, GenPar i DeterministicGC (zobacz Zasoby).

Kolektor mark-and-sweep śledzi odniesienia i oznacza każdy znaleziony obiekt bitem “live”. Zazwyczaj ustawiony bit odpowiada adresowi lub w niektórych przypadkach zestawowi adresów na stercie. Żywy bit może być na przykład przechowywany jako bit w nagłówku obiektu, wektor bitów lub mapa bitów.

Po tym jak wszystko zostanie oznaczone jako żywe, rozpocznie się faza wymiatania. Jeśli kolektor ma fazę zamiatania, to w zasadzie zawiera jakiś mechanizm do ponownego przemierzania sterty (nie tylko zestawu na żywo, ale całej długości sterty), aby zlokalizować wszystkie nieoznaczone kawałki kolejnych przestrzeni adresowych pamięci. Niezaznaczona pamięć jest wolna i możliwa do odzyskania. Kolektor następnie łączy te nieoznaczone kawałki w zorganizowane listy wolnych kawałków. W garbage collectorze mogą istnieć różne wolne listy – zwykle zorganizowane według rozmiarów kawałków. Niektóre maszyny JVM (takie jak JRockit Real Time) implementują kolektory z heurystykami, które dynamicznie ustalają zakres wielkości list na podstawie danych profilowania aplikacji i statystyk rozmiaru obiektów.

Po zakończeniu fazy wymiatania alokacja rozpocznie się ponownie. Nowe obszary alokacji są przydzielane z wolnych list, a kawałki pamięci mogą być dopasowane do rozmiarów obiektów, średnich rozmiarów obiektów na ID wątku lub dostrojonych przez aplikację rozmiarów TLAB. Dopasowanie wolnej przestrzeni bardziej zbliżonej do rozmiaru tego, co aplikacja próbuje przydzielić, optymalizuje pamięć i może pomóc zredukować fragmentację.

Więcej o rozmiarach TLAB

Partycjonowanie TLAB i TLA (Thread Local Allocation Buffer lub Thread Local Area) omówiono w Optymalizacja wydajności JVM, część 1.

Downsides of mark-and-sweep collectors

Faza mark jest zależna od ilości żywych danych na stercie, podczas gdy faza sweep jest zależna od rozmiaru sterty. Ponieważ musisz poczekać, aż obie fazy mark i sweep zostaną zakończone, aby odzyskać pamięć, ten algorytm powoduje problemy z czasem pauzy dla większych stert i większych zbiorów danych na żywo.

Jednym ze sposobów, w jaki możesz pomóc aplikacjom zużywającym dużo pamięci, jest użycie opcji dostrajania GC, które dostosowują się do różnych scenariuszy i potrzeb aplikacji. Dostrajanie może w wielu przypadkach pomóc przynajmniej odroczyć jedną z tych faz, aby nie stała się zagrożeniem dla aplikacji lub umów o poziomie usług (SLA). (Umowa SLA określa, że aplikacja będzie spełniać określone czasy odpowiedzi aplikacji – tj. opóźnienia). Strojenie dla każdej zmiany obciążenia i modyfikacji aplikacji jest jednak powtarzalnym zadaniem, ponieważ strojenie jest ważne tylko dla określonego obciążenia i współczynnika alokacji.

Implementacje mark-and-sweep

Istnieją co najmniej dwa komercyjnie dostępne i sprawdzone podejścia do implementacji kolekcji mark-and-sweep. Jednym z nich jest podejście równoległe, a drugim podejście współbieżne (lub w większości współbieżne).

Zbiorniki równoległe

Zbiorniki równoległe oznaczają, że zasoby przypisane do procesu są używane równolegle do celów zbierania śmieci. Większość komercyjnie zaimplementowanych kolektorów równoległych to monolityczne kolektory typu stop-the-world — wszystkie wątki aplikacji są zatrzymywane do czasu zakończenia całego cyklu zbierania śmieci. Zatrzymanie wszystkich wątków pozwala na efektywne wykorzystanie wszystkich zasobów równolegle do zakończenia zbierania śmieci poprzez fazy mark i sweep. Prowadzi to do bardzo wysokiego poziomu wydajności, zwykle skutkującego wysokimi wynikami w benchmarkach przepustowości, takich jak SPECjbb. Jeśli przepustowość jest kluczowa dla Twojej aplikacji, podejście równoległe jest doskonałym wyborem.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.