Popraw wydajność aplikacji dzięki tym zaawansowanym technikom GC

Wydajność aplikacji jest na pierwszym planie naszych umysłów, a optymalizacja zbierania śmieci jest dobrym miejscem do robienia małych, ale znaczących postępów

Automatyczne zbieranie śmieci (wraz z kompilatorem JIT HotSpot) jest jednym z najbardziej zaawansowanych i najbardziej cenionych składników JVM, ale wielu programistów i inżynierów jest o wiele mniej zaznajomionych ze zbieraniem śmieci (GC), jak ono działa i jak wpływa na wydajność aplikacji.

Po pierwsze, po co w ogóle jest GC? Garbage collection jest procesem zarządzania pamięcią dla obiektów na stercie. Gdy obiekty są przydzielane do sterty, przechodzą przez kilka faz zbierania – zwykle dość szybko, ponieważ większość obiektów na stercie ma krótki czas życia.

Zdarzenia zbierania śmieci zawierają trzy fazy – zaznaczanie, usuwanie i kopiowanie/kompletowanie. W pierwszej fazie GC przechodzi przez stertę i zaznacza wszystko albo jako żywe (referencjonowane) obiekty, obiekty niereferencjonowane lub dostępne miejsce w pamięci. Nieodniesione obiekty są następnie usuwane, a pozostałe obiekty są zagęszczane. W pokoleniowych kolekcjach śmieci, obiekty „starzeją się” i są promowane przez 3 przestrzenie w swoim życiu – Eden, przestrzeń Survivor i przestrzeń Tenured (Old). To przesunięcie również występuje jako część fazy zagęszczania.

Ale dość o tym, przejdźmy do części rozrywkowej!

Psst! Szukasz rozwiązania, aby poprawić wydajność aplikacji? OverOps pomaga firmom zidentyfikować nie tylko kiedy i gdzie występują spowolnienia, ale dlaczego i jak one występują. Obejrzyj demo na żywo, aby zobaczyć, jak to działa.

Poznanie Garbage Collection (GC) w Javie

Jedną z najlepszych rzeczy w zautomatyzowanym GC jest to, że programiści nie muszą rozumieć, jak to działa. Niestety, oznacza to, że wielu programistów NIE rozumie jak to działa. Zrozumienie garbage collection i wielu dostępnych GC, jest trochę jak znajomość komend CLI Linuksa. Technicznie nie musisz ich używać, ale znajomość i wygoda korzystania z nich może mieć znaczący wpływ na twoją produktywność.

Tak jak w przypadku poleceń CLI, istnieją absolutne podstawy. Polecenie ls, aby wyświetlić listę folderów w ramach folderu nadrzędnego, mv, aby przenieść plik z jednej lokalizacji do innej, itd. W GC, te rodzaje poleceń byłyby równoważne z wiedzą, że istnieje więcej niż jeden GC do wyboru, i że GC może powodować problemy z wydajnością. Oczywiście, jest dużo więcej do nauczenia się (o używaniu CLI Linuksa ORAZ o zbieraniu śmieci).

Celem nauki o procesie zbierania śmieci w Javie nie jest tylko bezinteresowne (i nudne) rozpoczęcie rozmowy, celem jest nauczenie się, jak efektywnie wdrożyć i utrzymać właściwy GC z optymalną wydajnością dla konkretnego środowiska. Wiedza o tym, że zbieranie śmieci wpływa na wydajność aplikacji jest podstawowa, a istnieje wiele zaawansowanych technik zwiększania wydajności GC i zmniejszania jego wpływu na niezawodność aplikacji.

Zagrożenia związane z wydajnością GC

Przecieki pamięci –

Znając strukturę sterty i sposób, w jaki odbywa się zbieranie śmieci, wiemy, że wykorzystanie pamięci stopniowo wzrasta, aż do momentu wystąpienia zdarzenia zbierania śmieci i ponownego spadku wykorzystania. Wykorzystanie sterty dla obiektów referencjonowanych zwykle pozostaje stałe, więc spadek powinien być mniej więcej taki sam.

W przypadku wycieku pamięci, każde zdarzenie GC czyści mniejszą część obiektów sterty (chociaż wiele obiektów pozostawionych w tyle nie jest w użyciu), więc wykorzystanie sterty będzie nadal rosło, aż pamięć sterty będzie pełna i zostanie rzucony wyjątek OutOfMemoryError. Powodem tego jest fakt, że GC zaznacza do usunięcia tylko obiekty, do których nie ma odniesienia. Tak więc, nawet jeśli obiekt, do którego się odwołujemy nie jest już używany, nie zostanie usunięty ze sterty. Istnieje kilka pomocnych sztuczek kodowania, aby temu zapobiec, które omówimy nieco później.

