Eine, um alles zu beherrschen

Eine Einführung in GraalVM, Oracles neue Virtual Machine

Oliver Fischer

@ Esteban De Armas/Shutterstock.com

Die HotSpot Java VM ist nun gut zwei Jahrzehnte alt. Seitdem hat sich die Welt der Softwareentwicklung stark verändert. Zeit also, HotSpot in Rente zu schicken? Seit Längerem arbeiten die Oracle Labs an einem neuen Compiler für die JVM, der polyglott und hochperformant sein soll.

Seit einigen Monaten stellt Oracle der Allgemeinheit die ersten Release Candidates der GraalVM, einer neuen virtuellen Maschine, zur Verfügung (zum Zeitpunkt der Niederschrift des Artikels ist RC10 aktuell). Wenn eine Software mit ihrem Namen Bezug auf den heiligen Gral nimmt, legt das die Messlatte gleich um einige Zentimeter höher. Erinnern wir uns: Der heilige Gral verspricht seinem Besitzer nicht weniger als Lebenskraft, Jugend, Nahrung in Hülle und Fülle sowie Glückseligkeit und damit alles in allem quasi die Unsterblichkeit. Entsprechend lang ist auch die Featureliste der GraalVM, die aus einer Reihe von Forschungsprojekten am Institut für Softwaresysteme der Linzer Johannes-Kepler-Universität in enger Zusammenarbeit mit den Oracle Labs hervorgegangen ist.

Nicht ohne Grund ist im Namen nur die Abkürzung VM enthalten und nicht JVM, denn auf einer anderen Architektur kann sie nicht nur Java-Bytecode ausführen, sondern ist eine echte polyglotte virtuelle Maschine, auf der prinzipiell jede Programmiersprache ausgeführt werden kann. Damit stellt die GraalVM etwas Neues dar. Denn auch wenn schon seit Langem der Begriff polyglott für die JVM in Bezug auf Sprachen wie Groovy, Kotlin oder auch JRuby verwendet wird, ist die JVM selbst nicht polyglott: Sie selbst kann nur Bytecode ausführen. Dass es Compiler gibt, die für eine bestimmte Sprache Bytecode für die JVM erzeugen können, hat nichts mit der JVM zu tun, sondern erlaubt es uns Entwicklern nur, mit anderen Sprachen für die JVM entwickeln zu können. Die GraalVM hingegen lässt solche Grenzen hinter sich und ist als universelle Virtual Machine ausgelegt. So unterstützt sie alle Sprachen, für die es einen Bytecode-erzeugenden Compiler gibt, sprich, wer mit Java, Scala, Clojure und Co. arbeitet, kann jetzt schon die GraalVM nutzen. Derzeit unterstützt die sie Java bis Version 8; Java 11 und höher sollen später folgen. Neben diesen Sprachen für die JVM können auch Programme in der Statistiksprache R, Python, Ruby, JavaScript sowie LLVM-Bitcode durch die GraalVM ausgeführt werden.

Aber warum braucht es überhaupt eine neue virtuelle Maschine, wenn es doch schon andere gibt? Sicherlich gibt es nicht den einen Grund, und – wie bei einigen Projekten – auch nicht den großen Plan am Anfang. Wohl eher flossen Möglichkeiten und Bedürfnisse zum richtigen Zeitpunkt zusammen. In den vergangenen Jahren sind viele neue Sprachen auf der Bühne der Softwareentwicklung erschienen. Dabei geht es nicht um exotische Sprachen oder Projekte ohne praktische Relevanz für die tägliche Arbeit. Vielmehr geht es um Sprachen wie Scala oder Kotlin, die anderen Paradigmen folgen, oder Sprachen wie Go und Rust, die von Organisationen vorrangig für ihre eigenen Bedürfnisse entwickelt wurden. Die Motivation für viele JVM-basierte Sprachen war es, hinsichtlich der Sprachfeatures in einigen Bereichen besser als Java zu sein und Dinge zu vereinfachen. In anderen Bereichen stellte Java bis jetzt nie eine echte Alternative dar. Sei es, weil die Installation des Java Runtime Environments (JRE) zusammen mit der Anwendung oft zu aufwendig, sei es weil die Start-up-Zeit der Java Virtual Machine vor dem jeweiligen Anwendungshintergrund nicht praktikabel ist. Aber auch Trends in der Softwarearchitektur, wie zum Beispiel Serverless Computing oder schnell skalierende Cloudarchitekturen, bei denen es nicht auf maximalen Durchsatz, sondern auf schnelle Startzeiten ankommt, setzen Java unter Druck. Projekte wie Kotlin/Native bestätigen das ebenso wie der Erfolg von Go in der Systementwicklung.

