Java Developer? Przejdź na wyższy poziom wiedzy 🔥💪  Sprawdź

Team Leader? Podnieś efektywność swojego zespołu 👌 Sprawdź

Co każdy programista Java powinien wiedzieć o JVM

utworzone przez Java

Dla większości programistów Java najbardziej podstawowym elementem związanym z codzienną pracą jest Maszyna Wirtualna Javy, w skrócie JVM (Java Virtual Machine). Pomimo kluczowej roli dla programisty Java, jaką odgrywa JVM, jest ona często pomijana i traktowana jak „czarna skrzynka”, która po prostu działa. W tym artykule przyjrzymy się bardziej szczegółowo podstawom działania JVM.

Z tego artykułu dowiesz się

  • Co to jest JVM?
  • Jak wygląda cykl wykonania programu Java?
  • Czym jest JIT (Just in Time Compiler)?

Czym jest JVM?

JVM – mówiąc wprost to po prostu aplikacja. Co prawda nie jest to zwykła aplikacja. Implementuje bardzo złożone algorytmy pozwalające na interpretacje byte code, optymalizację kodu, czy odśmiecanie pamięci. JVM napisana jest w większości za pomocą języków takich jak C++. Aby zrozumieć ideę pracy JVM, powinniśmy wrócić do podstaw i zadać sobie pytanie – czym tak naprawdę jest JAVA?

Idea, jaka przyświecała twórcom języka Java była mniej więcej taka „Napisz raz, uruchamiaj wszędzie” (Write Once, Run Anywhere – WORA). Aby to osiągnąć, twórcy języka Java potrzebowali jakiejś dodatkowej warstwy abstrakcji nad kodem binarnym, dzięki czemu mogliby się uniezależnić od konkretnej platformy sprzętowej. Gdyby zrobić taki twór, żyjący pomiędzy kodem maszynowym a aplikacją – teoretycznie możliwe byłoby uruchamianie kodu na dowolnej maszynie (MAC, Windows, Linux, czy nawet IoT). Nie potrzebne byłoby kompilowanie aplikacji pod różne środowiska i sprzęt.

Tym tworem jest nasz JVM. Interpretuje on kod bajtowy Javy (Java bytecode) w sposób niezależny od środowiska, na którym jest zainstalowany. Co warte zaznaczenia – sama maszyna wirtualna Javy nie jest zainteresowana językiem źródłowym, z którego powstał bytecode. Dlatego właśnie na JVM możemy uruchamiać programy napisane m.in. w:

  • Java
  • Scala
  • Kotlin
  • Grovy
  • Jakikolwiek język, który jest kompilowany do kodu bajtowego Javy

Obszerna dokumentacja JVM została opisana w dokumencie „The Java® Virtual Machine Specification”. Najbardziej aktualna wersja dostępna jest pod linkiem https://docs.oracle.com/javase/specs/index.html. JVM posiada kilka najpopularniejszych implementacji t.j. Oracle HotSpot, OpenJDK, IBM J9, Azul Zing

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Cykl wykonania programu

Przenieśmy się do momentu, kiedy mamy już przygotowany artefakt aplikacji gotowy do uruchomienia (przygotowanie artefaktu, wykracza poza zakres niniejszego artykułu – proces opiszę w kolejnych artykułach). Pierwszym krokiem uruchomienia artefaktu jest:

  • Start maszyny wirtualnej Javy (Java Virtual Machine Startup), czyli start JVM. Etap ten polega na odnalezieniu klasy głównej aplikacji, w celu uruchomienia statycznej metody main. Aby uruchomienie statycznej metody main było możliwe – klasa musi zostać załadowana.
  • Do gry wkracza ClassLoader, którego zadaniem jest odnalezienie binarnej reprezentacji klasy w classpath aplikacji. W następnym kroku ClassLoader wczytuje klasę do odpowiedniego segmentu pamięci (Creation and Loading – utworzenie obiektu Class, która nie jest jeszcze gotową do uruchomienia klasą). Kiedy klasa główna zostanie umieszczona w pamięci, przechodzimy do następnego kroku.
  • Linking składa się z 3-etapowego procesu łączenia:
    • Przeprowadzenie weryfikacji definicji klasy. Podczas tego kroku sprawdzana jest poprawność strukturalna klasy czy interfejsu. Sprawdzane są również wszystkie zależności. Jeśli np. funkcja statyczna main, odwołuje się do innej klasy – sprawdzane jest, czy klasa ta jest załadowana. Dodatkowo mają miejsce walidacje tj.:
      • Czy nie nadpisujemy finalnych metod lub klas.
      • Metody mają prawidłową liczbę i typy parametrów.
      • Zmienne mają wartość prawidłowego typu.
      • Bytecode nie próbuje w złośliwy sposób manipulować pamięcią.
      • Sprawdzane jest „szanowanie” poziomów dostępu (nie próbujemy dobić się do prywatnych metod itp.)
    • W kolejnym kroku następuje faza przygotowania tzw. preparation. Podczas tego kroku tworzone oraz inicjowane są wszystkie pola statyczne.
    • Ostatni krok linkowania to tzw. rozwiązanie (z ang. resolution). Krok ten zapewnia, że wszystkie pola, do których się odnosimy, są już gotowe do użycia, a nie tylko załadowane.
  • Ostatnim krokiem jest inicjalizacja (z ang. initialization). Tutaj JVM tworzy obiekty klasy. Po tym procesie obiekt Class jest już w pełni prawną klasą, gotową do użycia.

