Jak działa JVM od środka: stos, sterta, Eden i JIT

Jak działa program w Javie?

Zanim przejdę do omawiania jak Java zarządza pamięcią, warto zrozumieć, co właściwie dzieje się od momentu napisania kodu Java do jego wykonania przez procesor.

Kod napisany w Javie nie jest wykonywany bezpośrednio przez system operacyjny. Najpierw plik źródłowy z rozszerzeniem .java jest kompilowany przez kompilator javac do tzw. bytecode’u, czyli kodu pośredniego zapisywanego w plikach .class. Bytecode nie jest jeszcze kodem maszynowym charakterystycznym dla konkretnego procesora. Jest to zestaw instrukcji przeznaczonych dla Java Virtual Machine (JVM).

Dzięki temu ten sam skompilowany program może zostać uruchomiony na różnych systemach operacyjnych, pod warunkiem że znajduje się na nich odpowiednia implementacja JVM. To właśnie stąd pochodzi znane hasło Javy: Write Once, Run Anywhere.

Po uruchomieniu aplikacji JVM ładuje potrzebne klasy i zaczyna wykonywać bytecode. Początkowo część instrukcji może być wykonywana przez interpreter JVM. W trakcie działania programu JVM analizuje jednak, które fragmenty kodu są wykonywane szczególnie często.

Takie fragmenty określa się jako hot code.

Wtedy do działania włącza się JIT (Just-In-Time Compiler), który może skompilować często wykonywany bytecode bezpośrednio do kodu maszynowego procesora. Dzięki temu kolejne wykonania danego fragmentu kodu mogą być znacznie szybsze.

Kompilacje kodu można opisać w taki sposób:

.java → javac → .class → JVM → JIT → kod maszynowy

Zarządzanie pamięcią w Java

Jednym z najważniejszych zadań JVM jest zarządzanie pamięcią używaną przez aplikację. Programista w Javie nie musi ręcznie przydzielać i zwalniać pamięci, tak jak ma to miejsce w starszych językach programowania (np. C++ lub Assembler). Procesem tym zajmuje się wirtualna maszyna Javy.

Podczas uruchamiania programu JVM wydziela kilka obszarów pamięci, które pełnią różne funkcje. Najważniejsze z nich to:

  • Heap – obszar, w którym przechowywana jest większość tworzonych obiektów,
  • Stack – pamięć związana z wykonywaniem metod i lokalnymi danymi poszczególnych wątków,
  • Metaspace – obszar przechowujący informacje dotyczące załadowanych klas,
  • Code Cache – pamięć wykorzystywana między innymi do przechowywania kodu maszynowego wygenerowanego przez JIT.

W uproszczeniu pamięć wykorzystywana przez JVM można przedstawić w następujący sposób:

jvm memory

Omówmy teraz jak te obszary są zarządzane.

Heap (czyli sterta) jest współdzielony pomiędzy wątki aplikacji i to właśnie tutaj działa Garbage Collector, który wyszukuje obiekty, do których program nie może już dotrzeć, a następnie może odzyskać zajmowaną przez nie pamięć.

Stack (czyli stos) działa inaczej. Każdy wątek otrzymuje własny stos. Znajdują się na nim między innymi informacje związane z aktualnie wykonywanymi metodami, ich argumentami i zmiennymi lokalnymi. Dane zapisane na stosie są automatycznie usuwane wraz z zakończeniem wykonywania danej metody.

Przykładowo:

public void createUser(int value) {
    int age = value;
    User user = new User("Jan", age);
}

Rozłóżmy jak działa pamięć w tym kodzie na czynniki pierwsze. Zmienna age i argument value lądują na stosie (który zostaje utworzony dla tej metody), Następnie tworzona jest zmienna user. Jest to lokalna referencja związaną z wykonywaną metodą, natomiast sam obiekt User jest alokowany na stercie.

W kolejnych rozdziałach przyjrzę się dokładniej poszczególnym elementom pamięci JVM: Stack, Heap, Eden Space oraz sposobowi, w jaki Garbage Collector zarządza cyklem życia obiektów.

Stack – stos w Javie

Każdy wątek w aplikacji Java posiada własny stos, dzięki czemu dane związane z wykonywaniem kodu w jednym wątku są odseparowane od stosów pozostałych wątków. Natomiast, gdy wywoływana jest metoda, na stosie tworzony jest tzw. stack frame. Jest to fragment stosu przechowujący informacje potrzebne do wykonania konkretnej metody, między innymi jej argumenty, zmienne lokalne oraz dane potrzebne JVM do powrotu do miejsca, z którego metoda została wywołana.