Die Entwicklung von Java war bis Java 9 eher behäbig als schnell, und der Innovationsdruck ist, wie gerade gezeigt wurde, hoch. Java als weitverbreitete Sprache weiterzuentwickeln ist nicht einfach, denn Änderungen an der Sprache können auch Änderungen an anderen Stellen des JDK, beispielsweise der JVM, erforderlich machen. Dabei hat HotSpot ein Problem: HotSpot ist in C++ geschrieben, ist komplex und hat eine inzwischen sehr alte Codebasis. Zwar hat sich auch C++ weiterentwickelt, doch eine bestehende Codebasis zu modernisieren, ist aufwendig. Zudem wird C++ immer weniger an den Universitäten gelehrt. Damit ist es schwieriger, auch Nachwuchs für die Arbeit an HotSpot zu finden.

Bezug und Installation

Oracle stellt zwei Varianten der GraalVM bereit: die kostenfrei einsetzbare Community Edition und die kostenpflichtige Enterprise Edition. Beide Varianten sind derzeit nur für macOS und Linux verfügbar. Erwartungsgemäß sind bestimmte Features der Enterprise Edition vorbehalten. So kann von mit der Enterprise Edition erzeugten Native Binarys ein Heapdump gezogen und auf DWARF-Debugging-Informationen zugegriffen werden. Zudem kann die Enterprise Edition bessere Optimierungen vornehmen als die Community Edition. Die Community Edition ist über die Projektwebseite erhältlich, die Enterprise Edition über das Oracle Technology Network. Beide Editionen können als .tar.gz-Datei heruntergeladen werden. Zur Installation ist es daher ausreichend, das jeweilige Archiv zu entpacken und die Variable JAVA_HOME auf das entpackte Verzeichnis zu setzen.

Rückblick

Mit Java 1.3 hielt im April 1999 die HotSpot JVM Einzug in die damals noch sehr junge Java-Welt. Java 1.0 war drei Jahre zuvor erschienen, kannte damals weder innere Klassen noch Reflection. Das Collections Framework kam erst in Java 1.2 hinzu. Diese Version war auch die erste Version mit einem Just-in-Time-Compiler (JIT) für Bytecode, der vorher nur interpretiert ausgeführt wurde. Das liegt jetzt gut zwanzig Jahre zurück, und wahrscheinlich ist der eine oder andere Leser zu dieser Zeit noch gar nicht auf der Welt gewesen. Vorherrschend für die Anwendungsentwicklung waren zu dieser Zeit C und C++. Viele Geschäftsanwendungen wurden auch mit Borlands Delphi entwickelt, und das Internet wurde langsam von einer breiteren Gruppe wahrgenommen.

