W internecie i nie tylko aż roi się od stwierdzeń typu “Java jest 20 razy wolniejsza od C++“. Wielu użytkowników zawzięcie dyskutuje za i przeciw użyteczności aplikacji wykonanych w Javie oraz rzeczywistym wpływie użytego języka na wydajność końcową. Ile w tym prawdy i jaki jest rzeczywista różnica w wydajności między Javą a innymi językami? Czy Java jest faktycznie wolniejsza od C++ czy to tylko kolejny mit powtarzany od lat?

Różnice techniczne

Języki takie jak C, C++ są kompilowane do kodu natywnego, gdy się uruchamiają, działają bezpośrednio na procesorze. Tym samym jednak są od razu przypisane pod konkretny system i konkretną architekturę procesora. Języki interpretowane likwidują ten problem: kod jest w pełni przenośny dzięki temu że kod jest czytany na miejscu, powoduje to jednak znaczny spadek szybkości, rzeczywiście czasem nawet 20-krotny. Ale czy Java jest językiem interpretowanym? Była. Pierwsza wersja, wtedy właśnie narodziło się najwięcej takich bzdur, które przetrwały do dziś. Jak wygląda proces tworzenia aplikacji w Javie?

Proces budowania i uruchamiania aplikacji w Javie

Omówię to na przykładzie oficjalnej implementacji. Mając plik .java z klasą, kompilowany jest on do pliku .class – kompilowany tak samo jak w C++, tyle że nie do Assemblera, tylko tzw. bajtokodu – można go nazwać “assemblerem maszyny Java”. Te pliki .class nie są assemblowane i linkowane, tylko zazwyczaj pakowane do archiwum zip o rozszerzeniu .jar wraz z tzw. manifestem – nagłówkiem, który informuje gdzie się program zaczyna i jakie biblioteki ma dodatkowo załączyć. A jak wygląda uruchamianie? Kiedyś maszyna rzeczywiście czytała linijka po linijce wykonując poszczególne instrukcje. A jak to wygląda teraz?

Just-in-Time Compiler

Wirtualna Maszyna Javy (JVM) wyposażona jest w tzw. kompilatora JIT. Jego zadaniem jest zoptymalizować kod pod daną platformę, na której aplikacja jest uruchamiana. Dzieje się to dopiero po uruchomieniu aplikacji. Wadą tego rozwiązania jest ciężki rozruch, zaletą – przenośność, nieważne na jakiej maszynie kod został skompilowany, uruchomi się na każdym procesorze i systemie (o ile nie użyto zewnętrznych natywnych bibliotek). Spora część instrukcji przenoszona jest z wirtualnej maszyny na fizyczną – procesor, dzięki czemu kod przyspiesza. Ile?

time java Test<data>output

Nie, nie, i jeszcze raz NIE! Jak wspomniałem, JIT zaczyna robić swoje dopiero po uruchomieniu, więc sprawdzenie czasu wykonania całej aplikacji jest… niesprawiedliwe. W tym czasie aplikacja jeszcze się kompiluje, to tak jakby mierzyć czas programu w C poprzez time gcc test.o && ./a.out.

Przykład: wyznacznik macierzy 10×10 metodą rozwinięcia Laplace’a. Stosowana jest rekurencja i częsta alokacja/dealokacja pamięci (10 wykonań).

C++ (z -O2): 2,700s
Java: 3,700s

Czy to jest aż 20 razy więcej? Poza tym, wyniki cząstkowe:

 time: 490.904373
 time: 374.741364
 time: 353.051288
 time: 351.861573
 time: 362.405451
 time: 352.954302
 time: 348.976093
 time: 346.036781
 time: 357.956662
 time: 339.504749
 sum time: 3700.863812