Cały proces zobrazowany jest na poniższym rysunku.

Jak w życiu – nic nie trwa wiecznie. Tak samo wystartowana na początku maszyna wirtualna Javy, musi kiedyś zakończyć swój żywot. Maszyna JVM kończy swoją prację, jeśli jakikolwiek wątek zawoła metodę Runtime.exit(), lub zostanie ona „zabita” z zewnątrz (ubicie procesu, wyłączenie komputera…). Jako ciekawostkę dodam, że JVM potrafi popełnić samo destrukcję (służy do tego flaga -XX:SelfDestructTimer=10 ustawiona na liczbę minut, po którym JVM kończy pracę)

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

JIT Just-In-Time Compiler

Aby rozpocząć pracę programu, w pierwszej kolejności uruchamiana jest statyczna metoda main. Zazwyczaj powoduje to kaskadowe tworzenie klas i wywoływanie metod. Jak wspominałem wcześniej – ciała metod są transformowane do postaci kodu bajtowego, który jest interpretowany przez Maszynę Wirtualną Javy. Sam interpreter, w przeciwieństwie do byte codu, jest już zależny od konkretnego środowiska, na którym został uruchomiony. Pośrednictwo w postaci interpretera kodu bajtowego powoduje dodatkowy narzut czasowy na wykonywanym programie. W końcu interpreter, w pierwszej kolejności musi zinterpretować kod, potem go skompilować do kodu maszynowego a dopiero na samym końcu go uruchomić. Innymi słowy – interpretacja jest wolniejsza od wykonywania natywnego kodu przez procesor. Często powtarzanym mitem jest to, że „Java jest wolna”. Cofając się do pierwszych wersji Javy – możnaby to uznać za prawdę. Jednak przy nowoczesnych algorytmach optymalizujących kod, może dojść do sytuacji, kiedy to dobrze napisany program w Java jest szybszy od jego odpowiednika kompilowanego do kodu maszynowego.

Sam fakt uruchomienia kodu bajtowego w kontrolowanym środowisku daje nam niesamowite możliwości pracy nad optymalizacją programu. Właśnie tym zajmuje się JIT, który nieustannie zbiera statystyki pracy naszego programu, i kiedy przekroczymy pewien próg wywołań metod (o czym później) – JIT stwierdzi, że skoro ta funkcja jest wywoływana taką ilość razy – jej zoptymalizowanie może znacząco przyśpieszyć wykonywanie całego programu. Dla uproszczenia przyjmujemy, że próg wywołań to 10 000. Co zatem robi JIT z kodem, którym się zainteresuje? Przeprowadza kilkadziesiąt optymalizacji. Opisanie ich wszystkich to temat na niejedną książkę. Wy listujemy jednak te najpopularniejsze optymalizacje:

  • Zagnieżdzanie metod (method inlining).
  • Eliminacja martwego kodu (dead code elimination).
  • Kompilacja do kodu natywnego.
  • Grupowanie blokad (lock coarsening).
  • Rozwijanie pętli (loop unrolling).
  • Eliminacja blokad (lock elision).
  • Ostrzenie typów (type sharpening).

W tym wpisie omówimy trzy pierwsze metody, co pozwoli nam lepiej zrozumieć pracę JIT.

Zagnieżdżanie metod

Polega na zastąpieniu wywołania funkcji, przyklejeniem jej ciała wprost do metody wywołującej. Spójrzmy na kod przed optymalizacją.

public String getValueFromSupplier(final Supplier<String> supplier) {
    return supplier.get();
}

public String someMethod(final String param) {
    Supplier<String> stringSupplier = new StringSupplier("some string" + param);
    return getValueFromSupplier(stringSupplier);
}