Die meisten Java-Programmierer der ersten Stunde waren der Dominanz von C und C++ entsprechend vorher C/C++-Programmierer, denen der Umstieg auf Java wegen der großen Ähnlichkeit der Sprachen leichtfiel. Aufgrund ihrer Verbreitung und der hohen Geschwindigkeit von C und C++ wurde auch die HotSpot JVM in C++ mit Assemblerteilen geschrieben. Der damals geschriebene Java-Code unterschied sich auch von Code, wie er heute geschrieben würde. Wo heute Streams zum Einsatz kämen, herrschten damals for-Schleifen, und wenn es performancekritisch wurde, galt es, die Erzeugung von neuen Objekten zu vermeiden oder sie zu cachen. Die Einführung der HotSpot JVM brachte hier durch die Just-in-Time Compilation eine wesentliche Verbesserung. Vor allem die von HotSpot durchgeführten dynamischen Optimierungen wie beispielsweise Inlining, Dead Code Elimination oder Loop Unrolling trugen wesentlich zu einer besseren Performance bei. Java-Code mit aktuellen Features wie Autoboxing, Lambdas und Streams steigern auf der einen Seite sowohl die Eleganz als auch die Expressivität des Codes, sind aber auf der anderen Seite auch langsamer als klassische Konstrukte wie beispielsweise for-Schleifen. Grund hierfür ist, dass im Hintergrund oft viele kurzlebige Objekte erzeugt werden, die anschließend wieder weggeräumt werden müssen. Denn auch die Tatsache, dass die Garbage Collectors leistungsfähiger und die Erzeugung von Objekten immer billiger geworden sind, ändert an diesem Problem nichts.

Somit werden Techniken wichtiger, die stärker auf die Optimierung von aktuell erzeugtem Bytecode einzahlen oder am besten die Erzeugung von neuen Objekten im Heap gleich ganz vermeiden. Grundlage hierfür ist beispielsweise die sogenannte Escape-Analyse. Dabei werden die möglichen Ausführungspfade von einem Codeabschnitt daraufhin untersucht, ob erzeugte Objekte den aktuellen Scope verlassen können. Ein Beispiel hierfür ist das Hinzufügen eines neuen Objekts in eine als Parameter übergebene Map oder dessen Rückgabe als Return-Wert. In diesen Fällen kann das Objekt auch nach der Beendigung einer erzeugenden Methode weiter existieren und verwendet werden. Kann dies durch statische Codeanalyse ausgeschlossen werden, kann in der untersuchten Methode die Objekterzeugung eliminiert und statt mit Objekten mit Variablen gearbeitet werden. Im besten Fall würde es sich um Variablen für primitive Typen handeln, die nur auf dem Stack lägen. Doch selbst wenn es sich bei den Variablen um Referenzen auf Objekte im Heap handeln würde, käme eine solche Optimierung der Garbage Collection zugute, da die zu analysierenden Strukturen einfacher wären.

Die Architektur von Graal

Ehe wir uns den Interna der GraalVM zuwenden können, ist es wichtig, sich noch einmal mit dem Aufbau des Java Development Kit (JDK) und der JVM auseinanderzusetzen. Das JDK umfasst sowohl alle für die Entwicklung als auch die Ausführung von Java-Programmen benötigten Komponenten. Für die Entwicklung stellt das JDK Werkzeuge wie den Java-Compiler javac und andere Tools bereit. Für die Ausführung ist hingegen die Java Runtime Environment (JRE) zuständig, die entweder stand-alone oder als Teil des JDK verfügbar ist. Herzstück der JRE ist die HotSpot JVM (java).

Die JVM (Abb. 1) ist eine Implementierung der Java Virtual Machine Specification und besteht selbst aus mehreren Komponenten. Das Class-Loader-Subsystem ist zuständig für das Laden, Verifizieren und Linken von Class-Dateien und die dann erfolgende Initialisierung von statischen Feldern sowie die Ausführung von statischen Codeblöcken. Die Runtime Data Areas enthalten vereinfacht gesagt alle für die Ausführung notwendigen Daten wie den Heap oder den Stack des aktuellen Threads. Die Execution Engine stellt den Interpreter, den JIT-Compiler sowie den Garbage Collector bereit. Im Kontext der GraalVM ist der JIT-Compiler von besonderem Interesse. Java-Code wird anfänglich nur durch den Interpreter in der Execution Engine ausgeführt.

