Die Java Virtual Machine (JVM) gibt es jetzt seit über 24 Jahren. Sie wurde geschaffen, um eine objektorientierte und robuste Programmiersprache plattformunabhängig ablaufen lassen zu können. Mit den Jahrzehnten wuchs ihre Verbreitung. Unterschiedliche Programmiersprachen entstanden und das Java Software Development Kit (SDK) bekam immer neue Funktionen. Das alles hat die JVM aber auch groß und träge gemacht.

Zugute halten muss man ihr, dass in der Vergangenheit RAM-Bedarf oder längere Startzeit nicht übermäßig problematisch waren. Seit Softwareentwickler aber mit Containern und Containerorchestrierungen oder FaaS-Systemen (Function-as-a-Service) arbeiten, sind solche Metriken relevanter. Sie bestimmen mit, ob Applikationen schnell skalieren oder man sie als Function (on request) einsetzen kann. Ebenso ist es ein gängiges Pattern, nichtfunktionale Anforderungen (CORS Header, Jobs, Rate Limiting etc.) in eigene Container auszulagern. Dadurch müssen die Applikationscontainer nicht mehr so komplex sein und die verwendeten Frameworks nicht mehr alles auf einmal können.

Obwohl Go und Rust als moderne kompilierte Programmiersprachen im Kubernetes-Universum eine große Rolle spielen und sich teilweise anschicken, das Applikations-Layer zu erobern, sind jedoch meist die JVM und ihre Frameworks aus verschiedenen Gründen (Personal, Policies etc.) gesetzt. Was sollte man also tun? Zum Glück gibt es eine ganze Reihe an Entwicklungen, die Entwicklern Hoffnung machen.

GraalVM: Ein Hoffnungsträger

Ein Projekt, das mehrere der neuen Herausforderungen adressiert, ist die GraalVM. Gerade in einer ersten stabilen Version erschienen, soll sie die Performance der bestehenden JVM verbessern und Tools bereitstellen, um Java-Applikationen als native Binaries laufen zu lassen. Dabei soll man auf den Overhead verzichten können, eine vollständige VM zur Laufzeit zu benötigen. Doch was ist die GraalVM und wie funktioniert sie?

Das Graal-Projekt

Die Geschichte des Graal-Projekts reicht zurück bis in das Jahr 2013, als die Oracle Labs angefangen haben, an einer VM für Java zu forschen, die selbst in Java geschrieben ist. Damals noch unter dem Namen MaxineVM bekannt, haben Entwickler das Projekt unter dem Namen Graal fortgesetzt. Neben der Performanceverbesserung für Sprachen auf Basis der JVM hat man sich als Ziel gesetzt, die C++-Anteile der jetzigen JVM-Plattform zu ersetzen und dadurch eine Möglichkeit zu schaffen, die Vorteile der JVM auch anderen Sprachen zur Verfügung zu stellen.

Eine der größten C++-Anteile in der bestehenden JVM-Plattform ist der Hotspot-JIT-Compiler. Deshalb haben die Java-Macher in Java 9 mit dem JEP-243 ein Java-Level-JVM-Compiler-Interface eingebaut. Es bietet die Möglichkeit, den JIT-Compiler durch eine alternative Implementierung zu ersetzen. Integraler Bestandteil des Graal-Projekts ist der gleichnamige Graal-JIT-Compiler, der dieses Interface implementiert und für bestimmte Anwendungsfälle eine höhere Performance verspricht als der C1/C2-Compiler. Da er ebenfalls in Java geschrieben ist, bringt er weitere Vorteile mit sich wie guten IDE-Support und Exception Handling.

Die GraalVM ist somit eine Hotspot-JVM, die als JIT-Compiler den Graal-Compiler verwendet. Man kann sie damit als Ersatz für eine bestehende JVM ohne Anpassung des Codes nutzen. Die Performancesteigerung, die besonders CPU-lastigen Applikationen zu gute kommt, hat schon ein paar Teams dazu veranlasst, mit der GraalVM zu experimentieren, oder wie das Twitter-VM-Team sogar produktiv zu nutzen).

Native Image

Damit der Nutzer ein natives Binary aus seiner Java-Applikation erzeugen kann, gehört die SubstrateVM zum Graal-Projekt. Dafür stellt sie das Kommandozeilen-Tool native-image zur Verfügung. Dabei handelt es sich um eine leichtgewichtige, in Java geschriebene VM, die Aufgaben der JVM im finalen Binary übernimmt, wie Thread Management oder auch die Garbage Collection.

