GraalVM – Ein Blick auf die Performance

Die GraalVM ist nun seit reichlich zwei Jahren am Markt. Sie verspricht im Wesentlichen zwei Dinge: Bessere Laufzeiteigenschaften und die Integration mehrerer Programmiersprachen.

Dieser Blogbeitrag konzentriert sich auf die Performance. Dabei soll es aber nicht primär darum gehen, ob und wieviel ein spezielles Programm auf dem Graal-JDK schneller ausgeführt wird als auf einem herkömmlichen JDK. Die konkreten Messwerte und die relativen Vergleiche sind ohnehin nicht nur vom untersuchten Programm abhängig und wenig verallgemeinerbar, sondern auch nur Momentaufnahmen: Sowohl die GraalVM als auch beispielsweise das OpenJDK werden beständig weiterentwickelt, so dass sich auch die Messwerte stets ändern werden. Stattdessen beschäftigt sich der Blogbeitrag vor allem mit den Fragen: Warum sollte die GraalVM wesentlich performanter sein? Was macht sie anders als die herkömmlichen JDKs? Damit wird eine Abschätzung möglich, ob alle Programme performanter ausgeführt werden oder keine nennenswerte Steigerung zu erwarten ist oder ob die Performance-Steigerung nur in bestimmten Anwendungsfällen zu erwarten ist. Und letztendlich, ob das „herkömmliche“ Java demzufolge zu langsam ist…

Laptop verbunden mit LED Display, auf dem "Hello World" abgebildet wird.

Entwicklung der Compiler

Da die Performance eines Java-Programms wesentlich durch den Compiler bestimmt wird und auch hier die Kernfrage ist, was die GraalVM anders macht verschaffen wir uns zunächst einmal einen Überblick über Compiler.

In den Anfängen der Programmierung existierte zunächst noch gar kein Compiler – es wurde direkt der Maschinencode programmiert. Da dies unübersichtlich und wenig verständlich war, hat sich zeitnah der Assembler-Code entwickelt. Dabei handelt es sich jedoch im Wesentlichen um eine direkte Abbildung des Maschinencodes – nur dass statt Binär- oder Hexadezimal-Opcodes nun Buchstabenkürzel verwendet werden. Auch hier sprechen wir (zumindest im Scope dieses Blogbeitrags) noch nicht von einer Programmiersprache und einem Compiler.

Mit der Zeit wurde die Entwicklung immer komplizierterer Programme notwendig. Damit wurde der Assembler-Code zunehmend unpraktikabel. Daher wurden in den 1950er Jahren die ersten höheren Programmiersprachen entwickelt. Diese benötigten einen Compiler, der den Quelltext in Maschinencode übersetzt.

Dies war zunächst der klassische AOT-Compiler (AOT: Ahead Of Time). Der Quelltext wird analysiert (Syntaxanalyse), in eine interne Baumstruktur überführt (Syntaxbaum) und aus diesem wird Maschinencode generiert (Codegenerierung). Die entstehende Binärdatei kann nun direkt ausgeführt werden.

Alternativ zur AOT-Kompilierung kann ein Programm auch durch einen Interpreter ausgeführt werden. Hierbei wird der Quelltext eingelesen und zeilenweise durch den Interpreter umgesetzt. Die eigentlichen Operationen (z. B. Addition, Vergleich, Programmausgabe) führt dann der Interpreter aus.

Der AOT-Compiler hat gegenüber dem Interpreter den Vorteil, dass die Programme wesentlich schneller ausgeführt werden. Allerdings sind die erzeugten Binärdateien maschinenabhängig. Darüber hinaus hat der Interpreter bessere Fehleranalysemöglichkeiten, da er beispielsweise Zugriff auf Laufzeitinformationen hat.

Java, Interpreter und JIT-Compiler

Beim Entwurf der Programmiersprache Java war ein Ziel, dass sie architekturneutral und portabel ist. Aus diesem Grund wurde der Java-Quellcode von Anfang an in maschinenunabhängigen Bytecode übersetzt. Dieser konnte dann von einer Laufzeitumgebung, der JRE (Java Runtime Environment), interpretiert werden. Damit war der übersetzte Bytecode maschinenunabhängig. So konnten beispielsweise Applets ohne Anpassungen auf einem Windows-PC, einem Mac oder einer Unix-Workstation ausgeführt werden. Die JRE muss dazu – unabhängig vom Applet – vorab auf den Workstations installiert sein.

Diese Mischform – AOT bis zum Bytecode, dann Interpretation zur Laufzeit – ist übrigens keine Idee aus der Java-Welt: Bereits in den 1970er Jahren hat beispielsweise Pascal den p-Code genutzt. [1]

Als die Java-Technologie im Jahre 1995 veröffentlicht wurde [2], war diese Maschinenunabhängigkeit zunächst mit großen Performance-Einbußen verbunden. Viele der damals relevanten Programmiersprachen wie bspw. „C“ kompilieren ihren Quellcode direkt in Maschinencode (AOT). Dieser kann auf dem entsprechenden System nativ ausgeführt werden und ist somit wesentlich performanter als die Interpretation des Bytecodes. Zu jener Zeit hat sich in den Köpfen vieler IT-Fachkräfte die Grundeinstellung „Java ist langsam“ festgesetzt – damals zu Recht.