Das bedeutet, dass der Java-Bytecode ohne jegliche Optimierung eins zu eins in Maschinencode umgesetzt und ausgeführt wird. Sind während der Ausführung genügend Informationen vom Profiler gesammelt und ist eine Methode hinreichend oft ausgeführt worden, beginnt die Arbeit des JIT-Compilers. Aufgrund der gesammelten Profiling-Daten kann der JIT-Compiler entscheiden, wie die Methode optimiert werden kann, ehe sie in Maschinencode übersetzt wird. Die HotSpot JVM enthält zwei JIT-Compiler: C1, einen schnellen und nur leicht optimierenden Compiler, ursprünglich gedacht für Desktopanwendungen, und C2, einen aggressiv optimierenden Compiler für Serveranwendungen, bei denen es auf höchsten Durchsatz ankommt.

Abb. 1: Schematische Darstellung der JVM

Abb. 1: Schematische Darstellung der JVM

Lange waren diese beiden Compiler der HotSpot JVM nicht austauschbar. Mit dem Java Enhancement Proposal 243 wurde 2014 die Schaffung eines Java Virtual Machine Compiler Interface (JVMCI) vorgeschlagen, um andere, selbst in Java geschriebene Compiler nutzen zu können. Neben der reinen Modularisierung würde so sowohl die Leistungsfähigkeit von Java gezeigt als auch eine breitere Basis für Entwicklungsarbeiten an der JVM geschaffen.

Anders als der Name es vermuten lässt, ist das Herzstück der GraalVM keine eigene virtuelle Maschine, sondern vielmehr der selbst in Java geschriebene und hoch optimierende Graal-Compiler. Die GraalVM nutzt als Grundlage OpenJDK, in das es über das JVMCI den Graal-Compiler integriert, der dort C2 ersetzt. Der Name GraalVM ist wohl daher eher aus Marketinggründen gewählt worden, richtig wäre wohl eher „OpenJDK/Oracle JDK with Graal Compiler and additional Tooling“.

Damit ein Compiler unterschiedliche Sprachen unterstützen kann, muss dieser mit einer sprachunabhängigen Zwischenrepräsentation zwischen der Quellsprache und dem zu erzeugenden Maschinencode arbeiten. Im Fall von Graal wurde als Format für diese Zwischenpräsentation ein Graph gewählt. Der wesentliche Vorteil eines Graphen ist, dass sich ähnliche Statements verschiedener Sprachen gleich darstellen lassen. foreach-Schleifen in Python und Java lassen sich im Graphen gleich darstellen, ebenso wie ein if-Statement in fast jeder Sprache. Durch diese sprachunabhängige Darstellung ist es auch möglich, mehrere Sprachen im gleichen Programm zu verwenden. Um sie aus Sicht des Compilers als ein Programm zu behandeln, ist es lediglich notwendig, aus ihnen einen gemeinsamen Graphen zu erzeugen. Auf diesem Graphen kann dann sprachunabhängig optimiert und Maschinencode erzeugt werden.

Der Graal-Compiler ist nur eine, wenn auch zentrale Komponente für die GraalVM. So stellt sie den LLVM Bitcode Interpreter Sulong bereit, wodurch es möglich ist, jede Sprache, für die es ein LLVM Frontend gibt, mit der GraalVM auszuführen. Die SubstrateVM erlaubt es, aus Java-Programmen via Ahead-of-Time (AOT) Compilation Native Binaries zu generieren. Das ebenfalls in Java geschriebene Truffle Framework, das die Grundlage für die Unterstützung anderer Sprachen ist, stellt ein API bereit, über das Interpreter für Programmiersprachen gebaut werden können, die anschließend mittels der GraalVM ausführbar sind. Durch die Ausführung durch die GraalVM profitieren die so unterstützten Sprachen auch von den Optimierungsmöglichkeiten des Graal-Compilers. Abbildung 2 zeigt das Zusammenspiel der einzelnen Komponenten.