Eine Herausforderung ist, dass sie kein dynamisches Nachladen von Bytecode wie die Original-JVM unterstützt (closed world assumption). Dadurch müssen alle Klassenabhängigkeiten der SubstrateVM beim Erstellen des Binary bekannt sein und der Code, der auf das dynamische Nachladen von Klassen angewiesen ist (z. B. per Reflection), speziell behandelt werden. Während des Erstellens des Binarys führt die SubstrateVM neben der Analyse der Klassenabhängigkeiten auch alle statischen Initialisierungsblöcke der Klassen aus. Den entstehenden Heap packt sie dann mit in das Binary und sorgt so für eine kürzere Startzeit.

Doch die SubstrateVM hat auch Nachteile, da sie nicht alle Schnittstellen und Sprach-Features unterstützt, die man von der JVM kennt. Zum Beispiel unterstützt sie Method Handles nicht, die es einem Nutzer erlauben, dynamisch Methoden zu definieren und sie als Argumente ähnlich zu Function Pointers aus anderen Sprachen an andere Methoden zu übergeben. Im Hintergrund wird dafür die in Java 7 eingeführte invokedynamic-Bytecode-Anweisung genutzt, um Lambdas in der neuen Stream-API zu ermöglichen. Die SubstrateVM setzt das Feature nur so weit um, dass sie Lambdas zwar unterstützt, jedoch nicht vollumfänglich. Damit ist der Einsatz von Method Handles im Zusammenspiel mit der SubstrateVM nicht möglich.

Das Erstellen des Binarys beeinflussen

Um die meisten der Einschränkungen umgehen zu können, bietet die SubstrateVM Entwicklern die Möglichkeit, das Erstellen des Binary zu beeinflussen: entweder durch das Bereitstellen von Konfigurationsdateien oder durch die SubstrateVM Java API. Sie ermöglicht es, Meta-Informationen für das Erstellen verfügbar zu machen

Um dem native-image-Befehl beispielsweise mitzuteilen, welche Klassen per Reflection geladen werden und damit im Binary mit aufgenommen werden sollen, kann man eine in JSON definierte Konfigurationsdatei zur Verfügung stellen. Folgendes Beispiel ist ein Auszug aus einer möglichen Konfiguration:

[ {
 "name" : "com.fasterxml.jackson.datatype.jdk8.Jdk8Module",
 "allDeclaredConstructors" : true
}, {

}]

Das Listing beschreibt, dass die Klasse Jdk8Module aus den Jackson-Projekten zusammen mit allen definierten Konstruktoren mit in das Binary kompiliert werden soll. Nur so ist es möglich, die Klasse später per Reflection zu nutzen. Mit der Konfigurationsdatei haben Entwickler eine feingranulare Kontrolle darüber, welche Teile einer Klasse sie mitkompilieren wollen. Eine weitere Konfigurationsdatei kann dazu dienen, alle Interfaces zu definieren, für die man später mögliche Proxys erstellen möchte. Durch die fehlende Möglichkeit, zur Laufzeit Bytecode zu erzeugen, müssen die Informationen bereits zur Kompilierzeit bekannt sein. Eine Datei, die Interfaces definiert, sieht zum Beispiel so aus:

[
 ["java.sql.PreparedStatement"]
]

Um komplizierte Konfigurationen vorzunehmen, bietet die SubstrateVM auch eine eigene Java-API an. Zum Beispiel bietet sie die Möglichkeit, Methodenimplementierungen durch eine alternative Implementierung zu ersetzen. Ein Beispiel dafür wäre folgendes:

@TargetClass(io.netty.util.internal.logging.InternalLoggerFactory.class)
final class Target_io_netty_util_internal_logging_InternalLoggerFactory {
   @Substitute
   private static InternalLoggerFactory newDefaultFactory(String name) {
       return JdkLoggerFactory.INSTANCE;
   }
}

@TargetClass und @Substitute sind Annotationen, die zur SubstrateVM-API gehören. Das sind Metainformationen, die die SubstrateVM beim Bauen des Binary auswerten kann. Im konkreten Beispiel ersetzt die definierte Methode, die mit @Substitute markiert ist, die Methode in der TargetClass, die eine identische Signatur und Namen besitzt. Die Methode newDefaultFactory(String name) in der Klasse InternalLoggerFactory wird durch die definierte Methode ersetzt. Dadurch hat man die Möglichkeit den Code, der beim Kompilieren zu Problemen führen kann, durch einen alternativen Code zu ersetzen. Das funktioniert ebenfalls wie hier beschrieben mit Drittanbieterbibliotheken wie Netty.

