Poprzedni wpis był o tworzeniu obiektów w JVMie w zależności od wybranego garbage collectora
. W tym wpisie skupię się na przedstawieniu wpływie wybranego gc
na odczyt istniejących obiektów.
Benchmark
Przed uruchomieniem Benchmarku tworzona jest statyczna tablica. Następnie w bloku statycznym jest ona wypełniana przykładowymi danymi. Tworzone obiekty to wrapper
y na int
.
Benchmark uruchamiany jest dla młodej oraz starej generacji (o ile istnieje taki podział). Aby wymusić przynależność do starej generacji JVM jest proszony o wykonanie GC kilkakrotnie.
private static final IntWrapper[] WRAPPERS = new IntWrapper[100000]; static { for (int i = 0; i < WRAPPERS.length; i++) { WRAPPERS[i] = new IntWrapper(i); } for (int i = 0; i < 20; i++) { // Runtime.getRuntime().gc(); } }
Następnie w benchmarku zliczana jest suma pierwszych 2000 wartości we wspomnianych obiektach.
@Benchmark public long method() { long sum = 0; for (int i = 0; i < 2000; i++){ sum += WRAPPERS[i].getI(); } return sum; }
UWAGA! Jak widać w powyższym benchmarku bazujemy jedynie na zmiennych lokalnych (czyt. trzymanych na stosie wątku). Jednocześnie w trakcie benchmarku nie tworzymy ani jednego nowego obiektu. Badamy jedynie wpływ gc
na odczyty, a nie wydajność samego gc
.
UWAGA 2! We wpisie zakładam, że czytelnik przeczytał poprzedni wpis, a także, że jego treść była w miarę zrozumiała. 😉
Epsilon GC
Na początek EpsilonGC
, czyli brak działań. Zarówno wynik dla wariantu bez gc oraz z wymuszonym gc
jest taki sam – około 1708 ns/op (wymuszanie gc
nie ma w tym przypadku sensu i jest ignorowane, stąd te same wyniki).
W tym wpisie uznaję ten wynik uznać za „wzorcowy” i do niego będę porównywał inne rezultatu.
ParallelGC
W przypadku ParallelGC
program działa nieprzerwanie aż do momentu braku miejsca na nowe obiekty (MinorGC
) lub braku miejsca na promocję obiektów do starej generacji (FullGC
). Wszystkie metadane niezbędne do gc
wyliczane są w czasie STW
bez dodatkowego narzutu na „runtime”. Zatem można spodziewać się wyniku analogicznego do EpsilonGC
.
Tak też się dzieje w przypadku obiektów w młodej generacji – w tym przypadku wynik to 1703 ns/op .
W przypadku starej generacji mamy niespodziankę – wynik 1579 ns/op (szybciej o 8%). Z czego wynika szybsze wykonanie benchmarku?
Otóż przy kopiowaniu danych z młodej do starej generacji wykonywana jest analiza, która pozwala na uporządkowanie danych w bardziej logiczny sposób niż przy tworzeniu obiektów. O ile wcześniej elementy tablicy mogły być rozdzielone innymi obiektami (szczególnie, że statyczna inicjalizacja działa się przy ładowaniu klas), o tyle po wykonaniu gc
elementy tablicy prawdopodobnie trzymane są razem.
ConcMarkSweepGC
W tym przypadku również młoda generacja wypada podobnie do EpsilonGc
– 1694 ns/op. W przypadku starej generacji wynik to 1837 ns/op, czyli 7% wolniej. Jednak podobnie jak w poprzednim wpisie zostawię tę analizę historykom, skoro w OpenJDK 14 CMSa już nie uświadczymy 😉
G1GC
W przypadku G1Gc
nie widać różnicy w porównaniu do EpsilonGc
– 1701 ns/op oraz 1698 op/s. Prawdopodobnie wynika to z tego, że zarówno młoda generacja, jak i stara znajdują się w takiej samej liczbie segmentów, które niekoniecznie muszą być zlokalizowane blisko siebie. Aczkolwiek, to tylko domysł, który trudno zweryfikować…
O ile G1Gc
korzysta z barier zapisu, o tyle z barier odczytu nie korzysta, stąd też brak dodatkowego narzutu na odczyt.
ZGC
Jak już wspominałem w poprzednim wpisie ZGC
korzysta z barier odczytu. Wspomniałem również, że aktualnie nie ma podziału na młodą i starą generację.
Oba te fakty widoczne są w wynikach benchmarku – 1996 ns/op w wersji z gc
oraz 2025 ns/op w wersji bez gc
. Zatem w tym benchmarku narzut spowodowany korzystaniem z ZGC
to 16%. Dość dużo, biorąc pod uwagę, że ten gc
nie został ani razu uruchomiony w czasie benchmarkowania…
Shenandoah
O ile Shenandoah
w OpenJDK 13 również nie posiada podziału o tyle wyniki dwóch benchmarków się znacząco różnią.
W przypadku braku wcześniejszych gc
, wynik jest porównywalny do EpsilonGC (~1700 ns/op).
W drugim przypadku – gdy został wcześniej wymuszony gc
– wynik był 4041 ns/op. To jest bardzo duży narzut – 136% więcej w porównaniu do EpsilonGC.
Taka różnica może wynikać z głównego założenia działania Shenandoah. Przed każdym obiektem zawsze znajduje się dodatkowy wskaźnik na ten obiekt, lub na nowe miejsce w pamięci, gdzie został ten obiekt przeniesiony. W pesymistycznym przypadku najpierw odczytujemy wartość wskaźnika (jednocześnie ładując do Cache’u procesora obiekt), po czym okazuje się, że załadowany obiekt nie jest tym, którego szukamy. Wówczas musimy pobrać obiekt z innej lokalizacji. Jednak wydaje się dość nieprawdopodobne, że taka sytuacja by była cały czas.
Trzeba pamiętać, że ten gc
jest w wersji 13 eksperymentalny. Plany są takie, by ten gc
mógł działać produkcyjnie w wersji 15. Zatem jest szansa, że ten przypadek będzie usprawniony.
Podsumowanie
Wybór Garbage Collectora może mieć wpływ nie tylko na samo czyszczenie pamięci, lecz również na działanie aplikacji. Garbage Collector może przyspieszyć wykonywanie kodu (przypadek ParallelGC
) lub spowolnić (Shenandoah
, ZGC
).
Warto wspomnieć, że Shenandoah
i ZGC
wejdą produkcyjnie dopiero w OpenJDK 15 zatem można oczekiwać pewnych poprawek.
Standardowo ludziom z nadmiarem czasu polecam prezentacje na Youtube. Bardzo dobre podsumowanie wydajności wykorzystania procesora oraz pamięci – Sergey Kuksenko z Oracle. Ponadto, dla ludzi chcących zaznajomić się z Shenandoah polecam prezentację Alexey’a Shipilev’a.
Przy okazji życzę wszystkiego dobrego na święta Wielkanocne 😉