Ciągłe zdarzenia „Stop the World” –

W niektórych scenariuszach, zbieranie śmieci może być nazywane zdarzeniem Stop the World, ponieważ kiedy występuje, wszystkie wątki w maszynie JVM (a zatem aplikacja, która jest na niej uruchomiona) są zatrzymywane, aby umożliwić GC wykonanie. W zdrowych aplikacjach czas wykonania GC jest stosunkowo niski i nie ma dużego wpływu na wydajność aplikacji.

W nieoptymalnych sytuacjach jednak zdarzenia Stop the World mogą znacznie wpłynąć na wydajność i niezawodność aplikacji. Jeśli zdarzenie GC wymaga pauzy Stop the World i jego wykonanie trwa 2 sekundy, użytkownik końcowy tej aplikacji doświadczy 2-sekundowego opóźnienia, ponieważ wątki działające w aplikacji są zatrzymywane, aby umożliwić GC.

Gdy występują wycieki pamięci, ciągłe zdarzenia Stop the World są również problematyczne. Ponieważ mniej miejsca w pamięci sterty jest oczyszczane z każdym wykonaniem GC, potrzeba mniej czasu, aby pozostała pamięć się zapełniła. Kiedy pamięć jest pełna, JVM wyzwala kolejne zdarzenie GC. Ostatecznie, JVM będzie uruchamiać powtarzające się zdarzenia Stop the World powodujące poważne problemy z wydajnością.

Użycie CPU –

I wszystko sprowadza się do użycia CPU. Głównym symptomem ciągłych zdarzeń GC / Stop the World jest skokowe zużycie CPU. GC jest operacją ciężką obliczeniowo, więc może zajmować więcej niż odpowiednią część mocy procesora. W przypadku GC, które uruchamiają współbieżne wątki, użycie CPU może być jeszcze wyższe. Wybór odpowiedniego GC dla twojej aplikacji będzie miał największy wpływ na użycie CPU, ale istnieją również inne sposoby optymalizacji w celu uzyskania lepszej wydajności w tym obszarze.

Na podstawie tych problemów z wydajnością związanych ze zbieraniem śmieci możemy zrozumieć, że niezależnie od tego, jak zaawansowane są GC (a są coraz bardziej zaawansowane), ich pięta achillesowa pozostaje taka sama. Nadmiarowe i nieprzewidywalne alokacje obiektów. Aby poprawić wydajność aplikacji, wybór odpowiedniego GC nie jest wystarczający. Musimy wiedzieć, jak działa ten proces i musimy zoptymalizować nasz kod tak, aby GC nie pobierał nadmiernych zasobów ani nie powodował nadmiernych przerw w działaniu aplikacji.

Generational GC

Zanim zagłębimy się w różne GC w Javie i ich wpływ na wydajność, ważne jest, aby zrozumieć podstawy generational garbage collection. Podstawowa koncepcja generacyjnego GC opiera się na założeniu, że im dłużej istnieje odniesienie do obiektu na stercie, tym mniej prawdopodobne jest, że zostanie on oznaczony do usunięcia. Oznaczając obiekty figuratywnym „wiekiem”, można je rozdzielić na różne przestrzenie przechowywania, aby były rzadziej oznaczane przez GC.

Gdy obiekt jest alokowany na stercie, jest umieszczany w tak zwanej przestrzeni Eden. To tam zaczynają się obiekty i w większości przypadków to tam są one oznaczane do usunięcia. Obiekty, które przetrwają ten etap „obchodzą urodziny” i są kopiowane do przestrzeni Survivor. Ten proces jest pokazany poniżej:

Przestrzenie Eden i Survivor tworzą tak zwaną Młodą Generację. To tutaj rozgrywa się większość akcji. Kiedy (jeśli) obiekt w Młodym Pokoleniu osiągnie określony wiek, zostaje przeniesiony do przestrzeni Tenured (zwanej również Old). Zaletą podziału pamięci Obiektów na podstawie wieku jest to, że GC może działać na różnych poziomach.

Minor GC to kolekcja, która skupia się tylko na Młodej Generacji, skutecznie ignorując przestrzeń Tenured. Generalnie, większość obiektów w Młodej Generacji jest przeznaczona do usunięcia i nie jest konieczne wykonywanie Głównego lub Pełnego GC (włączając w to Starą Generację), aby zwolnić pamięć na stercie. Oczywiście Major lub Full GC zostanie uruchomiony, gdy będzie to konieczne.