Nun ist aber ein weiteres Entwurfsziel der Programmiersprache Java die hohe Leistungsfähigkeit. Aus diesem Grund wurde im Jahr 1998 der JIT-Compiler (JIT: Just In Time) eingeführt [2]. Damit wurden die Performance-Einbußen durch die reine Interpretation stark reduziert.

Bei der JIT-Kompilierung wird der Bytecode beim Programmstart zunächst ebenfalls interpretiert. Allerdings wird dabei genau verfolgt, welche Programmteile wie oft ausgeführt werden. Die häufig ausgeführten Programmteile werden nun – zur Laufzeit – in Maschinencode übersetzt. Zukünftig werden diese Programmteile nicht mehr interpretiert, sondern es wird der native Maschinencode ausgeführt. Hier wird somit zunächst Ausführungszeit für die Kompilierung „investiert“, um bei jedem zukünftigen Aufruf dann Ausführungszeit einsparen zu können.

Die JIT-Kompilierung ist daher ein Mittelweg zwischen AOT-Kompilierung und Interpretation. Die Plattformunabhängigkeit bleibt erhalten, da der Maschinencode erst zur Laufzeit erzeugt wird. Und da die häufig genutzten Programmteile nach einer gewissen Warmlauf-Zeit als nativer Maschinencode ausgeführt werden, ist auch die Performance (dann) annähernd so gut wie bei der AOT-Kompilierung. Ganz allgemein gilt dabei: Je häufiger einzelne Programmteile ausgeführt werden, desto mehr können die anderen, interpretierten Programmteile bei der Performance-Betrachtung vernachlässigt werden. Und dies gilt vor allem für oft durchlaufene Schleifen oder langlaufende Server-Anwendungen, deren Methoden ständig aufgerufen werden.

Laufzeitoptimierungen

Mit den bisher betrachteten Mechanismen ist der JIT-Compiler zwar plattformunabhängig – kann sich aber an die Ausführungszeit des AOT-Compilers nur herantasten, sie jedoch nicht erreichen oder gar übertreffen. Aus diesem Grund war zu dem Zeitpunkt, als der JIT-Compiler in das JDK integriert wurde, noch keineswegs sicher, dass es für einen Siegeszug ausreichen wird.

Allerdings hat der JIT-Compiler einen großen Vorteil gegenüber dem AOT-Compiler: Er ist nicht nur auf die statische Quellcode-Analyse angewiesen, sondern er kann das Programm direkt zur Laufzeit beobachten. Da sich die allermeisten Programme in Abhängigkeit von Eingaben und/oder Umgebungszuständen unterschiedlich verhalten, kann der JIT-Compiler zur Laufzeit wesentlich zielgenauer optimieren.

Ein großer Pluspunkt sind dabei die spekulativen Optimierungen. Dabei werden Annahmen getroffen, welche in den meisten Fällen zutreffen. Damit das Programm trotzdem korrekt funktioniert, wird die Annahme mit einem sogenannten „Guard“ abgesichert. Beispielsweise geht die JVM davon aus, dass in einem produktiv ausgeführten Programm die Polymorphie so gut wie nicht genutzt wird. Natürlich ist die Polymorphie grundsätzlich sinnvoll, aber die praktischen Einsatzszenarien beschränken sich meist auf den Testbereich oder auf die Entkopplung einer Codebasis – üblicherweise zur Nutzung durch verschiedene Programme oder für zukünftige Erweiterbarkeit. Während der Laufzeit eines konkreten produktiven Programmes – und dies ist der Scope der JVM – wird die Polymorphie jedoch selten genutzt. Das Problem ist dabei, dass es beim Aufruf einer Interface-Methode relativ zeitaufwendig ist, für das vorhandene Objekt die passende Methoden-Implementierung herauszusuchen. Aus diesem Grund werden die Methodenaufrufe getrackt. Wird beispielsweise mehrmals die Methode „java.util.List.add(…)“ auf einem Objekt vom Typ „java.util.ArrayList“ aufgerufen, merkt sich die JVM dies. Bei den folgenden Methodenaufrufen „List::add“ wird darauf spekuliert, dass es sich wieder um eine ArrayList handelt. Zunächst wird mit einem Guard die Annahme abgesichert: Es wird geprüft, dass das Objekt tatsächlich vom Typ ArrayList ist. Üblicherweise trifft dies zu und die bereits mehrfach ermittelte Methode wird mittels der „gemerkten” Referenz einfach direkt aufgerufen.