Podczas wykonywania znanej z poprzedniego akapitu metody createUser na stosie znajdą się między innymi zmienna age oraz referencja user. Sam obiekt User będzie natomiast zazwyczaj znajdował się na stercie, czyli w obszarze Heap.

Stos działa zgodnie z zasadą LIFO – Last In, First Out, czyli „ostatni wszedł, pierwszy wyszedł”. Oznacza to, że ostatnio wywołana metoda musi zakończyć się jako pierwsza.

Przykład:

public void methodA() {
    methodB();
}

public void methodB() {
    methodC();
}

public void methodC() {
    System.out.println("Hello");
}

W tym kokretnym przypadku najpierw wykona się methodC, później methodB, a na końcu methodA.

Rozmiar stosu jest ograniczony. Jeżeli program utworzy zbyt wiele kolejnych stack frame’ów, może zabraknąć miejsca i Java zgłosi słynny błąd: java.lang.StackOverflowError, czyli przepełnienie stosu. Najczęściej dzieje się tak przy pętlach, które wykonują bardzo dużo iteracji lub przy rekurencji.

Heap – sterta

Heap, czyli sterta, to obszar pamięci JVM, w którym przechowywana jest większość obiektów tworzonych podczas działania aplikacji. W przeciwieństwie do stosu, który jest przypisany do konkretnego wątku, Heap jest współdzielony przez wszystkie wątki aplikacji. Oznacza to, że różne wątki mogą posiadać referencje do tych samych obiektów znajdujących się na stercie.

Obiekt pozostaje w pamięci tak długo, jak długo JVM może do niego dotrzeć poprzez istniejące referencje. Jeżeli obiekt przestaje być osiągalny, może zostać uznany przez Garbage Collector za nieużywany i jego pamięć może zostać później odzyskana.

Generacje pamięci

Heap nie jest traktowany przez JVM jako jeden jednolity obszar pamięci. W klasycznym modelu generacyjnym obiekty są dzielone na grupy w zależności od tego, jak długo istnieją w pamięci.

Najczęściej wyróżnia się dwie główne generacje:

Young Generation – przeznaczoną przede wszystkim dla nowych i krótkotrwałych obiektów,
Old Generation – przeznaczoną dla obiektów, które przetrwały dłużej.

Young Generation można dodatkowo podzielić na:

  • Eden Space,
  • Survivor Space S0,
  • Survivor Space S1

Podział ten zakłada ona, że większość obiektów tworzonych przez aplikację istnieje bardzo krótko.

Young Generation

Young Generation to obszar przeznaczony przede wszystkim dla nowych obiektów.

W klasycznym modelu większość nowo tworzonych obiektów trafia początkowo do Eden Space:

User user = new User("Jan", 30);

Jeśli w tym obszarze zaczyna brakować miejsca to JVM może uruchomić specjalny GC przeznaczony do sprzątania młodej generacji. Obiekty, które nie są już osiągalne, mogą zostać usunięte, natomiast te, które nadal są używane, mogą zostać przeniesione do jednego z obszarów Survivor. S0 i S1 nie różnią się przeznaczeniem. To dwa równorzędne obszary Survivor, które JVM wykorzystuje naprzemiennie.

Old Generation

Old Generation to część pamięci Heap przeznaczona głównie dla obiektów, które żyją dłużej niż typowe obiekty tymczasowe.

Przykładem obiektów, które mogą żyć długo, są:

  • elementy cache,
  • singletony,
  • konfiguracja aplikacji,
  • obiekty trzymane przez kolekcje,
  • obiekty używane przez aplikację przez wiele sekund, minut albo nawet przez cały jej czas działania

Jak działa Garbage Collector

Garbage Collector (GC) to mechanizm JVM odpowiedzialny za automatyczne zarządzanie pamięcią. Jego głównym zadaniem jest wykrywanie obiektów, do których aplikacja nie może już dotrzeć, a następnie odzyskiwanie zajmowanej przez nie pamięci.

Skąd GC wie, że obiekt nie jest już potrzebny?

Garbage Collector nie analizuje kodu i nie próbuje przewidzieć, czy programista „jeszcze będzie używał” danego obiektu. Zamiast tego JVM sprawdza, czy do obiektu można dotrzeć z tzw. GC Roots.

GC Roots to punkty startowe, od których Garbage Collector rozpoczyna analizę grafu obiektów. Takimi punktami startowymi mogą być metody, od których pracę zaczyna wątek. Każdy kolejny zagnieżdżony obiekt tworzy kolejny liść w grafie.

