W ramach pierwszego technicznego wpisu postanowiłem zagłębić się w temat Garbage Collectorów. Praktycznie każdy programista pracujący z technologiami stworzonymi w oparciu o JVM słyszał o GC – że jest to mechanizm, który pracuje w tle, sprząta po nas, i… to w zasadzie tyle. Oczywiście mocno to uogólniam, ale prawdą jest, że jest to rodzaj specjalistycznej wiedzy, niewymaganej w przypadku większości stanowisk developerskich. Nie znaczy to oczywiście, że znajomość pracy GC jest nieprzydatna. Wręcz przeciwnie! Jest kluczowa, jeżeli chcemy „podkręcić” działanie aplikacji.
Post ten jest tylko wprowadzeniem do tematu. Różne algorytmy działania „odśmiecacza pamięci” (czy tylko mnie gryzie ta polska nazwa? :)) postaram się opisać w osobnych wpisach.
Czym jest Garbage Collector?
Garbage Collector to program, którego głównym zadaniem jest usuwanie z pamięci nieużywanych obiektów. Gdyby nie jego działanie, sterta, na którą trafiają nowo tworzone obiekty, szybko by się zapełniała i tym samym uniemożliwiała dalsze funkcjonowanie aplikacji. Często nie zdajemy sobie sprawy ze skali tego problemu, a wystaczy zerknąć na pierwszy lepszy przykład.
BigDecimal sum(List<BigDecimal> numbers) {
BigDecimal result = BigDecimal.ZERO;
for (BigDecimal number : numbers) {
result = result.add(number);
}
return result;
}
BigDecimal jest immutable, w związku z czym wszelkie operacje arytmetyczne związane z tą klasą zwracają jako rezultat działania nowe obiekty. Powyższa metoda wywołana dla listy tysiąca elementów dołoży do sterty (za sprawą metody add) co najmniej tyle samo nowych instancji BigDecimal. Gdyby nie Garbage Collector, powtarzające się wykonania sumzapchałyby w końcu przestrzeń adresową programu i zakończyły jego działanie z wypisanym na konsoli OutOfMemoryError.
Zasada działania
Różne implementacje maszyny wirtualnej Javy stosują różne podejście w kwestii automatycznego zarządzania pamięcią. Opis działania zamieszczony poniżej dotyczy HotSpot JVM, czyli maszyny wirtualnej dostarczanej przez firmę Oracle. Wybór tej implementacji podyktowany jest tym, że jest ona po prostu najpopularniejsza.
HotSpot oferuje cztery algorytmy GC: Serial, Parallel, CMS (Concurrent Mark-Sweep), Garbage First (G1). Szczegóły implementacyjne każdego z nich opiszę w późniejszych wpisach. Póki co jednak spojrzymy na nie z dalszej perspektywy, ponieważ o ile pomiędzy poszczególnymi algorytmami istnieją poważne różnice, tak wszystkie z nich działają według podobnego schematu.
W pierwszym kroku Garbage Collector znajduje wszystkie żywe obiekty. W drugim usuwa pozostałe i ewentualnie reorganizuje pamięć programu, aby uniknąć problemu fragmentacji.
Problem fragmentacji
Zanim przyjrzymy się z bliska procesom znajdowania i usuwania obiektów, zatrzymajmy się na chwilę przy problemie fragmentacji.
W miarę tworzenia nowych obiektów zabieramy coraz więcej miejsca ze sterty. Pamięć alokowana jest w sposób ciągły i, tuż przed wkroczeniem do akcji Garbage Collectora, wygląda jak na obrazku poniżej.
Gdyby GC nie przejmował się fragmentacją i zakończył swoją pracę po zwolnieniu pamięci, to mogłoby się okazać, że struktura sterty jest mocno „podziurawiona”.
Jest to bardzo zła sytuacja z co najmniej dwóch powodów. Po pierwsze, kiedy tworzymy nowy obiekt, to JVM spędza więcej czasu na szukaniu odpowiedniego obszaru do alokacji. Po drugie, może być tak, że żaden z wolnych bloków nie jest na tyle duży, by ten obiekt zmieścić.
Szukanie żywych obiektów
Garbage Collector Roots to obiekty osiągalne spoza sterty, czyli takie, do których możemy odwołać się w sposób bezpośredni, a nie tylko poprzez łańcuch referencji.
SystemUser user = new SystemUser("mmarczak");
user.creationDate = LocalDateTime.now();
W powyższym przykładzie user traktowany jest jako GC Root, ale user.creationDate już nie, ponieważ aby użyć tego obiektu potrzebujemy referencji do usera.
Kiedy JVM uzna, że należy wykonać cykl GC, to w ramach szukania żywych obiektów najpierw zdefinuje listę takich GC Roots, a następnie przeszuka zakorzenione w nich drzewa referencji i zaznaczy wszystkie napotkane obiekty jako osiągalne. Te, których w tym czasie nie oznaczy, zostaną w późniejszym etapie usunięte ze sterty.
GC Roots to na przykład zmienne lokalne i parametry metody wykonywanej w momencie uruchomienia Garbage Collectora, aktywne wątki, zmienne statyczne załadowanych klas (oczywiście pod warunkiem, że ich classloadery same w sobie są osiągalne), zasoby JNI.
Generacje
Gdyby GC miał za każdym razem przeglądać w ten sposób wszystkie zaalokowane obiekty, to czas spędzony przez niego na wykonywaniu odśmiecania miałby poważny wpływ na wydajność aplikacji. Aby zaradzić temu problemowi, twórcy JVM w trakcie projektowania struktury sterty wzięli pod uwagę prostą obserwację, że większość obiektów zaraz po utworzeniu jest niepotrzebna (przykład z początku tego wpisu dotyczący instancji klasy BigDecimal dobrze to obrazuje) i zdecydowali się podzielić ją na obszary zwane generacjami.
Young Generation to obszar, w którym żyją nowe obiekty. Dzieli się na trzy strefy: eden, survivor one, oraz survivor two. Kiedy tworzymy nowy obiekt, pamięć przeznaczona dla niego alokowana jest w strefie eden. Wraz z działaniem aplikacji strefa ta zapełnia się coraz bardziej, aż w końcu jest w niej na tyle mało miejsca, że do akcji wkracza Garbage Collector. Garbage Collector przeprowadza marking edenu i przenosi do survivor one żywe obiekty, jednocześnie odrzucając te nieosiągalne. Cykl zaczyna się od nowa, z tą różnicą, że GC analizuje teraz również strefę survivor one, a na koniec przenosi wszystkie żywe obiekty do survivor two. W każdej następnej iteracji role stref survivor się odwracają. Takie podejście rozwiązuje problem fragmentacji, ponieważ eden nieustannie ulega całkowitemu czyszczeniu.
Obiekty ze stref survivor, które przetrwały w nich wystarczająco długo, przenoszone są do obszaru nazwanego Old Generation. Jest on znacznie większy w porównaniu do Young Generation*, dlatego też GC odbywa się w nim rzadziej. Old Generation nie podlega takiemu samemu podziałowi na strefy jak Young Generation, a sposób jego czyszczenia zależy od implementacji Garbage Collectora.
Procesy czyszczenia generacji Young i Old nazywają się odpowiednio Minor GC i Major GC.
Usuwanie nieosiągalnych obiektów
Istnieją trzy główe strategie dotyczące usuwania nieosiągalnych obiektów. Możemy:
oznaczyć zajmowane przez nie obszary pamięci jako wolne do alokacji
zrobić to, co wyżej i dodatkowo przesunąć żywe obiekty w powstałe wolne miejsca, aby uniknąć problemu fragmentacji
skopiować wszystkie żywe obiekty do nowego obszaru pamięci
Przykład implementacyjny ostatniej strategii został przedstawiony przy okazji definiowania czym jest Young Generation. Prawie wszystkie algorytmy przeprowadzają w ten sposób Minor GC. Major GC natomiast zależy od konkretnej wersji algorytmu.
Podsumowanie
Jest to koniec części wprowadzającej do tematu Garbage Collectorów. O ile pomiędzy poszczególnymi algorytmami GC istnieją poważne różnice, tak opisane tutaj zagadnienia stanowią swoistego rodzaju fundament ich działania.
*Jak zauważył w komentarzu Bartek Kowalczyk, dzięki tzw. Adaptive Size Policy rozmiary generacji mogą się zmieniać w zależności od potrzeb aplikacji.