Bardzo wielu developerów tworzy bardzo udane projekty w Javie bez wiedzy jak działa wirtualna maszyna (JVM). Mimo wszystko uważam, że warto przynajmniej trochę orientować się “jak to działa pod spodem”.
Na początek kilka słów wstępu. W językach takich jak C++ kompilator tłumaczy kod aplikacji bezpośrednio do formatu binarnego. Wadą takiego rozwiązania jest ścisła zależność od systemu operacyjnego na którym ma działać nasza aplikacja. Proces kompilacji Javy jest podobny, przy czym kod programisty jest tłumaczony na pewien byt pośredni – kod bajtowy. On z kolei jest interpretowany przez JVM (Java Virtual Machine), która “załatwia” za nas cały problem różnorodności sprzętu na którym uruchamiamy aplikację (WORA – Write Once Run Anywhere)
Aby wirtualna maszyna Java mogła emulować rzeczywisty komputer musi posiadać/realizować kilka zasadniczych funkcji:
- Rejestry – w odróżnieniu od architektury x86 rejestry nie są wykorzystywane do przechowywania argumentów (np. podczas operacji arytmetycznych). W JVM rejestry są wykorzystywane do zapamiętania stanu wykonania aplikacji i są one aktualizowane po każdej wykonanej linii kodu. Warto dodać że wszystkie rejestry mają długość 32 bit.
- Stos – każda instrukcja kodu bajtowego bierze parametry ze stosu, wykonuje na nich operację i zwraca rezultat. Stos działa w oparciu o LIFO, co sprawia że “spodziewa” się parametrów w określonym miejscu i porządku. Należy wspomnieć tutaj o tzw. stack frame, który jest elementem każdej metody w kodzie i oznacza ile stosu potrzeba na przechowywanie zmiennych lokalnych, środowiskowych i na wykonywane operacje.
- Środowisko wykonywania – tłumaczenie kodu bajtowego na natywny (dla danego OS) jest realizowane na 2 sposoby:
- Interpreter – szybko tłumaczy kod, wolno go wykonuje
- JIT (Just-In-Time) kompilator – cały kod jest najpierw tłumaczony do postaci natywnej, a następnie w całości wykonywany. Jest to często spotykany efekt “udławienia” się Javy przy starcie aplikacji.
Jaka jest różnica ? Jeżeli kod ma być wykonywany tylko raz, bardziej optymalnie jest go interpretować linijka po linijce. JIT jest wykorzystywany wtedy gdy dana metoda jest odpalana więcej razy niż pewien zadany poziom.
- Zarządzanie pamięcią – podczas uruchomienia aplikacji w Javie programista może zadeklarować minimalną i maksymalną ilość pamięci RAM (sterty) której może używać program. W celu zapobiegania przekroczeniu tego limitu (co i tak często się zdarza) architekci Javy wymyślili coś takiego jak Garbage Collector. Odpowiada on za wyszukiwanie i usuwanie obiektów (komórek pamięci) do których nie prowadzą żadne referencje. Dzięki temu nasza pamięć jest regularnie “odśmiecana” a programista nie musi martwić się o jej manualną dealokacją z poziomu kodu.
- Obszar stałych i metod – JVM wyodrębnia osobne miejsce w pamięci do przechowywania stałych klasy i skompilowanych do kodu bajtowego metod.
- Zbiór instrukcji – instrukcje kodu bajtowego są całkiem podobne do instrukcji Assemblera. Można to łatwo podejrzeć przy wykorzystaniu narzędzia dostarczonego razem z JDK czyli tzw. disassemblerem.
Klasa Test.java:
System.out.println(“Hej!”);
Kompilujemy ją komendą javac Test.java i wykonujemy polecenie javap na pliku Test.class
0 getstatic #6 <Field java.lang.System.out Ljava/io/PrintStream;> 3 ldc #1 <String "Hej!"> 5 invokevirtual # 7 <Method java.io.PrintStream.println(Ljava/lang/String;)V> 8 return
Podsumowując – większa świadomość tego jak jest kompilowany i działa program w Javie może przełożyć się na lepsze zrozumienie pewnych procesów i zwracanie większej uwagi na dbałość o wydajność tworzonych programów, co przekłada się bezpośrednio na komfort pracy użytkownika.