Jak widać pierwsze wywołanie trwało najdłużej – JIT jeszcze wtedy nie zdążył skompilować bajtokodu do kodu maszynowego. Tu dużą rolę odgrywała rekurencja, a w niej: tworzenie tej klasy od nowa, a przy tym: wcześniejsza jej kompilacja. Kolejne próby są już jakby „pełnej szybkości”, czyli ok. 345ms, podczas gdy C++ potrzebował na to 270ms. Około 30% straty wydajności.

java wykres porownania wydajnosci

Wykonałem również test wyłączając JIT (opcja uruchomienia -Djava.compiler=NONE), obliczenie całości trwało 33 sekundy, czyli 3,3s na jedną macierz.

Wziąłem również przykład sortowania bąbelkowego:

java wykres porownania wydajnosci

C++ (GCC z -O2 i -O3) wykonał zadanie w 2,1 sekundy. W Javie ten sam kod (nieco zmieniony, żeby się skompilował w Javie) zajął 3,6 sekundy. Ale czy na pewno? Zmierzyłem czas wewnątrz programu dla drugiego wykonania sortowania: 1,6 sekundy! Ten sam algorytm w Javie wykonał się szybciej! W tym teście mam pewność, że drugie wykonanie jest już po pełnej kompilacji, a pierwsze nie: w algorytmie liczenia wyznacznika macierzy w trakcie obliczania pierwszego kompilował całą klasę do kodu maszynowego, przez co różnica jest niewielka.

Ale ale. Wykonałem też test tego algorytmu w C++ z użyciem clang i Open64. Oba osiągnęły wyniki w okolicach 1,6 – 1,7 sek. W przypadku programu z macierzami najlepiej wypadł GCC, więc reszty nawet nie pokazywałem. Nie będę tu nawet zamieszczał moich wyników testów z gcj – natywnym kompilatorem Javy z GCC. Te wyniki są po prostu… fatalne względem OpenJDK, którego używałem w tych testach.

To nie język jest szybki, to implementacja

Powtarzałem to już wiele razy, język jest tylko językiem, programy tworzą kompilatory. Java z pozoru może wydawać się wolna, w końcu jest to język zarządzany działający w maszynie wirtualnej (JVM). Jednakże optymalizacje stosowane przez tą maszynę i JIT potrafią przyspieszyć ten kod. Podobna technika jest stosowana w .NET i C# (Osobiście uważam że ten język nie powinien mieć C w nazwie: jest to mylące, to nie jest język będący “forkiem” C, tak jak C++, tylko całkiem odrębna technika na wzór Javy). Tak więc o ile czasy w krótko działających programach mogą być stosunkowo większe, to w długo działających aplikacjach użytkowych cały kod po pewnym czasie jest całkowicie kompilowany do kodu maszynowego i różnica ta staje się niewielka, o ile nie porównuje się z aplikacjami używającymi zaawansowanych funkcji procesora, do których Java dostępu nie ma z wiadomych względów (założenie przenośności kodu).

W Javie szybkość nie jest już aż takim problemem jak kiedyś. Są nawet sytuacje kiedy jest nawet tak samo szybka co odpowiednik w C++. Co prawda jest spadek wydajności w stosunku do C++, jednak aplikacje pisze się znacznie szybciej i łatwiej oraz łatwiej znaleźć błędy. W przypadku aplikacji użytkowych taki spadek wydajności nawet nie jest zauważalny.

Nie mówię, że w Javie można spokojnie pisać wielkie wymagające komercyjne gry, bo nie do tego ten język został stworzony. Owszem: małe, mało wymagające można śmiało napisać w Javie, przykładem jest chociażby Minecraft. Dlaczego czasem tnie? Wbrew pozorom grafika w tej grze jest bardzo skomplikowana i to ona zżera najwięcej czasu. Klony w C++ chodzą niewiele lepiej, a mają znacznie mniej zaimplementowanych elementów świata. Ten krótki test nie miał na celu zbadania konkretnych czasów, nie był benchmarkiem, tylko dowodem na to, że źle przeprowadzony test może doprowadzić do niepoprawnych wniosków.