Seit der Integration des JIT-Compilers in das JDK sind nun mittlerweile mehr als zwei Jahrzehnte vergangen. In dieser Zeit wurden sehr viele Laufzeitoptimierungen integriert. Die vorgestellte Polymorphie-Spekulation ist nur ein kleines Beispiel, welches verdeutlichen soll: Neben der Kompilierung von Maschinencode wurden sehr viele Optimierungen erdacht, welche in einer komplexen Sprache wie Java nur zur Laufzeit funktionieren. Wenn eine Instanz beispielsweise mittels Reflection erzeugt wird, ist es für einen AOT-Compiler sehr schwer bis unmöglich, den konkreten Typ zu bestimmen und die spekulative Optimierung umzusetzen. Der Geschwindigkeitsvorteil der aktuellen JIT-Compiler beruht demnach im Wesentlichen darauf, dass sie dem Programm bei der Ausführung zuschauen, Gewohnheiten erkennen und schließlich Abkürzungen einbauen können.

GraalVM

Die GraalVM ist ein JDK von Oracle, welches auf dem OpenJDK basiert. Sie bringt eine virtuelle Maschine sowie viele Entwickler-Tools mit – was soweit auch für die anderen JDKs gilt. Warum also erregt die GraalVM wesentlich mehr Aufmerksamkeit als die anderen JDKs?

Zunächst bringt die GraalVM einen GraalVM-Compiler mit, welcher in Java entwickelt wurde. Darüber hinaus soll auch die gesamte JVM in Java umgeschrieben werden. Im letzten Kapitel wurde dargelegt, dass die aktuellen JVMs vor allem deswegen sehr performant sind, weil jahrzehntelang verschiedenste Optimierungen ergänzt wurden, welche sich nun summieren. Diese Optimierungen sind vor allem Java-spezifisch. Sie werden zumeist von Leuten entwickelt, welche einen Java-Background besitzen. Wenn nun die Ausführungsumgebung nicht in C++ sondern in Java umgesetzt ist, sind folglich keine C++-Kenntnisse mehr notwendig, um Optimierungen beizusteuern. Die Entwickler-Community wird so mittel- bis langfristig auf eine breitere Basis gestellt.

Ein weiterer spannender Aspekt der GraalVM ist, dass sie nicht nur Java-basierte Sprachen unterstützt. Das “Truffle Language Implementation Framework” ist ein Ansatzpunkt für die Entwicklung eigener Sprachen (DSL). Der GraalVM-Compiler unterstützt mit dem Truffle-Framework entwickelte Sprachen, so dass diese auch in der GraalVM ausgeführt werden können und von allen Vorteilen entsprechend profitieren. Einige Sprachen wie JavaScript, Python oder Ruby werden dabei bereits von Haus aus seitens der GraalVM unterstützt. Da alle Truffle-Sprachen gleichzeitig und gemeinsam in der GraalVM ausgeführt werden können, wird hier auch von einer polyglotten VM gesprochen.

Darüber hinaus werden auch LLVM-basierte Sprachen unterstützt. Bei LLVM handelt es sich um ein Rahmenprojekt für optimierende Compiler [4][5]. Dabei werden nicht nur Compiler-Bestandteile und -Technologien für externe Compiler-Entwicklungen bereitgestellt, sondern es werden auch im LLVM-Projekt bereits Compiler für viele Programmiersprachen wie bspw. C/C++ oder Fortran angeboten. Die LLVM Runtime ist ein weiterer Bestandteil der GraalVM, mit welchem LLVM-basierte Sprachen aufbauend auf dem Truffle-Framework in der GraalVM ausgeführt werden können. Auf den polyglotten Aspekt soll hier aber nicht weiter eingegangen werden, denn er hätte seinen eigenen Blogbeitrag verdient.

GraalVM Native Image

Die für diesen Beitrag relevanteste Neuerung ist die sogenannte „Native-Image-Technologie“. Das native-image ist ein Entwickler-Tool der GraalVM. Es erzeugt aus Bytecode eine ausführbare Datei. Die Ziele sind eine bessere Performance und weniger Hauptspeichernutzung zur Laufzeit. Nun wurde aber bisher beschrieben, dass Java immer schneller geworden ist: Der JIT-Compiler übersetzt alle häufig ausgeführten (d. h. relevanten) Programmteile in nativen Maschinencode. Das Programm wird während der Ausführung beobachtet und es werden fortlaufend Laufzeitoptimierungen vorgenommen. Damit stellt sich also die Frage: Was kann denn hier noch um Größenordnungen verbessert werden?

Die Antwort ist verblüffend einfach: Die Startzeit. Auch mit JIT-Compilern wird der Bytecode zunächst interpretiert. Erstens ist der Programmstart meistens kein häufig ausgeführter Programmteil. Zweitens müssen diese Programmteile üblicherweise erst ein paarmal durchlaufen werden, damit der JIT-Compiler diese als lohnendes Übersetzungsziel erkennt. Mit den Laufzeitoptimierungen verhält es sich analog: Das Programm muss erst einmal zur Laufzeit beobachtet werden, damit die passenden Optimierungen erkannt und eingebaut werden können. An dieser Stelle kommt verschärfend hinzu, dass beim Start alle benötigten Objekte sowie deren Klassen inklusive der kompletten Vererbungshierarchie initialisiert werden müssen.