Abb. 2: Aufbau der GraalVM

Abb. 2: Aufbau der GraalVM

 

Performance im Vergleich

Für das Versprechen, unabhängig von der eigentlichen Quellsprache immer eine hohe Ausführungsgeschwindigkeit zu erreichen, sind die Optimierungsmöglichkeiten des Graal-Compilers ausschlaggebend. Um die so erreichbaren Performanceeigenschaften der GraalVM mit denen anderer JVMs zu vergleichen, dient das Programm Top Ten von Chris Seaton. Es ermittelt aus einer ca. 144 MiB großen Textdatei die zehn häufigsten Wörter unter Nutzung von Javas Streaming API und wurde für die Messung in einen JMH-Benchmark umgeschrieben (Listing 1).

public class TopTenBenchmark {
private Stream<String> fileLines(String path) {
  try { return Files.lines(Paths.get(path));
    } catch (IOException e) { throw new RuntimeException(e); }
  }
@BenchmarkMode(Mode.SampleTime)
@Benchmark
public void topten(Blackhole blackhole) {
  Arrays.stream(new String[]{"large.txt"}
    .flatMap(this::fileLines)
    .flatMap(line -> Arrays.stream(line.split("\\b")))
    .map(word -> word.replaceAll("[^a-zA-Z]", ""))
    .filter(word -> word.length() > 0)
    .map(String::toLowerCase)
    .collect(Collectors.groupingBy(Function.identity(), 
             Collectors.counting()))
    .entrySet().stream()
    .sorted((a, b) -> -a.getValue().compareTo(b.getValue()))
    .limit(10)
    .forEach(e -> blackhole.consume(format("%s = %d%n", e.getKey(), e.getValue())));
  }
}

Für die Ausführung des Benchmarks, dessen Ergebnisse in Tabelle 1 für die unterschiedlichen JDK-Versionen zusammengefasst wurden, kam ein MacBook Pro 2017 mit einem 2,5 GHz Intel Core i7 zum Einsatz.

JDK Zeit in Sekunden
Oracle JDK 8u192 15,801
OpenJDK 8u192 15.250
GraalVM 1.0 CE RC 10 13.814
GraalVM 1.0 EE RC 10 9.867
OpenJDK 11 (Open 9) 26,268
OpenJDK 11 16,920
Oracle JDK 11 17,102

Tabelle 1: Ausführungszeit des Top-Ten-Benchmarks für unterschiedliche JDK

Für jedes verglichene JDK wurde der Benchmark mit fünf Durchläufen zum Aufwärmen und fünf Messdurchläufen ausgeführt. Im Fall der GraalVM muss noch bedacht werden, dass die JVM hier selbst noch den in Java geschriebenen Graal-Compiler übersetzen muss. Für das gewählte Beispiel erreichen die beiden GraalVM-Editionen die beste Ausführungsperformance. Die Community Edition benötigt rund vierzehn Sekunden, die Enterprise Edition schlägt diesen Spitzenwert mit zehn Sekunden nochmals um vier Sekunden. An letzter Stelle liegt OpenJDK 11 mit der Open9-JVM mit einem Abstand zum besten Wert von achtzehn Sekunden. Diese Werte können und sollen nicht verallgemeinert werden, zeigen aber doch, um welche Größenordnungen die einzelnen virtuellen Maschinen auseinander liegen können. Dass die Enterprise Edition der GraalVM deutlich schneller ist als die Community Edition, zeigt, dass Oracle hier bewusst eine Trennlinie zwischen dem zieht, was kostenfrei und was kostenpflichtig zu bekommen ist.

Native Binaries erzeugen

Der Wunsch, Java-Programme als richtiges Native Binary vom Betriebssystem ohne Umweg über die Java Virtual Machine ausführen zu können, existiert schon lange. Java-Programme könnten so leichter verteilt und installiert werden, da keine in einem zusätzlichen Schritt auszuführende JRE-Installation mehr notwendig ist. Auch müssten keine .jar-Dateien von benötigten Bibliotheken mehr verteilt und der entsprechende Klassenpfad für die Ausführung bestimmt werden.

Weiterhin hätte ein Native Binary den Vorteil, schneller zu starten, da es bereits vor der Ausführung in Maschinencodeform übersetzt worden ist (AOT Compilation) und nicht erst zur Laufzeit noch durch den JIT-Compiler in solchen übersetzt werden muss. Damit ist das Performanceverhalten von Native Binaries konstant und vorhersagbar, denn der Code wird nicht mehr zur Laufzeit durch den JIT-Compiler im Zusammenspiel mit dem Profiler optimiert. Native Binaries brauchen auch wesentlich weniger Speicher, denn die Infrastruktur der JVM für den JIT-Compiler muss nicht mitlaufen.

An einer Verbesserung der Start-up-Zeit hat Oracle in verschiedenen JDK-Versionen durch Techniken wie CDS (Class Data Sharing), AppCDS (Application Class Data Sharing) und dem mit Java 9 inoffiziell eingeführten AOT-Compiler jaotc, der selbst eine frühere Version des Graal-Compilers nutzt, gearbeitet. Doch bei jeder dieser Techniken wurde auch weiterhin ein installiertes JRE benötigt, und auch wenn messbare Verbesserungen bei der Startgeschwindigkeit festgestellt werden konnten, waren diese nicht signifikant.

Für die Erzeugung von Native Binaries kommt mit der GraalVM das Programm native-image, mit dem einzelne .jar– oder .class-Dateien in Maschinencode übersetzt werden können. Die Herausforderung bei Java-Programmen, wie bei allen JDK-basierten Sprachen, liegt in der Möglichkeit, Klassen zur Laufzeit nachzuladen. Dieses mächtige Feature wird oft auch benutzt, um zur Laufzeit zu bestimmen, welche Frameworks genutzt werden können. So könnte ein Programm zur Laufzeit testen, welches Logging Framework im Klassenpfad liegt, und abhängig vom Ergebnis dieser Suche sein Logging konfigurieren. Das verbreitetste Beispiel für diese Art der Konfiguration zur Laufzeit dürfte das Spring Framework sein. Dynamisches Verhalten wie dieses stellt für die Ahead-of-Time Compilation eine große Herausforderung dar.

native-image, das intern für die Übersetzung auch den Graal-Compiler verwendet, analysiert daher während der Übersetzung alle möglichen Pfade, die das Programm bei einem gegebenen Klassenpfad durchlaufen kann. Diese Analyse führt dazu, dass eine Übersetzung, verglichen mit javac oder dem LLVM-Compiler clang, wesentlich länger dauert. Dafür wird zuverlässig bestimmt, welche Klassen benötigt werden und welche nicht. Mittels native-image erzeugte Programme sind daher auch um Größenordnungen kleiner (meist nur ein paar Megabyte) als die Summe aus JDK/JRE und benötigten Bibliotheken. Normalerweise müssen Java-Programmierer sich nicht um Thread- und Speichermanagement kümmern, dies übernimmt die JVM für sie automatisch. Auch im Fall von Native Binaries bleibt das so. Hierfür sorgt die SubstrateVM, die selbst in Java geschrieben ist und von native-image in das erzeugte Binary mit hineinkompiliert wird.

Doch es gibt auch Besonderheiten bei der Erzeugung von Native Binaries zu beachten. Oft wird in Java die statische Initialisierung genutzt, also die Möglichkeit, statische Felder direkt beim Laden der Klasse durch eine direkte Zuweisung oder in einem statischen Block zu erstellen. native-image führt standardmäßig statische Initialisierungen während der Übersetzung aus, was dazu führt, dass die erzeugten Programme bei jeder Ausführung mit den immer exakt gleichen Werten laufen. So kann die Start-up-Zeit verbessert werden.

Werden während der statischen Initialisierung nur Listen mit Konstanten gefüllt, ist dies unkritisch. Werden jedoch laufzeitabhängige Werte wie ein Datum zugewiesen, wird das Programm immer genau mit diesem Datumswert arbeiten.

public class DaysUntilChristmas {
  private static LocalDateTime now = LocalDateTime.now();

