Kolejnym bardzo ważnym komponentem w wirtualnej maszynie Javy jest kompilator Just-in-Time. Pierwszym krokiem w wytwarzaniu oprogramowania jest napisanie kodu źródłowego przez programistę. To właśnie w tym miejscu najczęściej popełniane są błędy wydajnościowe. Martwy kod, warunki zawsze prawdziwe, rekurencje ogonowe, itp. Przykładów jest wiele. Omawiany komponent ma na celu pomóc i zadbać o optymalizację tego kodu. Zestawienie i porównanie wszystkich kompilatorów zostanie poruszony w innym artykule. Teraz trochę teorii.
W tym artykule znajdziesz odpowiedzi na poniższe pytania:
- Czym jest kompilator Just-in-Time?
- Jakie nowości pojawiły się od Java 8?
- Za co odpowiedzialny jest kompilator JIT?
- Czym różni się kompilator javac, JIT i AOT?
Krótko o kompilatorze JIT
Kompilator Just-in-Time (w skrócie JIT) jest jednym z głównych elementów, który wpływa bezpośrednio na wydajność aplikacji Java i odegrał olbrzymią rolę w temacie wydajności. Kompilator działa w czasie wykonywania programu. Pomimo interpretowania kodu bajtowego przez Java, to w niektórych sytuacjach kod optymalizowany przez kompilator JIT może być szybciej wykonywany niż kod wytworzony w językach niższego poziomu. Ważnym aspektem, dzięki któremu kompilator może działać jest fakt uruchamiania aplikacji na wirtualnej maszynie Java w trybie interpretowania kodu bajtowego. Dzięki wirtualnej maszynie Java zbierane są statystyki o wykonywanym kodzie. W momencie, w którym metody osiągną odpowiedni próg wywołań to rozpoczyna się optymalizacja. Jedną z optymalizacji jest uruchomienie kompilatora JIT w celu skompilowania kodu bajtowego Java do natywnego kodu wykonywalnego, który wykonywany jest bezpośrednio przez procesor, gdzie czas wykonywania jest nawet 10-krotnie szybszy. Proces kompilowania kodu źródłowego został przedstawiony na rysunku poniżej.
Oprócz skompilowania do kodu natywnego, kompilator będzie próbował wykonać optymalizacje na poziomie kodu bajtowego. Do najważniejszych i najpopularniejszych optymalizacji, które zostały do tej pory wprowadzone należą:
- eliminacja martwego kodu,
- rozwijanie pętli,
- zagnieżdżanie metod,
- grupowanie blokad,
- eliminacja blokad,
- ostrzenie typów.
W maszynie wirtualnej dostarczonej przez firmę Oracle standardowo możemy znaleźć kompilator klienta, zwany również C1 i kompilator serwera, zwanym C2. Obecna instalacja Javy używa obu kompilatorów. C1 jest zaprojektowany tak, aby szybciej działał i szybciej wprowadzał zmiany kosztem tworzenia mniej zoptymalizowanego kodu. Jeśli liczba wywołań ponownie będzie rosła to wirtualna maszyna ponownie skompiluje kod, ale tym razem przy użyciu kompilatora C2. Kompilator C2 zajmuje więcej czasu, ale tworzy lepiej zoptymalizowany kod. Od kilku lat, klienci nie doczekali się większych ulepszeń w kompilatorze, ze względu na poziom trudności w jego utrzymywaniu. Sam kompilator został zaprojektowany w języku programowania C++.
Nowy kompilator JIT
Długo nie trzeba było czekać, aż powstanie alternatywna opcja dla domyślnego kompilatora JIT. W Javie wersji 9 o kodzie JEP 243 opracowano oparty na języku Java interfejs kompilatora JVM, zwany JVMCI. Nowa zmiana umożliwia wprowadzenie kompilatora napisanego w języku Java do maszyny JVM jako kompilator dynamiczny. To, co faktycznie pozwala JVMCI, to wyłączenie standardowego narzędzia i wymianę na nowy, bez konieczności zmiany czegokolwiek w JVM. Interfejs jest dość prosty i zawiera metodę compileMethod z jednym parametrem. Jako dane wejściowe otrzymuje kod bajtowy wraz z istotnym informacjami do przetworzenia, a zwraca kod maszynowy. Zostało to przedstawione na rysunku poniżej.
package jdk.vm.ci.runtime; import jdk.vm.ci.code.CompilationRequest; import jdk.vm.ci.code.CompilationRequestResult; public interface JVMCICompiler { int INVOCATION_ENTRY_BCI = -1; CompilationRequestResult compileMethod(CompilationRequest request); }
Kod źródłowy przedstawiający interfejs JVMCI
Wraz z wprowadzeniem interfejsu kompilatora JIT, za ciosem twórcy wprowadzili kilka innych eksperymentalnych zmian. Pierwsza daje możliwość użycia opcjonalnego kompilatora Graal, który został stworzony przez Oracle w ramach projektu GraalVM. Graal to wysokowydajny kompilator JIT, którego celem jest zastąpienie standardowego kompilatora. Został napisany w języku Java, co oznacza, że otrzymamy wszystkie zalety pisania aplikacji w Java w porównaniu do języka C++, czyli wyjątki zamiast awarii i brak prawdziwych wycieków pamięci. Dodatkowo wsparcie dla IDE oraz możliwość debuggowania i profilowania. Najważniejsza zaletą jest ciągły rozwój i możliwość wprowadzenia nowych optymalizacji. Zmiana została zarejestrowana jako eksperymentalna o kodzie JEP 317.
Kompilator AOT
Kolejną zmianą jest wprowadzenie kompilatora Ahead-of-Time, zwany kompilatorem AOT. Został wpisany do listy poprawek jako JEP 295. Narzędzie ma na celu poprawę tak zwanego okresu rozgrzewania i skompilowanie klas do kodu natywnego już przed uruchomieniem maszyny wirtualnej. Proces kompilowania kodu źródłowego przy pomocy kompilatora AOT został przedstawiony na rysunku poniżej.
Podobnie jak w poprzedniej zmianie został tutaj użyty kompilator AOT z projektu GraalVM. Świetnym przypadkiem użycia kompilatora AOT są krótko działające programy, które kończą wykonywanie jeszcze przed uruchomieniem kompilatora JIT. Podobnie jak Graal został zarejestrowany jako eksperymentalny. Oba kompilatory w Java 17 (JEP 410) zostały usunięte. Aby użyć jednego z wymienionych kompilatorów to należy skorzystać z projektu GraalVM lub samemu pobrać konkretny komponent.
Modyfikacje w pamięci JVM
W Javie 9 dla kompilatora i wirtualnej maszyny Javy wprowadzono segmentacje pamięci podręcznej kodu. Pamięć podręczna kodu to obszar pamięci, w której przechowywany jest wygenerowany natywny kod aplikacji przez kompilator JIT. Kod zmiany to JEP 197. Celem jest podział sterty kodu na odrębne segmenty: niemetodowy, profilowany i nieprofilowany. Zostało to przedstawione w tabeli poniżej.
Code Cache | |||
Profiled Code | Non-Profiled Code | Non-Method Code | |
Compiled Code | Lightly optimized profiled methods | Fully optimized non-profiled methods | Compiler buffers, bytecode interpreter |
Lifespan | Short lifetime | Long lifetime | Parmanent |
Efektem wprowadzonych zmian jest krótszy czas oczyszczania pamięci, zmniejszona fragmentacja zoptymalizowanego kodu, lepsza kontrola nad zużyciem pamięci maszyny JVM, a co za idzie to skrócenie czasu skanowania skompilowanych metod oraz poprawienie wydajności.
Ciekawe artykuły
- J. Kubryński, Co każdy programista Java powinien wiedzieć o JVM: zarządzanie pamięcią, https://bottega.com.pl/pdf/materialy/jvm/jvm1.pdf
- E. Lavieri, P. Verhas, Mastering Java 9, Segmented code cache [JEP 197], Packt, https://subscription.packtpub.com/book/application_development/9781786468734/2/ch02lvl1sec19/segmented-code-cache-jep-197
- C. Seaton, Understanding How Graal Works – a Java JIT Compiler Written in Java, https://chrisseaton.com/truffleruby/jokerconf17/
- Baeldung, Deep Dive Into the New Java JIT Compiler – Graal, https://www.baeldung.com/graal-java-jit-compiler
- M. Aboullaite, Understanding JIT compiler (just-in-time compiler), https://aboullaite.me/understanding-jit-compiler-just-in-time-compiler/
- A. Shipilёv, JVM Anatomy Quarks, https://shipilev.net/jvm/anatomy-quarks/