Jedną z szybkich sztuczek optymalizacji działania GC na podstawie tego jest dostosowanie rozmiarów obszarów sterty, aby najlepiej pasowały do potrzeb aplikacji.

Typy kolektorów

Istnieje wiele dostępnych GC do wyboru, i chociaż G1 stał się domyślnym GC w Javie 9, był pierwotnie przeznaczony do zastąpienia kolektora CMS, który jest Low Pause, więc aplikacje działające z kolektorami Throughput mogą być lepiej dostosowane do pozostania z ich obecnym kolektorem. Zrozumienie różnic operacyjnych i różnic w wpływie na wydajność dla kolektorów śmieci w Javie jest nadal ważne.

Kolektory przelotowe

Lepsze dla aplikacji, które muszą być zoptymalizowane pod kątem wysokiej przepustowości i mogą handlować wyższymi opóźnieniami, aby to osiągnąć.

Seryjny –

Seryjny kolektor jest najprostszy i najmniej prawdopodobne jest, że będziesz go używał, ponieważ jest przeznaczony głównie dla środowisk jednowątkowych (np. 32-bitowych lub Windows) i dla małych sterty. Kolektor ten może pionowo skalować wykorzystanie pamięci w JVM, ale wymaga kilku GC Major/Full, aby zwolnić niewykorzystane zasoby sterty. Powoduje to częste pauzy Stop the World, co dyskwalifikuje go dla wszystkich intencji i celów z użycia w środowiskach zorientowanych na użytkownika.

Parallel –

Jak opisuje jego nazwa, ten GC używa wielu wątków działających równolegle do skanowania i kompaktowania sterty. Chociaż Parallel GC używa wielu wątków do zbierania śmieci, nadal wstrzymuje wszystkie wątki aplikacji podczas pracy. Kolektor równoległy najlepiej nadaje się do aplikacji, które muszą być zoptymalizowane pod kątem najlepszej przepustowości i mogą tolerować większe opóźnienia w zamian.

Kolektory o niskiej pauzie

Większość aplikacji skierowanych do użytkownika będzie wymagać GC o niskiej pauzie, tak aby wrażenia użytkownika nie były zakłócane przez długie lub częste pauzy. W tych GC chodzi o optymalizację pod kątem responsywności (czas/zdarzenie) i silnej wydajności krótkoterminowej.

Concurrent Mark Sweep (CMS) –

Podobny do kolektora Parallel, kolektor Concurrent Mark Sweep (CMS) wykorzystuje wiele wątków do zaznaczania i zamiatania (usuwania) obiektów bez odniesienia. Jednakże, ten GC inicjuje zdarzenia Stop the World tylko w dwóch określonych przypadkach:

(1) podczas początkowego znakowania korzeni (obiektów w starej generacji osiągalnych z punktów wejścia wątku lub zmiennych statycznych) lub wszelkich referencji z metody main(), oraz kilku kolejnych

(2) gdy aplikacja zmieniła stan sterty podczas współbieżnego działania algorytmu, zmuszając go do powrotu i wykonania kilku ostatnich poprawek, aby upewnić się, że ma właściwe obiekty oznaczone

G1 –

Pierwszy kolektor śmieci (powszechnie znany jako G1) wykorzystuje wiele wątków tła do skanowania sterty, którą dzieli na regiony. Działa on poprzez skanowanie tych regionów, które zawierają najwięcej obiektów zaśmiecających jako pierwsze, nadając mu nazwę (Garbage first).

Strategia ta zmniejsza szansę na wyczerpanie sterty przed zakończeniem skanowania przez wątki tła w poszukiwaniu nieużywanych obiektów, w którym to przypadku kolektor musiałby zatrzymać aplikację. Kolejną zaletą kolektora G1 jest to, że kompaktuje on stertę na bieżąco, co kolektor CMS robi tylko podczas pełnych kolekcji Stop the World.

Poprawa wydajności GC

Wydajność aplikacji jest bezpośrednio zależna od częstotliwości i czasu trwania zbierania śmieci, co oznacza, że optymalizacja procesu GC odbywa się poprzez zmniejszenie tych metryk. Istnieją dwa główne sposoby, aby to zrobić. Po pierwsze, poprzez dostosowanie rozmiarów sterty młodych i starych generacji, a po drugie, zmniejszenie szybkości alokacji i promocji obiektów.