JIT zauważa, że metoda getValueFromSupplier przekroczyła próg wywołań i jedną z optymalizacji, jakie może zrobić jest wyciągnięcia ciała metody getValueFromSupplier i przeklajenie go do metody someMethod. Kod po optymalizacji będzie wyglądał następująco.

public String someMethod(final String param) {
    Supplier<String> stringSupplier = new StringSupplier("some string" + param);
    return stringSupplier.get();
}

Zagnieżdżanie metod jest często zwane „matką wszystkich optymalizacji”. Operacja ta, często wyzwala możliwość kolejnych optymalizacji. Aby to sobie uświadomić, posłużymy się kolejnym przykładem. Wyobraźmy sobie, że metoda getValueFromSupplier wygląda następująco.

public String getValueFromSupplier(final Supplier<String> supplier) {
    if (supplier == null) {
        throw new IllegalArgumentException("Supplier cannot be null");
    }
    return supplier.get();
}

Implementacja tej funkcji jest jak najbardziej prawidłowa. Nie mamy tutaj się do czego doczepić. Sytuacja jednak ulega radykalnej zmianie, kiedy użyjemy znanego nam już zagnieżdżania i przekleimy ciało metody do metody someMethod.

public String someMethod(final String param) {
    Supplier<String> supplier = new StringSupplier("some string" + param);
    if (supplier == null) {
        throw new IllegalArgumentException("Supplier cannot be null");
    }
    return supplier.get();
}

Jak widzimy zmienna supplier nigdy nie będzie nullem, ponieważ na starcie zostaje zainicjalizowana. Kod sprawdzający, czy supplier jest nullem jest kodem martwym. I to jest kolejna optymalizacja JIT.

Usuwanie martwego kodu

Wyjątek z poprzedniego przykładu nigdy nie zostanie wyrzucony. Jeśli my to widzimy, kompilator JIT również to zauważy. Przystąpi on do usuwania martwego kodu. Kod zachowa swoją funkcjonalność, a przyśpieszy to jego wykonywanie. Efekt pracy JIT będzie wyglądał następująco.

public String someMethod(final String param) {
    Supplier<String> supplier = new StringSupplier("some string" + param);
    return supplier.get();
}

Kompilacja do kodu natywnego

Ostatnią optymalizacją, którą omówimy w tym artykule, jest kompilacja kodu bajtowego Javy do kodu natywnego. Daje to niesamowity przyrost wydajności. Optymalizacja ta polega na pominięciu interpretera kodu bajtowego przy wykonywaniu programu. Aby to uczynić, kod musi zostać skompilowany do kodu maszynowego, który jest nawet 20-krotnie szybszy od kodu bajtowego. Co warte podkreślenia uwagi – optymalizacja ta nie jest pernamentna. Innymi słowy, jeśli JIT po obserwacji statystyk, stwierdzi, że optymalizacja nie przyniosła efektu w postaci większej szybkości wykonywania – swoją decyzję może cofnąć, wyrzucając kod natywny i wracając do interpretacji. Dzieje się tak dlatego, że mogły zmienić się warunki wykonywania metody. Mogły zostać załadowane kolejne klasy, co skutkuje zupełnie innymi możliwościami optymalizacji.

Jak więc widać JIT wykonuje ogrom pracy, starając się zoptymalizować kod naszej aplikacji. Jest w tym na tyle dobry, że szybkość współczesnej Javy jest porównywalna z szybkością kodu maszynowego. JIT zaraz po starcie aplikacji zaczyna badać statystyki wywołań metod. Ma to wpływ na wydajność naszej aplikacji, ponieważ na samym starcie jest ona w pełni interpretowana, nie posiada żadnej optymalizacji. Po przekroczeniu progów wywołań – JIT korzysta z kilkudziesięciu skomplikowanych algorytmów optymalizujących. Ten startowy okres, kiedy JIT jest najbardziej aktywny, nazywamy potocznie „rozgrzewaniem systemu„. JIT zużywa ogromne ilości zasobów na analizę kodu, tuż po uruchomieniu aplikacji. Badając testy wydajnościowe, powinniśmy wziąć tę prawidłowość pod uwagę, gdyż profil wydajnościowy naszej aplikacji może być zupełnie inny tuż po uruchomieniu i po jakimś czasie na produkcji.

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Kompilacja pozioma (Tiered compilation)