Da wir nun eine Vorstellung vom „Was“ haben, interessiert uns das „Wie“: Wie kann unser Programm dazu bewegt werden, schneller zu starten?

Bei der Erstellung des Native Image wird der Bytecode zunächst sehr umfangreich statisch analysiert. Unter anderem wird dabei geprüft, welche Code-Teile zur Laufzeit überhaupt ausgeführt werden können. Dies bezieht sich nicht nur auf die vom Nutzer bereitgestellten Klassen, sondern auf den gesamten Klassenpfad – also inklusive der von der JVM bereitgestellten Java-Klassenbibliotheken. Nur die ermittelten Quellcode-Fragmente werden in das Native Image aufgenommen. Somit wird an dieser Stelle zwar der Umfang stark reduziert – aber es wird auch eine „Closed world assumption“ aufgestellt: Sobald irgendetwas dynamisch zur Laufzeit geladen wird, steht das Native Image Tool vor einem Problem. Es erkennt nicht, dass auch diese Quellcode-Teile ausgeführt werden können und damit benötigt werden. Aus diesem Grund wird auf diese Weise nicht viel mehr als ein einfaches HelloWorld-Programm funktionieren. Deshalb kann und muss man bei der Erstellung des Native Image dem Tool noch Informationen mitgeben, was alles dynamisch aufgerufen werden kann.

Nach der statischen Analyse wird nun der erste Punkt umgesetzt, welcher die Startgeschwindigkeit erhöht: Da der JIT-Compiler mit der Interpretation starten würde, wird ein AOT-Compiler genutzt, um Maschinencode zu erstellen. Das erzeugte Native Image ist, wie der Name schon impliziert, nativ ausführbarer Maschinencode. Damit geht allerdings die Plattformunabhängigkeit verloren.

Zusätzlich zum nativ kompilierten Programm wird die sogenannte SubstrateVM in das Native Image aufgenommen. Hierbei handelt es sich um eine abgespeckte VM, welche nur die zur Ausführung des Native Image notwendigen Komponenten wie beispielsweise Thread Scheduling oder Garbage Collection enthält. Die SubstrateVM hat dabei auch Limitierungen, beispielsweise ist die Unterstützung eines Security Managers gar nicht vorgesehen.

Eine zusätzliche Steigerung der Startgeschwindigkeit wird erreicht, indem das Native Image bei der Erstellung bereits vorab initialisiert wird. Das Programm wird dazu nach dem Kompilieren soweit gestartet, bis die wesentlichen Initialisierungen erfolgt sind, aber noch kein Input von außen verarbeitet werden muss. Von diesem gestarteten Zustand wird ein Speicherabbild erstellt und in das Native Image gelegt.

An das „Was“ und das „Wie“ schließt sich nun noch ein eher kritisches „Warum“ an: Der AOT-Compiler ist schon ein halbes Jahrhundert bekannt und Java existiert mittlerweile auch seit einem Vierteljahrhundert. Vor allem in der Anfangszeit von Java wurden verschiedene AOT-Ansätze ausprobiert – welche sich jedoch nicht durchsetzen konnten. Warum sollte es jetzt anders sein? Warum wird ausgerechnet jetzt eine geringere Startzeit interessant, wenn sie doch mit Nachteilen verbunden ist? Warum sind die performanten Antwortzeiten im darauffolgenden tage- oder wochenlangen Betrieb plötzlich weniger wichtig?

Die Antwort ist im Cloud Computing zu suchen: Hier werden die Services in einer anderen Form zur Verfügung gestellt. Bisher wurden die Services vorwiegend in einem Anwendungscontainer betrieben, welcher Tag und Nacht ausgeführt wird und in welchem das Programm schon seit Tagen komplett durchoptimiert? ist. Schließlich wurde der Anwendungscontainer üblicherweise auch bei spärlicher Nutzung (z. B. Tageszeiten-abhängig) nicht heruntergefahren. Im Gegensatz dazu kann in der Cloud die Service-Infrastruktur bei Nicht-Nutzung problemlos heruntergefahren werden – somit können Kapazitäten gespart werden. Beim nächsten Aufruf wird die Infrastruktur wieder hochgefahren und der Aufruf wird ausgeführt. Das bedeutet, dass die Programme in der Cloud nicht im Dauerbetrieb laufen, sondern dass es sich unter Umständen bei jedem Aufruf um einen Kaltstart handelt. Demzufolge ist die Startzeit hier „auf einmal“ sehr entscheidend. Und da zu erwarten ist, dass zukünftig noch mehr Java-Programme in der Cloud statt in einem Anwendungscontainer ausgeführt werden, wird sich die Fokussierung auf die Startzeit wohl noch verstärken.

Hands On: HelloWorld

Nach der ganzen Theorie möchten wir dem JDK nun bei der Arbeit zuschauen. Dazu kommt zunächst die in Listing 1 dargestellte Klasse „HelloWorld“ zum Einsatz.

package de.zeiss.zdi.graal;