W kwestii dostosowania rozmiarów sterty, nie jest to tak proste, jak można by się spodziewać. Logicznym wnioskiem byłoby to, że zwiększenie rozmiaru sterty zmniejszyłoby częstotliwość GC przy jednoczesnym zwiększeniu czasu trwania, a zmniejszenie rozmiaru sterty zmniejszyłoby czas trwania GC przy jednoczesnym zwiększeniu częstotliwości.

Faktem jest jednak to, że czas trwania Minor GC zależy nie od rozmiaru sterty, ale od liczby obiektów, które przetrwają kolekcję. Oznacza to, że dla aplikacji, które głównie tworzą krótkotrwałe obiekty, zwiększenie rozmiaru młodego pokolenia może faktycznie zmniejszyć zarówno czas trwania GC, jak i częstotliwość. Jeśli jednak zwiększenie rozmiaru młodego pokolenia doprowadzi do znacznego wzrostu liczby obiektów wymagających skopiowania w przestrzeniach przetrwania, pauzy GC będą trwały dłużej, prowadząc do zwiększenia opóźnień.

3 Tips for Writing GC-Efficient Code

Tip #1: Predict Collection Capacities –

Wszystkie standardowe kolekcje Javy, jak również większość niestandardowych i rozszerzonych implementacji (takich jak Trove i Google’s Guava), używają bazowych tablic (opartych na prymitywach lub obiektach). Ponieważ tablice są niezmienne w rozmiarze po alokacji, dodawanie elementów do kolekcji może w wielu przypadkach spowodować, że stara tablica bazowa zostanie porzucona na rzecz większej nowo przydzielonej tablicy.

Większość implementacji kolekcji próbuje zoptymalizować ten proces ponownej alokacji i utrzymać go do zamortyzowanego minimum, nawet jeśli oczekiwany rozmiar kolekcji nie jest podany. Jednakże, najlepsze rezultaty można osiągnąć dostarczając kolekcji jej oczekiwany rozmiar podczas budowy.

Wskazówka #2: Przetwarzaj strumienie bezpośrednio –

Podczas przetwarzania strumieni danych, takich jak dane odczytane z plików lub dane pobrane przez sieć, na przykład, bardzo często można zobaczyć coś wzdłuż linii:

Wynikowa tablica bajtów może być następnie parsowana do dokumentu XML, obiektu JSON lub wiadomości Protocol Buffer, aby wymienić kilka popularnych opcji.

W przypadku dużych plików lub plików o nieprzewidywalnym rozmiarze, jest to oczywiście zły pomysł, ponieważ naraża nas na OutOfMemoryErrors w przypadku, gdy JVM nie jest w stanie zaalokować bufora o rozmiarze całego pliku.

Najlepszym sposobem podejścia do tego problemu jest użycie odpowiedniego InputStream (w tym przypadku FileInputStream) i podanie go bezpośrednio do parsera, bez uprzedniego odczytywania całości do tablicy bajtów. Wszystkie główne biblioteki udostępniają API do bezpośredniego parsowania strumieni, na przykład:

Tip #3: Use Immutable Objects –

Immutowalność ma wiele zalet. Jedną z nich, której rzadko poświęca się uwagę, na którą zasługuje, jest jej wpływ na zbieranie śmieci.

Obiekt niezmienny to obiekt, którego pola (a w szczególności pola nieprymitywne w naszym przypadku) nie mogą być modyfikowane po skonstruowaniu obiektu.

Immutowalność implikuje, że wszystkie obiekty, do których odwołuje się niezmienny kontener, zostały utworzone przed zakończeniem budowy kontenera. W terminologii GC: Kontener jest co najmniej tak młody, jak najmłodsza referencja, którą przechowuje. Oznacza to, że podczas wykonywania cykli odśmiecania na młodych generacjach, GC może pominąć niezmienne obiekty, które znajdują się w starszych generacjach, ponieważ wie na pewno, że nie mogą one odwoływać się do niczego w generacji, która jest zbierana.

Mniej obiektów do skanowania oznacza mniej stron pamięci do skanowania, a mniej stron pamięci do skanowania oznacza krótsze cykle GC, co oznacza krótsze przerwy GC i lepszą ogólną przepustowość.

Po więcej wskazówek i szczegółowych przykładów, sprawdź ten post obejmujący dogłębne taktyki pisania bardziej wydajnego pamięciowo kodu.

*** Ogromne podziękowania dla Amita Hurvitza z OverOps’ R&D Team za jego pasję i wgląd, który włożył w ten post!

.

Dodaj komentarz