  public static void main(String[] args) {
    LocalDateTime christmas = LocalDateTime.of(now.getYear(), 12, 1, 0, 0);
    Duration timeLeft = Duration.between(now, christmas);
    System.out.println("Weihnachten ist in " + timeLeft);
  }
}
> export JAVA_HOME=/opt/graal/graalvm-ce-1.0.0-rc10/Contents/Home/
> mkdir out
> $JAVA_HOME/bin/javac -d out src/main/java/net/sweblog/playground/graal/weihnachten/DaysUntilChristmas.java
> $JAVA_HOME/bin/native-image --no-server -H:Name=duc -cp out net.sweblog.playground.graal.weihnachten.DaysUntilChristmas
[duc:41559]    classlist:   1,426.77 ms
[duc:41559]        (cap):     855.38 ms
[duc:41559]        setup:   2,057.42 ms
[duc:41559]   (typeflow):   2,604.42 ms
[duc:41559]    (objects):     617.27 ms
[duc:41559]   (features):     109.90 ms
[duc:41559]     analysis:   3,389.01 ms
[duc:41559]     universe:     165.76 ms
[duc:41559]      (parse):     588.47 ms
[duc:41559]     (inline):   1,256.12 ms
[duc:41559]    (compile):   5,068.60 ms
[duc:41559]      compile:   7,150.38 ms
[duc:41559]        image:     401.07 ms
[duc:41559]        write:     154.97 ms
[duc:41559]      [total]:  14,988.09 ms
> ./duc && sleep 5 && ./duc
Weihnachten ist in PT7516H23M23.698S
Weihnachten ist in PT7516H23M23.698S

Listing 2 zeigt ein als Beispiel dienendes Programm, das die verbleibende Zeit bis Weihnachten berechnet. Listing 3 zeigt, wie dieses Programm übersetzt und zweimal ausgeführt wird. Dass das Feld now bei jeder Ausführung den gleichen Zeitstempel hat, ist daran ersichtlich, dass die verbleibende Zeit immer die gleiche ist.

Noch ein anderes Java-Feature stellt die Ahead-of-Time-Übersetzung vor Probleme: Reflection. native-image versucht hier so gut wie möglich, automatisch bei der Analyse des zu übersetzenden Bytecodes das Ergebnis des jeweils genutzten Reflection API zu bestimmen. Möglich ist das, wenn das Ergebnis konstant ist, beispielsweise wenn bei einem Aufruf von Class.forName(String className) im Rahmen der statischen Analyse ermittelt werden kann, dass der Wert von className immer derselbe, also konstant ist. In diesem Fall kann die Anweisung beispielsweise so umgeschrieben werden, als ob für die bestimmte Klasse direkt newInstance() aufgerufen werden würde. Jede Nutzung von Reflection, deren Ergebnis nicht exakt vorausbestimmt werden kann, ist ein Fehler.

Für jeden dieser einschränkenden Punkte steht aber auch eine Lösung. Kann oder soll die statische Initialisierung von Werten nicht vermieden werden, verfügt native-image über die Option –delay-class-initialization-to-runtime, mit der eine kommaseparierte Liste von Klassen angegeben werden kann, deren Initialisierung doch erst zur Laufzeit durchgeführt werden soll. Erlangt die GraalVM eine größere Verbreitung, dürfte wohl aber auch die statische Initialisierung von laufzeitabhängigen Feldern zukünftig als Bad Practice gelten.

Ebenso können alle für die korrekte Unterstützung von Reflection notwendigen Informationen entweder über Konfigurationsdateien oder programmatisch mittels des für die SubstrateVM und die GraalVM verfügbaren API spezifiziert werden. Mittels dieses API können auch fremde Anwendungen wie Netty oder Tomcat so angepasst werden, dass aus ihnen auch Native Binaries erzeugbar sind.

Für wen sind Native Binaries interessant? Für jeden, der auf den Write-once-run-anywhere-Anspruch von Java verzichten kann oder dem eine schnelle Start-up-Zeit wichtiger ist als ein maximaler Durchsatz. Diesen zu erreichen, ist nur mittels der Kombination aus Laufzeit-Profiling und JIT-Compiler möglich. Wie groß der Unterschied zwischen AOT und JIT sein kann, zeigt Listing 4. Hier ist die Ausführungszeit von Top Ten als normales Programm bei einer einzelnen Ausführung mittels der GraalVM und als Native Binary über die Befehlszeile zu sehen.

> /usr/bin/time $JAVA_HOME/bin/java -cp target/classes net.sweblog.playground.graal.TopTen 1>&-
       16.25 real        22.21 user         0.67 sys
> /usr/bin/time ./TopTen 1>&-
       36.25 real        35.99 user         0.18 sys

Als Native Binary läuft Top Ten mehr als zwanzig Sekunden länger als bei einer Ausführung durch die GraalVM. Dass die Werte für real und user auch nahe beieinander liegen, lässt vermuten, dass nicht mehrere Threads bei der Ausführung genutzt werden. Auf der anderen Seite braucht ein als Native Binary ausgeführtes Hello World nicht länger als sein C-Pendant. Damit eröffnen Native Binaries endlich die Möglichkeit, Java in der Systementwicklung einzusetzen – ein Bereich, für den Java vorher nie in Betracht gezogen wurde. Hier lag bei Java die effektive Laufzeit bisher nie in einem sinnvollen Verhältnis zur Start-up-Zeit der JVM.

Fazit

Ziel war es, eine allgemeine Einführung in die GraalVM für Java-Entwickler zu geben sowie ihre Performanceeigenschaften und die Möglichkeit zu zeigen, Native Binaries zu generieren. Andere Features wie die Ausführung von Python oder Ruby sowie polyglotte Entwicklung hätten den Rahmen dieses Artikels gesprengt und sind ein eigenes Thema.

Selbst wenn die GraalVM lediglich wie eine normale JVM genutzt wird, verweist sie allein bei diesem Einsatzszenario andere JVMs auf die hinteren Ränge. Zusammen mit der Möglichkeit, sie auf Grundlage des Truffle Frameworks als Plattform für Projekte mit den bereits mehrfach genannten Sprachen zu nutzen, selbst domainspezifische Sprachen mit vergleichsweise geringem Aufwand implementieren und polyglotte Anwendungen entwickeln zu können, stellt die GraalVM einen Durchbruch dar, dessen Auswirkung auf die Softwareentwicklung im Moment noch nicht abschätzbar ist. Oracle dürfte damit die Grundlage für die nächsten zwanzig Jahre Softwareentwicklung mit Java gelegt haben, die wahrscheinlich mehr zur Zukunft von Java beiträgt als manches Sprachfeature, über das gerne leidenschaftlich diskutiert wird. Bleibt zum Schluss nur der Wunsch nach einem baldigen Final Release und einer kontinuierlichen Weiterentwicklung.

Verwandte Themen:

Geschrieben von
Oliver Fischer
Oliver Fischer
Oliver B. Fischer ist Senior Software Engineer bei der E-Post Development GmbH und engagiert sich in der JUG Berlin-Brandenburg.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Eine Einführung in GraalVM, Oracles neue Virtual Machine"

avatar
4000
  Subscribe  
Benachrichtige mich zu:
Stefan Reich
Gast

Prima Artikel. Alles korrekt bis auf eine Kleinigkeit: Der Graal-Compiler selbst wird zur Laufzeit nicht mehr übersetzt, sondern als AOT-Kompilat geladen.