Neben dem Beispiel bietet die API noch weitere Möglichkeiten. Man kann ebenso Offsets für den Zugriff auf nativen Speicher über die Unsafe-API neu berechnen lassen, da sich der Adressraum zwischen der SubstrateVM und einer normalen JVM unterscheidet.

Oder vielleicht doch nicht Java? Oder nur ein bisschen?

Dank der GraalVM ist es nun möglich, eine Reihe weiterer Sprachen auf der JVM zu verwenden, die bisher nicht direkt dort verfügbar waren, zum Beispiel JavaScript, Ruby oder R, aber auch C oder C++. Und nicht nur das: Die Sprachen können gemischt und in unterschiedlichen Umgebungen ausgeführt werden. Die GraalVM hebt die polyglotte Programmierung damit auf ein neues Level.

Das erlaubt beispielsweise das Verwenden von Funktionen, die in einer anderen Sprache bereits implementiert sind (z. B. den Einsatz von npm-Modulen in Java-Code) oder die Wahl der besten Sprache für das jeweilige Problem, ohne direkt die gesamte Applikation in einer Sprache implementieren beziehungsweise die Ausführungsumgebung ändern zu müssen (z. B. die Verwendung von R für die Darstellung von Graphen innerhalb einer Node-Anwendung). All das ist denkbar, weil das GraalVM-Team Sprachen basierend auf dem Sprach-Framework Truffle neu implementieren konnte. Truffle erlaubt es auf effiziente Weise, neue Sprachen für das Ausführen auf der GraalVM fit zu machen – dazu muss man lediglich mit Truffle einen Parse Tree (AST) bereitstellen. Die Übersetzung in Maschinencode mit zahlreichen Optimierungen übernimmt der Graal-Compiler. Zudem bietet die GraalVM ein Protokoll für die Interoperabilität zwischen Sprachen, das den effizienten Austausch von Werten zwischen Sprachen ermöglicht. Die Verwendung von JavaScript-Code innerhalb von Java-Code sieht mit GraalVM wie folgt aus:

try (Context context = Context.create()) {
  String name = "world";
  Value result = context.eval("js", "'Hello " + name + "'");
  System.out.println(result.asString());
}

Das Listing übergibt den Wert einer Java-Variable an den Kontext für die Ausführung von JavaScript und verwendet das Resultat wiederum in Java. Die Interoperabilität geht jedoch deutlich über das einfache Beispiel hinaus. Es wäre genauso möglich, innerhalb von JavaScript-Code Java-Objekte zu instanziieren, oder in Java-Code Methoden auf einem in JavaScript definierten Objekt oder umgekehrt aufzurufen.

Für das Ausführen von Code in unterschiedlichen Sprachen ohne die oben gezeigte Einbettung in Java liefert die GraalVM eine Reihe von Binaries wie js, node oder ruby, die als Ersatz für die existierenden Binaries dienen, unter der Haube die GraalVM verwenden und dadurch ebenfalls Polyglot-Support bieten. Es ist sogar möglich, die GraalVM innerhalb einer Oracle- oder MySQL-Datenbank zu verwenden und so JavaScript-Code direkt in der Datenbank auszuführen.

Als weiteren Leckerbissen liefert die GraalVM einen Debugger, der für jede auf Truffle implementierte Sprache Einsicht in das laufende Programm bietet. Entwickler können den Debugger über die Option –inspect aktivieren, danach ist er über die Chrome Dev Tools verfügbar. Das Ausführen eines JavaScript-Programms kann man folglich über js –inspect test.js debuggen.

Fazit

Die GraalVM ist noch immer ein sehr junges Projekt. Es gibt noch regelmäßig größere Änderungen, die dazu führen können, dass Entwickler den eigenen Code wieder anpassen müssen. Jedoch sieht man das große Potenzial, das im Erstellen eines nativen Binarys steckt, und dass viele Frameworks auf den Zug aufspringen.

Als Entwickler sollte man sich aber zumindest im Moment vor Augen halten, dass die Verbesserungen der Startzeit und des Speicherverbrauchs nicht ohne Nachteile daherkommen. Peak-Performance in langläufigen Prozessen ist im Moment schlechter, als wenn die Applikation normal in einer JVM läuft. Man sollte es daher immer vom Anwendungsfall abhängig machen, ob der Einsatz der GraalVM mit dem native-image hilfreich ist. Es ist aber zumindest ein weiterer wichtiger Baustein in dem Java-Ökosystem und wird in den nächsten Jahren mehr und mehr an Relevanz gewinnen.