Garbage Collector nie usuwa obiektów natychmiast, tylko oznacza dany obiekt, jako kandydat do usunięcia. JVM sama decyduje, kiedy uruchomić procesu sprzątania pamięci. Zależy to między innymi od używanego algorytmu GC, ilości dostępnej pamięci oraz aktualnego obciążenia aplikacji.

Stop-The-World

Część operacji Garbage Collectora wymaga chwilowego zatrzymania wykonywania wątków aplikacji. Taką przerwę określa się jako Stop-The-World pause. W tym czasie aplikacja nie wykonuje normalnego kodu biznesowego, ponieważ JVM musi bezpiecznie przeanalizować lub przemieścić obiekty w pamięci. Nowoczesne Garbage Collectory starają się ograniczać długość takich pauz, wykonując część pracy współbieżnie z działaniem aplikacji.

Popularne Garbage Collectory

W JVM dostępnych jest kilka różnych rodzajów Garbage Collectora. Do najważniejszych należą:

  • G1 GC – domyślny od JDK 9 (kompromis między szybkością a długością pauz)
  • ZGC – większość pracy wykonywana współbieżnie przy minimalnych pauzach (przy aplikacjach wymagających bardzo niskiej latencji)
  • Parallel GC – nie próbuje za wszelką cenę minimalizować pauz. Podczas kolekcji może zatrzymać aplikację, ale wykorzystuje wiele wątków GC jednocześnie, aby wykonać sprzątanie możliwie szybko.
  • Serial GC- do wykonywania pracy związanej z Garbage Collection wykorzystuje jeden wątek. Podczas kolekcji aplikacja jest zatrzymywana, a jeden wątek GC wykonuje potrzebne operacje. Dobry do prostych aplikacji

Czym jest JIT – Just-In-Time Compiler?

Na koniec warto przyjrzeć się, czym jest JIT (Just-In-Time Compiler). To element JVM odpowiedzialny za kompilowanie często wykonywanego bytecode’u do kodu maszynowego, który może być bezpośrednio wykonywany przez procesor. Nie pomyl go z javac, który kompiluje kod z pliku .java do pliku .class.

JIT fukncjonuje w pomencie działania interpretera bytcode’u przez JVM. Interpreter czy kod z pliku .class i stara się przetłumaczyć go na język maszynowy (możliwy do odczytania przez procesor). Jednak w kodzie często znajdują się fragmenty, które się powtarzają, przez co translacja linia po linii może być czasochłonna. Na pomoc tutaj przychodzi JIT, który potrafi zoptymalizować odczyt kodu, tak aby nie powtarzać podobnych fragmentów, gdy to nie jest konieczne.

Przykładowo:

for (int i = 0; i < 1_000_000; i++) {
    calculate();
}

Gdyby JVM za każdym razem interpretował metodę calculate(), oznaczałoby to ciągłe wykonywanie tej samej pracy. Dlatego wirtualna maszyna Javy monitoruje działanie aplikacji i identyfikuje fragmenty kodu wykonywane szczególnie często. Są one nazywane hot code lub hot spots. Takie fragmenty mogą zostać przekazane do JIT.

Warto tu podkreślić, że sam JIT działa w runtime, czyli podczas działania aplikacji.

Podsumowanie

Pamięć w Javie jest zarządzana przez JVM, która odpowiada między innymi za organizację obszarów takich jak Stack, Heap, Eden, Survivor czy Old Generation. Stos przechowuje dane związane z wykonywanymi metodami, natomiast na stercie znajduje się większość obiektów tworzonych przez aplikację.

Za odzyskiwanie nieużywanej pamięci odpowiada Garbage Collector, który analizuje, czy do obiektów nadal można dotrzeć z GC Roots. W zależności od używanego collectora JVM może stosować różne strategie, np. G1, ZGC, Parallel GC czy Serial GC.

Drugim ważnym mechanizmem JVM jest JIT Compiler, który działa w runtime i kompiluje często wykonywany bytecode do kodu maszynowego. Dzięki temu JVM może nie tylko uruchamiać ten sam bytecode na różnych platformach, ale również optymalizować kod na podstawie rzeczywistego zachowania aplikacji.

Zrozumienie pamięci JVM, Garbage Collectora i JIT pozwala lepiej diagnozować problemy z wydajnością, OutOfMemoryError, StackOverflowError czy zbyt długimi pauzami GC.

Dodaj komentarz