Po przeczytaniu wcześniejszego podrozdziału o optymalizacji – zadajesz sobie pewnie pytanie – skoro kod natywny jest tak bardzo wydajny, to czemu JIT nie przetransformuje całego kodu interpretowanego do kodu binarnego? Powodów jest wiele. Chyba najważniejszym z nich jest brak możliwości dalszej optymalizacji kodu interpretowanego, ponieważ kod natywny nie jest już bezpośrednio zarządzany przez JVM. Kolejnym powodem jest koszt samej kompilacji. To właśnie z powodu kosztów transformacji kodu interpretowanego do kodu natywnego, cały czas trwają prace nad ulepszeniem algorytmów JIT. Obecnie możemy rozróżnić dwie główne implementacje JIT:

  • Kliencka – C1
  • Serwerowa – C2

Flagi -client i -server dostępne dla JVM wybierały właśnie różną implementację JIT. W wielkim skrócie można powiedzieć, że kod produkowany przez wersję kliencką JIT jest wolniejszy, ale sam proces transformacji trwa szybciej. Natomiast wersja serwerowa potrzebuje dużo więcej czasu na transformację kodu interpretowanego do kodu natywnego (i na inne optymalizacje), ale efekt pracy jest lepszy. Zadajmy sobie teraz pytanie – „czy można mieć rybki i mieć akwarium?”. Tak właśnie narodziła się idea kompilacji poziomej, która standardowo była podzielona na 2 poziomy.

  • Kod interpretowany.
  • Kod natywny (wynik kompilacji JIT)

Przy włączeniu kompilacji poziomowej (w JDK 8 domyślne, we wcześniejszych wersjach po użyciu flagi –XX:+TieredCompilation) tych poziomów jest więcej:

  1. Kod interpretowany.
  2. Prosty kod natywny kliencki C1.
  3. Ograniczony kod natywny C1.
  4. Pełny kod natywny C1.
  5. Kod natywny serwerowy C2.

Standardowy proces wygląda następująco. Zaczynamy od poziomu 1, czyli od kodu interpretowanego. Kiedy JIT zainteresuje się konkretną metodą (domyślnie po 2000 wywołań), następuje kompilacja do kodu natywnego (z wykorzystaniem kompilatora klienckiego). Po przekroczeniu kolejnego progu wywołań (domyślnie 15 000) następuje kompilacja z wykorzystaniem kompilatora serwerowego.

Jak więc widzisz drogi czytelniku, optymalizacje, jakie serwuje nam JVM i JIT to bardzo złożony proces. Prużno szukać takich optymalizacji w językach kompilowanych do kodu maszynowego (statyczna analiza kodu). Dzięki dynamicznym statystkom, kilkudziesięciu algorytmom optymalizacyjnym a kończąc na kompilacji do kodu maszynowego, najbardziej newralgicznych miejsc naszej aplikacji – może się okazać, że aplikacja napisana w Javie będzie szybsza od swojego odpowiednika napisanego w języku kompilowanym do kodu maszynowego – np. C++. Obaliliśmy więc mit dotyczący „zamulania Javy”. Wykorzystując wzorce projektowe i pisząc optymalny kod, jesteśmy w stanie stworzyć piekielnie szybkie aplikacje.

W następnych artykułach rozprawimy się m.in. ze strukturą pamięci JVM oraz sposobem pracy Garbage Collector, który niezawodnie odśmieca pamięć Javy. Jeśli nie chcesz, aby omineły Cię tak mięsne artykuły – dołącz do naszego newslettera, a wyślemy Ci powiadomienie, kiedy się pojawią.

Jeśli przeczytałeś/aś ten artykuł i jeśli Ci się spodobał – będę bardzo wdzięczny, jeśli podzielisz się nim ze swoimi znajomymi i dołączysz do naszego fanpage na FaceBook – https://www.facebook.com/softwareskill

Podsumowanie

Co to jest JVM?

JVM, czyli Java Virtual Machine, to środowisko uruchomieniowe programów napisanych w językach interpretowanych, które są kompilowane do kodu bajtowego Javy (tzw. Java bytecode)

Co to jest JIT?

JIT, czyli Just-In-Time Compiler to program, który dynamicznie bada wydajność aplikacji uruchomionych na JVM. Na podstawie zebranych statystyk, przystępuje do optymalizacji najbardziej newralgicznych miejsc aplikacji, zwiększając wydajność całego systemu.

Technologia zdjęcie utworzone przez master1305 – pl.freepik.com

Gdybyś potrzebował jeszcze więcej:

Jesteś Java Developerem?

Przejdź na wyższy poziom wiedzy 
„Droga do Seniora” 🔥💪

Jesteś Team Leaderem? Masz zespół?

Podnieś efektywność i wiedzę swojego zespołu 👌

Podoba Ci się ten artykuł? Weź więcej.

Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

.

Dyskusja