Garbage collector
to taki stwór, który automagicznie przeczesując pamięć znajduje śmieci i odzyskuje miejsce przez nie zajmowane. Oczywiście za posprzątanie trzeba mu zapłacić.
Ile?
Odpowiedź standardowa – to zależy. Istnieje bowiem bardzo wiele garbage collectorów
. Nie są one ustandaryzowane, więc w zależności od implementacji JVMa są dostępne różne implementacje garbage collectorów
.
W tym wpisie skupię się na gc
kach dostępnych w AdoptOpenJDK (HotSpot)
w wersji 13. Dostępne w tym JVMie są między innymi:
- Epsilon GC (mój faworyt 🙂 )
- Parallel GC
- CMS (Concurrent Mark Sweep)
- G1
- ShenandoahGC
- ZGC
Większość z nich to skomplikowane mechanizmy, jednak postaram się pokrótce opisać jak działają.
Hipoteza generacyjna
Bardzo ważnym pojęciem dla gc
ków jest tzw. hipoteza generacyjna. Zakłada ona, że obiekty bardzo często mają bardzo krótki cykl życia (śmiertelność niemowląt), zatem pamięć po takich obiektach można szybko odzyskać. Jednocześnie zakłada ona, że jeśli obiekt przeżył kilka cykli odśmiecania, to można założyć, że będzie żył bardzo długo.
Ta teoria przekłada się na praktykę: w wielu implementacjach gc
jest podział na generację młodą oraz starą.
Minor GC
Młoda generacja składa się z dwóch fragmentów pamięci – Eden
i Survivor
. W Eden
ie umieszczane są zawsze nowo utworzone obiekty. Jeśli obiekt jest żywy i przetrwał cykl GC
, to jest przenoszony do Survivor
. Jeśli obiekt przetrwa kilka cykli GC
, wówczas jest promowany do starszyzny (starej generacji).
Czyszczenie młodej generacji jest bardzo wydajne, dlatego robi się je oddzielnie od starej generacji. Takie czyszczenie nazywamy Minor Garbage Collection.
MinorGC Najczęściej korzysta z algorytmu Mark-Copy. W pierwszej fazie musimy się dowiedzieć, jakie obiekty są żyjące.
W pierwszym kroku oznaczamy wszystkie obiekty jako nieżywe. Następnie znajdujemy wszystkie obiekty, które z pewnością są żywe tzw. GCRoot
y (zmienne ze stosów wątków, zmienne statyczne i wiele wiele innych). Oznaczamy je jako żywe, a następnie wszystkie jego pola oznaczamy jako żywe oraz rekurencyjnie oznaczamy pola pól jako żywe.
W drugim kroku, gdy już wiemy, jakie obiekty są żywe, kopiujemy je do bezpiecznych przestrzeni Survivor. Jako, że wszystkie obiekty z Edenu są bezpiecznie przeniesione, to możemy wyczyścić cały Eden.
Oczywiście cały proces jest bardziej skomplikowany i zależy od implementacji, ale na potrzeby tego artykułu mam nadzieję, że ta teoria wystarczy. A teraz do kodu!
Benchmark
W tym benchmarku będziemy sprawdzać, ile obiektów zdążymy zaalokować w czasie sekundy:
private static class IntWrapper {
int i = 12;
}
@Benchmark
public void method() {
new IntWrapper();
}
Trzeba oczywiście pamiętać o wyłączniu EscapeAnalysis, żeby być pewnym, że te obiekty się rzeczywiście tworzą. Stąd potrzebna flaga -XX:-DoEscapeAnalysis
.
Kolejnym potrzebnym argumentem jest odpowiednia wielkość sterty. W tym przypadku użyłem 8GB pamięci.
Ostatnią sprawą o której trzeba pamiętać jest upewnienie się, że ta pamięć jest zarezerwowana dla JVMa. Normalnie, gdy JVM prosi Linuxa o pamięć, to Linux tę pamięć da tak tylko teoretycznie tzn. czasem Linux da pamięć dopiero w momencie zapisu, przez co czasem musi jej poszukać.
Opcja -XX:+AlwaysPreTouch
zapewnia, że pamięć będzie od razu gotowa do użycia.
Epsilon GC
Jest to mój faworyt. Ten Garbage Collector nie robi nic do czasu, gdy zacznie kończyć się pamięć. Jeśli pamięć się skończy, to mówi: Nie ma więcej pamięci. I co mi pan zrobi? Czyli rzuca błędemOutOfMemoryError
.
Pytanie: na co komu taki nieużyteczny gc
? W zamyśle twórców (JEP 318) odpowiedzi jest wiele,a jedna z nich to – do testów wydajnościowych. I z tej cechy tego gc
skorzystamy.
W czasie całego Benchmarku prawie całkowicie zapełnia się cały heap.
Wynik benchmarku: 332 942 227 ops/s (przepustowość)
Parallel GC
Dość prosty garbage collector, który jest zrównolegloną wersją Serial GC. Działanie jest następujące: kończy się miejsce, więc zatrzymujemy cały JVM i szukamy pamięci. Najpierw szukamy obiektów żywych – wszystkie żywe przenosimy w bezpieczne miejsce, a całą resztę pamięci oramy (i możemy potem z niej korzystać). To bardzo duże uproszczenie.
W tym GC pamięć dzielimy na stałe części – young to domyślnie 1/2 old. Survivor to 20% Eden. Zatem pamięci w Edenie zostaje jakieś 2GB. Zatem kilka razy Minor GC się odpali. Jednak charakterystyka tych danych – wszystkie obiekty od razu są nieużytkami – powoduje, że nie ma żadnych obiektów do przeszukania, więc Minor GC trwa po 2ms i bardzo nieznacznie wpływa całość przetwarzania.
Wynik: 329 558 528 ops/s (99% Epsilion GC)
Concurrent Mark Sweep
Parallel GC posiada jedną szczególną wadę – faza Stop The World przy dużych Heapach i pełnych kolekcjach (Young + Tenured) potrafi trwać co najmniej kilkaset milisekund. Przy zastosowaniach serwerowych taka pauza potrafi spowodować timeout
żądania. Poszukiwano zatem rozwiązania, które skróci pauzy. Najstarszym jest właśnie Concurrent Mark Sweep.
Jednak w przypadku młodej generacji również jest używany analogiczny algorytm jak w przypadku ParallelGC.
Wyniki są zastanawiające, gdyż jest to średnio 183 989 107 operacji na sekundę (55% Epsilion GC), czyli znacznie mniej niż w poprzednich przypadkach. Czasy pauz dla MinorGC to 45ms, są też znacznie częściej.
I bardzo by mnie to zastanawiało, skąd ten stan rzeczy, jednak ten GC szczęśliwie przechodzi do historii począwszy od Javy 14 (JEP-363). Zostawmy historię historykom.
G1
Ten gc
wprowadzony w Java 6, a domyślny od Javy 9 był rewolucyjny. Koncepcja zupełnie inna – pamięć jest podzielona na około 2048 fragmentów, z których każdy może być przypisany do jednego z typów (free, eden, survivor, old oraz humongous). Dzięki temu również gc
może swobodnie zmieniać proporcje między young a old.
W tym gc
głównym założeniem jest zmniejszanie czasu zatrzymania aplikacji. Osiągnięte jest to poprzez przesunięcie wykonywania niektórych operacji na czas działania systemu równolegle do działania aplikacji. Oczywiście to wymaga pewnego narzutu przy wykonywaniu operacji, dodatkowej pamięci i działania osobnych wątków GC.
Wspomniane dodatkowe operacje są wygenerowane przez JIT dla tego GC i zatem skompilowany kod jest większy.
Do rzeczy – tutaj ten prosty benchmark pozwala wykonanie 311 283 463 ops/s (93% Epsilion GC). Należy pamiętać, że oprócz tego wątku równolegle działają również wątki GC, więc rzeczywista przepustowość jest jeszcze mniejsza.
Shenandoah
Ten garbage collector mocno bazuje na G1, próbując usprawnić niektóre etapy tak, by nie wymagały pauzy. Założenia, są takie, że długość pauzy to maksymalnie 10 ms niezależnie od wielkości stosu. Maksymalna strata przepustowości to 15%. Trzeba również nadmienić, że wymaga nieco więcej pamięci, gdyż każdy obiekt musi mieć dłuższy nagłówek. O ile możliwy jest podział na młodą i starą generację, o tyle aktualnie nie jest on zaimplementowany.
Shenandoah został zaprojektowany i jest rozwijany od 2014 roku przez Red Hat, jednak możemy go używać dopiero od Javy 12. Niestety wersje JDK wypuszczane przez Oracle (OracleJDK) nie zawierają Shenandoah, dlatego zaznaczałem na wstępie, że korzystam z AdoptOpenJDK opartego na Hotspot w wersji 13.
Rezultat dla tego GC to 323 042 410 ops/s (97% Epsilion GC), co oznacza, że sama alokacja nowych obiektów jest naprawdę sprawna. Oczywiście trzeba pamiętać o większym narzucie na pamięć oraz na wątki gc pracujące w tle.
ZGC
Założenia ZGC są bardzo podobne do Shenandoah – 10ms, 15% uszczerbku w wydajności. Implementacja jednak znacznie się różni i podobno bliżej jej do C4 Azul Zing. ZGC pojawił się eksperymentalnie w Javie 11 dla Linux i jest aktywnie rozwijany przez zespół Oracle.
Korzysta przede wszystkim z kolorowych wskaźników oraz barier. Kolorowe wskaźniki to koncepcja ukrycia pewnych informacji wewnątrz wskaźnika. Chodzi o to, że 64bitami możemy zaadresować w pamięci jakiś milion terabajtów danych, gdy aktualnie adresujemy maksymalnie kilka terabajtów. Zatem najwyższe bity zawsze będą zerami; można by je zagospodarować i przechowywać w nich jakieś informacje. Tak też się dzieje. Kosztem możliwości obsługi maksymalnie heapów o wielkości zaledwie 4TB nie mamy dodatkowego narzutu w każdym nagłówku obiektu.
W planach jest zwiększenie obsługi do 16TB, zmniejszenie maksymalnej pauzy do 1ms oraz wprowadzenie generacyjności.
A wracając do benchmarku – 306 055 939 ops/s (92% Epsilion GC).
Podsumowanie
Garbage Collectory to temat rzeka. Algorytmy składają się z wielu faz, istnieje wiele struktur danych wykorzystywanych przez gc. Nie sposób ich opisać w jednym wpisie na blogu…
Jeśli ktoś chciałby bardziej zgłębić temat gc
, to na początek proponuję Handbook od Plumbr. Czytanie o tym może być trudne, więc warto poszukać prezentacji na youtube o gckach. Po polsku na WJugu Jakub Kubryński, po angielsku na Devoxxie.
Wniosek jest prosty – nie ma nic za darmo… krótkie pauzy, to duży narzut na wydajność. Długie pauzy – brak narzutu na wydajność.
Pytanie na czym nam bardziej zależy…