public class HelloWorld {

    public static void main(String[] args) {
        System.out.println("Moin!");
    }

}

Listing 1

Zunächst die klassische Variante: Wir befinden uns auf einer Linux-VM und es ist ein OpenJDK installiert:

> java --version

openjdk 11.0.11 2021-04-20
OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04)
OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing)

java 11.0.12 2021-07-20 LTS
Java(TM) SE Runtime Environment GraalVM EE 21.2.0.1 (build 11.0.12+8-LTS-jvmci-21.2-b08)
Java HotSpot(TM) 64-Bit Server VM GraalVM EE 21.2.0.1 (build 11.0.12+8-LTS-jvmci-21.2-b08, mixed mode, sharing)

Mit diesem Setup kompilieren wir die HelloWorld-Klasse (javac) und führen den entstandenen Bytecode auf einer JVM aus:

time java -cp target/classes de.zeiss.zdi.graal.HelloWorld

Damit erhalten wir folgende Ausgabe:

Moin!

real    0m0.055s
user    0m0.059s
sys     0m0.010s

Für die Auswertung ist hier die Summe der beiden Zeilen „user“ und „sys“ relevant. Das ist die Rechenzeit, die für die Programmausführung notwendig war – in dem Fall also ca. 69ms.

Eine Anmerkung zu den 55ms: Das HelloWorld-Programm hat vom Start bis zu seiner Beendigung 55ms „Real-Zeit“ (die Zeit, welche der Nutzer wahrnimmt) benötigt. Das ist weniger als die 69ms benötigte Rechenzeit. Dies liegt daran, dass das eingesetzte Linux-System mehrere Prozessoren besitzt. Für unsere Messungen soll hier aber die vom System aufgewandte Rechenzeit betrachtet werden. Erstens ist die Rechenzeit unabhängiger davon, wie viele Prozessoren das Programm ausgeführt haben. Und zweitens ist dies in der Cloud beispielsweise auch die Zeit, welche der Anwendungsbetreiber bezahlen muss.

Nun sind wir auf die GraalVM gespannt. Auf deren Homepage [3] wird sie zum Download angeboten. Für unsere Evaluation ist die Enterprise-Variante („Free for evaluation and development“) passend, da ein Großteil der Performance-Optimierungen nur hier enthalten sind.

Die Installation ist für Linux sehr gut dokumentiert und funktioniert nahezu problemlos. Damit ist die GraalVM als JDK nutzbar.

> java --version

java version "11.0.12" 2021-07-20 LTS
Java(TM) SE Runtime Environment GraalVM EE 21.2.0.1 (build 11.0.12+8-LTS-jvmci-21.2-b08)
Java HotSpot(TM) 64-Bit Server VM GraalVM EE 21.2.0.1 (build 11.0.12+8-LTS-jvmci-21.2-b08, mixed mode, sharing)

Unser HelloWorld-Programm können wir nun genauso mit dem GraalJDK kompilieren (javac) und ausführen. Damit erhalten wir die folgende Ausgabe:

Moin!

real    0m0.084s
user    0m0.099s
sys     0m0.017s

Interessanterweise benötigt die JVM des GraalJDK nahezu 70 % mehr Rechenzeit, um unser HelloWorld-Beispiel als Bytecode auszuführen. Nun verspricht die GraalVM den signifikanten Performance-Vorteil allerdings auch nicht primär bei der Ausführung von Bytecode, sondern bei Nutzung der Native-Image-Technologie.

Das native-image (das Entwickler-Tool) ist in der heruntergeladenen GraalVM noch nicht enthalten, dafür existiert aber das Kommandozeilen-Tool „gu“ (GraalVM Updater). Mit diesem kann man zusätzliche Komponenten nachladen, verwalten und aktualisieren. Auch hier unterstützt die Dokumentation der GraalVM sehr gut. Mit dem nachgeladenen Entwickler-Tool können wir nun aus dem Bytecode das Native Image erzeugen. Im Fall eines so trivialen Programms wie unserem HelloWorld-Beispiel genügt dazu ein einfacher Kommandozeilenbefehl mit dem vollqualifizierten Klassennamen als Argument:

cd ~/dev/prj/graal-eval/target/classes
native-image de.zeiss.zdi.graal.HelloWorld

Die Erstellung des HelloWorld Native Image benötigt reichlich 3 Minuten Rechenzeit – und das ausführbare Programm ist ca. 12 MB groß. Auf den ersten Blick vergleicht man die Größe vermutlich mit dem Bytecode: Die HelloWorld.class ist lediglich 565 Byte groß. Allerdings enthält das Native Image nicht nur die kompilierte Klasse, sondern alle relevanten Teile der Java-Klassenbibliothek sowie die SubstrateVM. Verglichen mit der Größe einer JRE liegt das Native Image nur noch bei grob geschätzt 10 %.

Doch zurück zu unserem Native Image: Wir haben es erfolgreich erstellt, können es ausführen und erhalten dabei die folgende Ausgabe.

