GraalVM: Native Images für Java-Anwendungen
Zurück zum Blog

GraalVM: Native Images für Java-Anwendungen

6 Min. Lesezeit
Lesen auf English

Java und das Startup-Problem

Java hat viele Stärken: ein ausgereiftes Ökosystem, hervorragende Performance nach der Aufwärmphase, Stabilität in Produktionsumgebungen. In der Welt von Containern und Serverless-Funktionen offenbart sich aber eine Schwäche, die lange kaum ins Gewicht fiel: die Startzeit.

Eine typische Spring-Boot-Anwendung braucht 5 bis 15 Sekunden, bis sie bereit ist, Requests zu verarbeiten. In einem klassischen Deployment auf einem Application Server spielt das keine Rolle -- der Server läuft Monate am Stück. In einer Kubernetes-Umgebung mit Autoscaling, wo Pods bei Lastspitzen hochgefahren und danach wieder abgeräumt werden, sind 15 Sekunden Startup eine Ewigkeit. Bei Serverless-Funktionen, die pro Aufruf gestartet werden, ist es schlicht inakzeptabel.

Dazu kommt der Speicherverbrauch. Eine JVM reserviert beim Start einen erheblichen Heap, unabhängig davon, wie viel die Anwendung tatsächlich benötigt. In Zeiten, in denen Cloud-Kosten direkt an Speicherverbrauch gekoppelt sind, summiert sich das schnell.

GraalVM verspricht, genau diese Probleme zu lösen.

Was GraalVM Native Image macht

GraalVM ist eine von Oracle entwickelte, polyglotte Laufzeitumgebung, die seit 2018 verfügbar ist und mittlerweile in Version 20.x vorliegt. Das spannendste Feature für Java-Entwickler ist Native Image: ein Werkzeug, das Java-Bytecode in ein eigenständiges, nativ ausführbares Binary kompiliert.

Der Prozess unterscheidet sich grundlegend von der normalen Java-Ausführung. Statt den Bytecode zur Laufzeit durch den JIT-Compiler (Just-in-Time) in Maschinencode zu übersetzen, übernimmt Native Image diese Arbeit vollständig zur Build-Zeit. Man spricht von AOT-Kompilierung -- Ahead-of-Time. Das resultierende Binary enthält den kompilierten Anwendungscode, alle benötigten Bibliotheken und eine abgespeckte Laufzeitumgebung namens SubstrateVM.

Das Ergebnis ist beeindruckend. Eine einfache Java-Anwendung startet als Native Image in wenigen Millisekunden statt in Sekunden. Der Speicherverbrauch sinkt drastisch, weil keine vollständige JVM hochgefahren werden muss. Das Binary ist eine einzelne ausführbare Datei ohne externe Abhängigkeiten -- kein installiertes JDK nötig.

Ein einfaches Beispiel

Um ein Gefühl dafür zu bekommen, wie der Prozess funktioniert, reicht eine minimale Java-Anwendung. Zunächst braucht man die GraalVM-Distribution und das native-image-Tool, das über den GraalVM Updater nachinstalliert wird:

gu install native-image

Eine einfache Anwendung:

public class HelloNative {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        System.out.println("Hello from Native Image!");
        System.out.println("Startup: " + (System.currentTimeMillis() - start) + "ms");
    }
}

Die Kompilierung zum Native Image:

javac HelloNative.java
native-image HelloNative

Der Build-Prozess dauert -- je nach Rechner -- ein bis zwei Minuten. Was dabei passiert, ist die sogenannte Closed-World-Analyse: native-image verfolgt vom Eintrittspunkt aus alle erreichbaren Codepfade und kompiliert nur das, was tatsächlich verwendet wird. Alles andere fliegt raus. Das erklärt die kleine Binary-Größe, aber auch die Einschränkungen.

Das resultierende Binary startet in unter 5 Millisekunden und verbraucht einen Bruchteil des Speichers einer JVM-Ausführung. Für dieses triviale Beispiel ist der Unterschied beeindruckend, aber nicht praxisrelevant. Spannend wird es bei echten Anwendungen.

Die Closed-World-Annahme und das Reflection-Problem

Die Closed-World-Analyse ist gleichzeitig die größte Stärke und die größte Einschränkung von Native Image. Der Compiler muss zur Build-Zeit wissen, welcher Code erreichbar ist. Alles, was erst zur Laufzeit entschieden wird, ist ein Problem.

In Java gibt es dafür einen prominenten Mechanismus: Reflection. Die Fähigkeit, zur Laufzeit Klassen zu laden, Methoden zu finden und aufzurufen, ist tief im Java-Ökosystem verankert. Serialisierungs-Frameworks nutzen Reflection. Dependency-Injection-Container nutzen Reflection. Und Spring Boot nutzt Reflection in einem Ausmaß, das es zum schwierigsten Kandidaten für Native Image macht.

Wenn native-image während der Closed-World-Analyse auf einen Reflection-Aufruf stößt, kann es nicht automatisch bestimmen, welche Klassen betroffen sind. Der Aufruf Class.forName("com.example.MyService") ist ein String -- zur Build-Zeit steht der Wert fest, aber der Compiler kann das nicht in jedem Fall erkennen.

