Ile kosztuje Garbage Collection – tworzenie obiektów

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 gckach 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 gckó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 Edenie 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. GCRooty (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…

Oceń wpis

Quarkus – co ciekawego siedzi w środku.

Nie wiem, czy śledzicie wpisy na JvmBloggers, jednak w poprzednim tygodniu były 4 wpisy CRUDowe o Quarkusie. Jeżeli wszyscy o tym piszą, to przecież nie będę gorszy 😉

Jeśli chcecie się dowiedzieć, jak napisać CRUDa to tutaj po polsku, a tu po angielsku. Ja osobiście zajmę się bezużyteczną wiedzą – dlaczego to jest fajne i jaki to ma potencjał. Ale od początku.

GraalVM

GraalVM jest to Oracle’owa maszyna wirtualna bazowana na OpenJDK. Jej celem było stworzenie uniwersalnej maszyny wirtualnej, która obsługuje wiele języków programowania – zarówno JVMowe (Java, Scala, Groovy), jak również skryptowe (Js, Ruby, Python, R) i natywne (C++). Pozwala również na bardzo wydajne wywoływania funkcji zdefiniowanych w różnych językach (takie cross-językowe wywołania).

Jednak stworzenie takiej VMki to była długa droga… Najwięcej działo się przy releasie JDK 9. Wtedy położono fundamenty pod GraalVM – wydzielono w źródłach JDK interfejs kompilatora, poukładano projekt OpenJDK modularnie oraz wprowadzono kompilator Ahead-of-time. Skoro już był wydzielony interfejs kompilatora, to można było wprowadzić nowy kompilator – modularny, piękny i pachnący, o wdzięcznej nazwie Graal. W dodatku cały napisany w Javie (bo kto umie w C++, gdzie nawet GCka nie ma). Uczyniono to w JDK 10 w JEP 317. Tenże kompilator Graal jest w niektórych względach lepszy od C2 – z pewnością lepiej radzi sobie ze różnego rodzaju streamami. Swego czasu popularne było stwierdzenie, że Twitter przeszedł z C2 na Graala i zjadało mu 5% procesora mniej. Jakkolwiek mowa była o Scali, nie o Javie.

W końcu GraalVM wyszedł w maju zeszłego roku. W założeniu miał pozwalać na łatwe wywoływanie międzyjęzykowych funkcji. Standardowym use case’m miało być wydajne wywoływanie funkcji Machne Learningowych napisanych w R lub Pythonie (jak wiadomo Java w tej dziedzinie nie jest zbyt popularna). Innym przykładem może być jednokrotne tworzenie walidacji po stronie frontendu oraz wywoływanie tej walidacji także na backendzie (pozdro Apzumi! 😉 ). Jednak osobiście moim faworytem jest narzędzie do generowania kodu natywnego.

GraalVM Native Image

Narzędzie to pozwala na stworzenie łatwo uruchamianej aplikacji napisanej w Javie skompilowanej do wersji natywnej dla systemu operacyjnego. W pierwszej wersji (19) wspierano tylko Linuxa i MacOSa, w wersji 20 wpierany jest również Windows. GraalVM jest dostępny z JDK w wersji 8 jak i 11. Kompilacja do kodu natywnego jest wykonywana z użyciem wspomnianego wcześniej kompilatora Graal.

W czasie kompilacji do kodu natywnego ma miejsce stworzenie grafu zależności między klasami, polami, metodami, dzięki którego analizie wszystkie nieużywane pola, klasy, metody są usuwane. Wycięcie 90% platformy powoduje, że aplikacja wstaje w milisekundy zamiast sekund. Dodatkowo zużywa znacznie mniej pamięci (mierzone w dziesiątkach megabajtów zamiast setek). Choć na kompilację trzeba poświęcić kilka minut…

Co więcej – GraalVM Native Image nie wspiera wszystkich opcji dostępnych dla standardowego JDK. Brakuje między innymi MethodHandles, InvokeDynamic, serializacji, security managera. W przypadku użyciu refleksji, DynamicProxy, JNI lub dynamicznego ładowania klas wymagana jest rejestracja używanych klas w osobnym pliku konfiguracyjnym. Więcej o ograniczeniach można poczytać na tej podstronie.

Jest kilka rzeczy, które są robione inaczej. Najlepszym przykładem jest wykonywanie inicjalizacji klas na etapie kompilacji źródeł. Dzięki temu GraalVM uruchamia się szybciej. Czasem może jednak to prowadzić do dziwnych błędów. Inną różnicą jest to, że przekazywane propertiesty przy kompilacji są dostępne dla klas inicjalizowanych na etapie kompilacji, a na etapie runtime już nie.

GraalVM umożliwia również wydawanie natywnych bibliotek do systemów operacyjnych, co wcześniej w Javie nie było możliwe.

Jak to w środku działa?

W miarę normalnie – mamy normalny Heap, który co jakiś czas jest odśmiecany. Z tego gdzieś mi się obiło o oczy, a czego znaleźć aktualnie nie potrafię są dostępne 2 GCki. Jeden jest pewną odmianą Parallel GC, drugi jest z rodziny Lowlatency.
Przy uruchamianiu normalnie definiujemy parametry -Xmx oraz -Xms (gdyż normalnie bierze 1/4 całej pamięci 😉 ). Debugować się nie da, chyba, że z użyciem debuggerów natywnych takich jak GDB. VisualVM bodajże jest, choć dla Native Image jest ograniczony. Heap Dump można wykonać, lecz tylko w wersji Enterprise Edition, gdyż z czegoś Oracle musi żyć.

Ciekawe jest to, że na użytek inicjalizacji klas w czasie budowania i obiektów w nim stworzonych jest osobny tzw. Image Heap.

A co na tym można uruchomić?

Z pewnością nie Springa. Bazuje on bowiem z założenia na refleksji, generowaniu bytecode’u, proxy i innych. Nawet gdyby się dało uruchomić, byłoby to wciąż wolne.

Dlatego specjaliści od Red Hata postanowili stworzyć Quarkusa. Sam Quarkus jest nakładką na Vert.x zapewniającą odpowiednie działanie dla GraalVM Native Image. Dodatkowo istnieje wiele „wtyczek” do Quarkusa, w których głównym założeniem jest wykorzystanie pełni potencjału GraalVMa.

Przykładowo mechanizm Dependency Injection ogarnięte jest w czasie kompilacji zamiast w runtime. Inicjalizacja „kontrolerów” webowych również w czasie inicjalizacji.

Nawet Hibernate jest porządnie zoptymalizowany dzięki wtyczce do Quarkusa. A gdy zmigrują Hibernate na Jandexa, wówczas i część inicjalizacji metadanych możnaby przenieść na czas kompilacji.

Podobne pole do optymalizacji widzę również w Jacksonie. Już teraz można cache’ować metadane obiektów w zewnętrznych implementacjach. Nie sprawdzałem jednak, czy w Quarkusie tak się aktualnie dzieje.

Nawet jeśli jeszcze istniejące rozwiązania nie są optymalne, to:
1. Projekt ma mniej niż rok, więc spokojnie 😉
2. Już teraz aplikacje uruchamiają się w czasie mierzonym w milisekundach, więc raczej będzie tylko lepiej.

Czy to działa?

Osobiście w 2 wieczoro/noce zmigrowałem prywatny projekcik z Tomcata, Spring MVC, Hibernate (Criteria API), SpringFilter, Quartz na Quarkusa z Hibernate (JPA Criteria Api) oraz ichniejszego Crona.
Trochę brakuje Spring Data, ale da się żyć.
Działa, nie padło, działa szybciej, wstaje szybciej, zjada znacznie mniej pamięci. Czyli dla prywatnych projekcików postawionych na VPSie działa idealnie 😉

Czy działa gdzieś produkcyjnie? Nie wiem… Ale pewnie internet wie… Spytajcie Googla, może coś powie na ten temat!

Mowa końcowa

Quarkus ma potencjał. Jak nie do małych projekcików, to do lambd wstających w milisekundy.
Migracja ze Springa na Quarkusa nie jest trudna, lecz nieco uprzykrzająca życie ze względu na inne adnotacje. Micronaut ma bardziej zbliżone Api do Springa.

Dla chętnych prezentacja po polsku o Quarkusie z Warszawskiego JUGa przedstawiana przez jednego z jego twórców.

Zostawcie gwiazdki, komcie i inne takie.

Pax!

Oceń wpis