time ./de.zeiss.zdi.graal.helloworld
Moin!

real    0m0.004s
user    0m0.003s
sys     0m0.001s

Dieses Resultat können wir erst einmal als relevanten Geschwindigkeitsgewinn stehen lassen.

Hands On: HelloScript

Bei der GraalVM wird immer wieder herausgestellt, dass es sich dabei nicht nur um eine Java-VM sondern um eine polyglotte VM handelt. Aus diesem Grund erweitern wir unser HelloWorld-Programm noch um einen kleinen Exkurs in die JavaScript-Welt. Der Quellcode dazu ist in Listing 2 dargestellt. Der wesentliche Unterschied ist hier der notwendige Übergang von der Java- in die JavaScript-Welt.

package de.zeiss.zdi.graal;

import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

public class HelloScriptEngine {

    public static void main(String[] args) throws ScriptException {

        ScriptEngine jsEngine = new ScriptEngineManager().getEngineByName("javascript");

        System.out.print("Hello ");
        jsEngine.eval("print('JavaScript!')");
    }

}

Listing 2

Neben dieser universellen JavaScript-Anbindung mittels der javax.script.ScriptEngine wollen wir auch die Graal-spezifische JavaScript-Anbindung ausprobieren. Dazu nutzen wir org.graalvm.polyglot.Context. Der Quelltext ist in Listing 3 dargestellt.

package de.zeiss.zdi.graal;

import org.graalvm.polyglot.Context;

public class HelloScriptPolyglot {

    public static void main(String[] args) {

        System.out.print("Hello ");
        try (Context context = Context.create()) {
            context.eval("js", "print('JavaScript!')");
        }
    }

}

Listing 3

Die beiden HelloScript-Programme werden analog dem HelloWorld-Programm in Bytecode übersetzt. Bei der Erstellung der Native Images muss das Entwickler-Tool noch informiert werden, dass die JavaScript-Welt genutzt werden wird. Dies geschieht mit dem folgenden Aufruf:

cd ~/dev/prj/graal-eval/target/classes
native-image --language:js de.zeiss.zdi.graal.HelloScriptEngine
native-image --language:js de.zeiss.zdi.graal.HelloScriptPolyglot

Anschließend kann der Bytecode auf den VMs oder die Native Images nativ ausgeführt werden. Da das HelloScriptPolyglot Graal-spezifisch ist, können wir es allerdings nicht ohne Weiteres auf dem OpenJDK ausführen.

Ein Blick auf die Messwerte

Die drei Szenarien wurden jeweils als Bytecode auf dem OpenJDK, als Bytecode auf dem GraalJDK und als Native Image ausgeführt. Die durchschnittlichen Programmausführungszeiten sind in Tabelle 1 aufgeführt.

Hello WorldHelloScriptEngineHelloScriptPolyglot
Bytecode OpenJDK69 ms1321 msX
Bytecode GraalJDK116 ms2889 ms2775 ms
Native Image4 ms13 ms11 ms

Tabelle 1: Beispiel für durchschnittliche Programmausführungszeiten

Auf den ersten Blick fällt ins Auge, das die Ausführung als Native Image in allen drei Szenarien jeweils um ein Vielfaches schneller ist als die übliche Bytecode-Ausführung.

Auf den zweiten Blick fällt aber auch auf, dass die Bytecode-Ausführung mit dem GraalJDK wesentlich mehr Rechenzeit benötigt als mit dem OpenJDK: Im HelloWorld-Beispiel knapp 70 % mehr, bei dem HelloScriptEngine-Beispiel über 100 % mehr. Das wurde von Oracle so nicht kommuniziert, ist aber grundsätzlich auch kein allzu großes Problem, da die Motivation für den Einsatz der GraalVM vermutlich nicht in der schnelleren Bytecode-Ausführung liegt. Man sollte diesen Fakt aber im Hinterkopf behalten, wenn man den relevanten Speed-Up durch das Native Image ermitteln möchte: Zur Erstellung des Native Image muss schließlich die GraalVM installiert sein. Wenn man nun zum Vergleich die Bytecode-Ausführung misst und „java -jar …“ ausführt, wird der Bytecode mittels der GraalVM ausgeführt. Da im produktiven Betrieb aber bisher vermutlich eher das OpenJDK eingesetzt wurde, sollte man eher mit diesem vergleichen – und damit wäre der Speed-Up „nur“ noch reichlich halb so hoch.

Was zu bedenken ist

