W tym artykule opiszę sytuację jaką napotkałem podczas porównywania modułów Garbage Collector. W jednym z testów pojawiła się nieoczekiwana sytuacja, a dokładnie brak pamięci w wirtualnej maszynie Javy. Pomimo, że w pozostałych modułach GC test kończył się sukcesem.
Plan badań
Symulacja została przeprowadzona z wykorzystaniem Java wersji 8 i 11. Obie wersje zostały pobrane od podmiotu AdoptOpenJDK.
Java w wersji 8:
- openjdk version „1.8.0_292”,
- OpenJDK Runtime Environment (AdoptOpenJDK) (build 1.8.0_292-b10),
- OpenJDK 64-Bit Server VM (AdoptOpenJDK) (build 25.292-b10, mixed mode).
Java w wersji 11:
- openjdk version „11.0.11” 2021-04-20
- OpenJDK Runtime Environment AdoptOpenJDK-11.0.11+9 (build 11.0.11+9),
- OpenJDK 64-Bit Server VM AdoptOpenJDK-11.0.11+9 (build 11.0.11+9, mixed mode).
Do przeprowadzenia testów skorzystano z instancji Google Cloud z parametrami podanymi w tabeli poniżej. Testy zostały przeprowadzone na dwóch różnych odizolowanych instancjach o tych samych parametrach.
Typ maszyny | e2-standard-4 |
Pamięć RAM | 32 GB |
Procesor | Intel Xeon E5 v4 (Broadwell E5), 8 procesorów wirtualnych, bazowa częstotliwość 2.2GHz z przyspieszeniem do 2.8GHz |
System operacyjny | Canonical, Ubuntu, 20.04 LTS |
Dysk rozruchowy | 10GB |
Testy zostały wykonane przy użyciu tego samego kodu źródłowego. Zbudowane i uruchomione na odpowiedniej wersji Javy. Każdy test został uruchomiony z tą samą konfiguracją Javy:
- -Xmx2g – ustawia maksymalny rozmiar sterty na 2GB,
- -Xms2g – ustawia początkową wielkość sterty Java na 2GB,
- -XX:+AlwaysPreTouch – wstępnie wyzerowanie każdej strony sterty na żądanie podczas inicjalizacji maszyny JVM
- -XX:+UseG1GC – użycie G1GC
Konfiguracja narzędzia JHM dla wszystkich testów wygląda podobnie. Każdy test porównawczy wykorzystuje 5 iteracji rozgrzewających wirtualną maszynę Javy po 10 sekund i 15 iteracji pomiarowych po 10 sekund. Testy zostały wykonane tylko jeden raz z wykorzystaniem jednego wątku.
Algorytm
Zaprojektowano i poddano analizie test, który ma na celu porównanie zachowania modułów GC w przypadku alokowania małych jak i dużych obiektów na stercie. Algorytm zapełnia około 70% sterty obiektami o rozmiarach: 512B, 1KB, 2KB, 10KB, 100KB, 1MB, 2MB, 10MB i 100MB. Implementacja posiada dwie zawierające się w sobie pętle. W wewnętrznej pętli tworzone jest N obiektów i dodawane do lokalnej listy, aby zapełnić 70% sterty. Następnie lista jest przekazywana do innej metody, co oznacza, że obiekty już nie będą używane. Cały ten cykl powtarzany jest 4 razy, aby moduł do oczyszczania pamięci uruchomił się w trakcie testu.
@BenchmarkMode(value = {Mode.AverageTime}) @Fork(value = 1) @Warmup(iterations = 5) @Measurement(iterations = 15) @OutputTimeUnit(TimeUnit.MICROSECONDS) public class AllocationBenchmark { @State(Scope.Benchmark) public static class Plan { @Param({"512", "1024", "2048", "10240", "102400", "1048576", "2097152", "10485760", "104857600"}) public int size; public int numberOfObjects; @Setup(Level.Iteration) public void setUp() { final long maxHeap = Runtime.getRuntime().maxMemory(); numberOfObjects = (int) ((maxHeap * 0.70) / size); System.out.println("MaxHeap:" + maxHeap + ", numberOfObjects: " + numberOfObjects); } } @Benchmark public void fillHeap(Plan plan, Blackhole blackhole) { for (int iter = 0; iter < 4; iter++) { List<byte[]> objects = new ArrayList<>(plan.numberOfObjects); for (int i = 0; i < plan.numberOfObjects; i++) { objects.add(new byte[plan.size]); } blackhole.consume(objects); } } }
Problem
Podczas uruchamiania testu z parametrem do tworzenia tablic o rozmiarze 1MB wystąpił wyjątek java.lang.OutOfMemoryError: Java heap space, który oznacza, że zabrakło pamięci dla tworzonych obiektów. Szczegóły i listę aktywnych ramek stosu z omawianej sytuacji prezentuje poniższy zrzut ekranu.
Sytuacja miała miejsce w każdej wersji Java. Wspólną konfiguracją był moduł do odśmiecania pamięci z nieużywanych obiektów – G1 Garbage Collector. Aby podkreślić powagę sytuacji, to wyjątek powtórzył się nawet przy domyślnych ustawieniach w Java 11.
Rozwiązanie
Powodem zaistniałej sytuacji jest sposób działania modułu G1GC. Algorytm ma na celu wypełnić 70% pamięci obiektami rozmiaru 1MB. Warto zaznaczyć, że taka sytuacja miała miejsce tylko przy module G1. Opierając się na wiedzy z artykułu G1 Garbage Collector, przeanalizowanej literatury i działania testu możemy przeprowadzić następujące rozumowanie:
- Jeśli rozmiar sterty jest równy 2GB, to 70% z całkowitego rozmiaru to 1433MB.
- Jeśli celem jest zapełnienie 70% sterty obiektami o rozmiarze 1MB, to musiałoby powstać około 1433 obiektów.
- Jeśli całkowity rozmiar pamięci wynosi 2GB, to według przeanalizowanej literatury powinno powstać 2048 regionów o wielkości 1MB.
- Jeśli rozmiar obiektu jest 1MB, to taki obiekt jest większy od połowy rozmiaru regionu, czyli 512B. A więc obiekty tworzone w teście traktowane są jako Humongous object.
- Tablica, aby mogła być zaalokowana na stercie potrzebuje dodatkowo 16 bajtów pamięci dla nagłówka.
- Znając fakty, możemy wywnioskować, że rozmiar regionu jest wynikiem połączenia dwóch sąsiednich regionów, czyli obiekt znajduje się w regionie o rozmiarze 2MB.
- Cofając się do przeanalizowanej literatury, wiemy, że ogromny region może pomieścić tylko jeden ogromny obiekt.
- Jeśli algorytm chce zaalokować 1433 obiektów o rozmiarze 1MB + 16B nagłówka, to potrzebne jest co najmniej 1433 ogromnych regionów o wielkości 2MB.
Przeprowadzając takie rozumowanie, możemy wywnioskować, że algorytm próbował zaalokować 1433 obiektów na regionach o wielkości 2MB. Z czego udało mu się zaalokować część, ponieważ G1 dysponował tylko 1024 regionami (specjalne regiony) o wielkości 2MB, stąd brak pamięci. Z jednej strony ułatwia dostęp do takich obiektów dzięki czemu zyskujemy na wydajności, ale z drugiej strony należy mieć na uwadze, że w skrajnych przypadkach może marnować pamięć.