Die Lösung: eine manuelle Konfiguration. Über JSON-Dateien teilt man dem Native-Image-Compiler mit, welche Klassen per Reflection erreichbar sein müssen, welche dynamischen Proxies erzeugt werden und welche Ressourcen eingebettet werden sollen. Für eine kleine Anwendung ist das machbar. Für ein Spring-Boot-Projekt mit Dutzenden von Bibliotheken, die alle Reflection verwenden, ist es eine monumentale Aufgabe.

GraalVM liefert mit dem Tracing Agent ein Hilfsmittel: Man startet die Anwendung auf einer normalen JVM mit angehängtem Agent, der alle Reflection-Zugriffe protokolliert und die Konfigurationsdateien automatisch generiert. Das funktioniert in der Praxis ganz gut, aber es erfasst nur die Codepfade, die während des Testlaufs tatsächlich ausgeführt werden. Selten genutzte Pfade fehlen dann in der Konfiguration.

Der Stand Mitte 2020: Frameworks im Vergleich

Nicht alle Frameworks kämpfen gleichermaßen mit Native Image. Quarkus und Micronaut wurden von Grund auf mit GraalVM-Kompatibilität im Sinn entwickelt. Sie verlagern möglichst viel Arbeit von der Laufzeit in die Build-Zeit und vermeiden Reflection, wo immer es geht.

Ein Quarkus-Projekt lässt sich mit einem einzigen Maven-Profil als Native Image bauen:

mvn package -Pnative

Die Startzeit einer typischen Quarkus-REST-Anwendung sinkt von etwa 1,5 Sekunden auf der JVM auf unter 20 Millisekunden als Native Image. Der Speicherverbrauch fällt von über 100 MB auf circa 20 MB. Das sind beeindruckende Werte, die gerade für Serverless-Szenarien und CLI-Tools einen echten Unterschied machen.

Micronaut verfolgt einen ähnlichen Ansatz und erreicht vergleichbare Ergebnisse. Beide Frameworks haben GraalVM-Support als strategisches Ziel und investieren entsprechend.

Bei Spring Boot sieht die Lage anders aus. Das Framework basiert architekturbedingt stark auf Reflection, dynamischen Proxies und Classpath-Scanning. Ein bestehendes Spring-Boot-Projekt als Native Image zu kompilieren ist aktuell ein Kampf mit langen Konfigurationsdateien und schwer zu diagnostizierenden Laufzeitfehlern. Das Spring-Team arbeitet an einem experimentellen Projekt namens Spring Native, das die nötige Konfiguration automatisieren soll, aber es ist noch in einem sehr frühen Stadium. Für produktive Spring-Boot-Anwendungen ist Native Image heute keine realistische Option.

Wo es sich schon lohnt

Trotz der Einschränkungen gibt es Szenarien, in denen Native Image bereits produktiv einsetzbar ist:

  • CLI-Tools und Utilities: Kurz laufende Programme profitieren am stärksten. Kein JVM-Startup, sofortige Ausführung, kleines Binary.
  • Serverless-Funktionen: Wo die Startzeit direkt die Antwortzeit beeinflusst, sind Millisekunden statt Sekunden ein Wettbewerbsvorteil.
  • Sidecar-Container: Kleine Hilfsprozesse in Kubernetes, die wenig Speicher verbrauchen sollen.
  • Neue Projekte mit Quarkus oder Micronaut: Wer auf der grünen Wiese startet und Native Image als Ziel hat, kann von Anfang an die richtigen Architekturentscheidungen treffen.

Für bestehende Spring-Boot-Anwendungen lautet unsere ehrliche Einschätzung: abwarten. Das Spring-Team hat das Thema auf dem Radar, und die Ergebnisse aus dem Spring-Native-Projekt sind vielversprechend. Aber der Weg zu einer nahtlosen Integration wird noch dauern.

Ehrliche Einschätzung

GraalVM Native Image löst ein reales Problem. Die Startzeiten und der Speicherverbrauch klassischer Java-Anwendungen sind in einer Container-Welt ein Nachteil, der sich nicht schönreden lässt. Native Image beseitigt diesen Nachteil auf beeindruckende Weise.

Aber es ist kein Allheilmittel. Die Build-Zeiten sind lang -- mehrere Minuten für eine einfache Anwendung, deutlich mehr für komplexe Projekte. Der Build verbraucht viel Speicher. Debugging wird schwieriger, weil der vertraute Werkzeugkasten der JVM fehlt. Und die Closed-World-Annahme erzwingt eine andere Denkweise bei der Anwendungsentwicklung.

Für Teams, die heute in Spring Boot investiert sind, ist es sinnvoll, die Entwicklung bei Spring Native zu verfolgen und gegebenenfalls neue, isolierte Services mit Quarkus oder Micronaut zu bauen, um Erfahrung zu sammeln. Für neue Projekte ohne Spring-Abhängigkeit lohnt ein ernsthafter Blick auf Quarkus mit Native Image.

Die Richtung stimmt. Java wird in der Cloud-Native-Welt wettbewerbsfähig bleiben -- nicht trotz, sondern dank GraalVM.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.