Um die versprochenen Performance-Gewinne zu erzielen, reicht es nicht, die GraalVM anstatt eines herkömmlichen JDKs zu installieren. Bei der Bytecode-Ausführung konnte – zumindest mit unseren Beispielen und unserem Setup – noch kein Performance-Gewinn erreicht werden. Dies ist erst mit einem Native Image möglich, welches gegenüber der Bytecode-Ausführung jedoch auch mehrere Nachteile hat, derer man sich bewusst sein sollte.

  • Im Native Image wird die SubstrateVM als JVM verwendet. Diese besitzt einige Einschränkungen. Abgesehen davon, dass derzeit noch nicht alle Features umgesetzt sind, stehen einige Dinge wie z. B. ein Security Manager gar nicht auf der Agenda.
  • Weiterhin sollte die Dauer des Build-Prozesses beachtet werden: Beim Native Image verschwindet die Startzeit nicht einfach. Die Rechenzeit wird mit verschiedenen Ansätzen „lediglich“ verlagert: Von der Ausführungszeit zur Build-Zeit. Die Erstellung unseres HelloWorld-Beispiels hat in unserer Umgebung reichlich drei Minuten benötigt – die Erstellung des HelloScript-Programmes dauerte bereits mehr als 20 Minuten (HelloScriptEngine: 1291s, HelloScriptPolyglot: 1251s).
  • Die größte Herausforderung ist aber die „Closed world assumption“. Bei der Erstellung des Native Image wird eine statische Codeanalyse durchgeführt – und nur die durchlaufenen Code-Teile werden in das Native Image kompiliert. Das funktioniert zwar für unser HelloWorld-Programm, aber bereits bei den JavaScript-Beispielen mussten Kommandozeilen-Parameter angegeben werden. Mittels „Reflection“ geladene Klassen werden nur erkannt, wenn der vollqualifizierte Klassenname festverdrahtet im Quellcode steht. Demzufolge gibt es Probleme mit allen Technologien, welche Dynamic Class Loading in irgendeiner Form nutzen: JNDI, JMX, …

Die dynamisch geladenen Programmteile können (und müssen) bei der Erstellung des Native Image explizit angegeben werden. Das schließt alle Programmteile ein: Neben dem eigenen Projekt-Code sind das auch alle eingesetzten Bibliotheken – bis hin zu denen der JRE. Da diese Konfiguration für „echte“ Programme eine wirkliche Herausforderung ist, werden hierzu Hilfswerkzeuge bereitgestellt, ohne die es in der Praxis vermutlich nicht funktioniert. Der Tracing Agent beispielsweise beobachtet ein als Bytecode ausgeführtes Programm. Er erkennt alle reflektiven Zugriffe und erzeugt daraus eine JSON-Konfiguration. Diese kann nun für die Erstellung des Native Image verwendet werden.

In der Praxis würde die Build-Pipeline also zunächst die Bytecode-Variante erstellen. Nun können alle automatisierten Tests mit dieser Bytecode-Variante ausgeführt werden, wobei der Tracing Agent die reflektiven Zugriffe erkennt. Unter der Annahme, dass dabei wirklich jeder Programmpfad ausgeführt wurde, kann nun das Native Image in einem weiteren Build-Schritt erzeugt werden. Dies führt direkt zum nächsten Punkt: Bei der Arbeit mit der Native-Image-Technologie wird der Build-Prozess insgesamt länger und komplexer.

Zusammenfassend bedeutet dies, dass beim Einsatz der Native-Image-Technologie einige Dinge kaum oder nicht möglich sind (bspw. Security Manager). Viele andere Dinge funktionieren zwar grundsätzlich, müssen aber umständlich konfiguriert werden. Hierfür ist eine Tool-Unterstützung gegeben und wird auch recht dynamisch weiterentwickelt. Die Hoffnung ist hier, dass die Mehraufwände (abgesehen von der Build-Dauer) durch die Werkzeuge kompensiert werden können. Allerdings wird dadurch der Build-Prozess auch komplexer und somit fehleranfälliger.

Fallstricke unter Windows

Abschließend noch ein Blick zur Windows-Plattform: Diese wird mittlerweile auch unterstützt. Vorbereitend für diesen Blogbeitrag wurden die Versionen „GraalVM Enterprise 20.3.0″ sowie „GraalVM Enterprise 21.0.0.2“ auf einem Windows-System betrachtet. Leider war hier die Dokumentation noch etwas lückenhaft und das Tooling greift noch nicht so gut ineinander wie in der Linux-Umgebung. Dadurch gab es auch Hindernisse, die unter Linux nicht aufgefallen sind. So trat beispielsweise ein Problem bei der Erstellung eines Native Images auf, wenn der zugrundeliegende Bytecode von einem anderen JDK (in dem Fall durch das OpenJDK) erzeugt wurde. Dabei ist die auftretende Fehlermeldung leider auch nicht so aussagekräftig, dass sie auf die eigentliche Ursache hinweist:

native-image de.zeiss.zdi.graal.HelloWorld

[de.zeiss.zdi.graal.helloworld:20764]    classlist:     947.02 ms,  0.96 GB
[de.zeiss.zdi.graal.helloworld:20764]        (cap):   3,629.54 ms,  0.96 GB
[de.zeiss.zdi.graal.helloworld:20764]        setup:   5,005.98 ms,  0.96 GB

Error: Error compiling query code (in C:\Users\xyz\AppData\Local\Temp\SVM-13344835136940746442\JNIHeaderDirectives.c). Compiler command ''C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.28.29333\bin\HostX64\x64\cl.exe' /WX /W4 /wd4244 /wd4245 /wd4800 /wd4804 /wd4214 '-IC:\Program Files\Java\graalvm-ee-java11-21.0.0.2\include\win32' '/FeC:\Users\xyz\AppData\Local\Temp\SVM-13344835136940746442\JNIHeaderDirectives.exe' 'C:\Users\xyz\AppData\Local\Temp\SVM-13344835136940746442\JNIHeaderDirectives.c' ' output included error: [JNIHeaderDirectives.c, Microsoft (R) Incremental Linker Version 14.28.29337.0, Copyright (C) Microsoft Corporation.  All rights reserved., , /out:C:\Users\xyz\AppData\Local\Temp\SVM-13344835136940746442\JNIHeaderDirectives.exe , JNIHeaderDirectives.obj , LINK : fatal error LNK1104: Datei "C:\Users\xyz\AppData\Local\Temp\SVM-13344835136940746442\JNIHeaderDirectives.exe" kann nicht ge?ffnet werden.]

Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Error: Image build request failed with exit status 1

Ein weiterer Fallstrick bestand in der laufwerksübergreifenden Arbeit: Es ist unter Windows leider nicht möglich, die GraalVM auf einem Laufwerk zu installieren (in dem Fall unter C:\Programme) und auf einem anderen Laufwerk auszuführen (in dem Fall unter D:\dev\prj\…):

native-image de.zeiss.zdi.graal.HelloWorld

[de.zeiss.zdi.graal.helloworld:10660]    classlist:   3,074.80 ms,  0.96 GB
[de.zeiss.zdi.graal.helloworld:10660]        setup:     314.93 ms,  0.96 GB

Fatal error:java.lang.IllegalArgumentException: java.lang.IllegalArgumentException: 'other' has different root
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
[…]

Darüber hinaus konnten mit dem Native Image in der Windows-Umgebung leider keine Performance-Vorteile festgestellt werden. Derzeit ist die Windows-Unterstützung (sowohl die GraalVM-Toolchain selbst als auch die generierten Native Images) somit noch eher experimentell.

Fazit

Im Blogbeitrag wurde vor allem darauf eingegangen, dass die Startzeit der Java-Programme mit GraalVMs Native-Image-Technologie enorm verbessert werden kann. Es wurde dargestellt, welchen Ansätzen und welcher Techniken sich die GraalVM dabei bedient. Das Resultat wurde mit Messungen an Beispiel-Programmen untermauert. Auf der anderen Seite wurden aber auch einige Herausforderungen genannt, welche beim Einsatz der Native-Image-Technologie auftreten.

Mit länger laufenden Programmen sind vermutlich kaum Performance-Steigerungen zu erwarten, weil dann auch die Optimierungen der herkömmlichen JVMs greifen. Das ist erst einmal nur eine Behauptung. Eine Untersuchung dieses Aspekts würde den aktuellen Rahmen sprengen und hat genug Potenzial für einen eigenen Blogbeitrag.

Nun wollen wir uns noch kurz den Fragen aus der Einleitung zuwenden: Grundsätzlich ist das „herkömmliche“ Java schon lange nicht mehr langsam, sondern rasend schnell. Schon allein der Einsatz im (rechenintensiven) Big-Data-Umfeld ist ein Indiz hierfür. Die hauptsächliche Vorbedingung für diese hohe Performance ist eine gewisse Warmlaufzeit. Im Umkehrschluss heißt das: Der Start eines herkömmlichen Java-Programms lässt zu wünschen übrig – und genau dies ist der Hauptansatzpunkt der Native-Image-Technologie. Auf der anderen Seite bringt diese Technologie auch eine Menge Nachteile mit – vor allem für große, technik-reiche Anwendungen.

Zusammenfassend lässt sich sagen, dass die GraalVM das Potenzial hat, sich in verschiedenen Einsatzgebieten zu etablieren. Die polyglotten Eigenschaften können sich mehrsprachige Anwendungen zunutze machen und der Einsatz der betrachteten Native-Image-Technologie ist vor allem für Cloud Services durchaus denkbar. Allerdings wird sich der Einsatz der GraalVM vor allem für rechenintensive (d. h. meist auch länger laufende) nicht-triviale Anwendungen vermutlich nicht lohnen.

Abschließend soll noch als Vorteil für die GraalVM festgehalten werden, dass Compiler und Optimizer in Java implementiert sind. Das ist zwar zunächst grundsätzlich nicht besser oder schlechter als die bisherigen Implementierungen – wird aber mittel- bis langfristig die Chancen erhöhen, das Potential der Java-Community besser zu nutzen.

Alles in allem bleibt es wohl spannend: Eine grundsätzliche Ablösung des OpenJDK ist derzeit nicht absehbar. Und man darf schließlich auch nicht vergessen, dass die Entwicklung dort genauso wenig stillsteht. Allerdings hat die GraalVM durchaus das Potenzial, um sich (zunächst erst einmal?) in bestimmten Anwendungsgebieten zu etablieren.

Dieser Beitrag wurde verfasst von: