Sent from Hauptstadt!

ein Blog für den geneigten Leser

Native JVM Anwendungen

Tags: , ,

Kategorie Software Engineering | 3 Kommentare »

Die vielleicht wichtigste Neuerung im Java / JVM Ökosystem der letzten 10 Jahre ist die Möglichkeit, eine JVM Anwendung nativ zu kompilieren, statt sie in Bytecode zur Verfügung zu stellen. Deshalb folgt heute ein Überblick, warum native JVM Anwendungen so entscheidendend sind und wie die Technik funktioniert.

Klassische Bytecode Ausführung in der JVM

Java ist ursprünglich mit dem Versprechen „Write once, run anywhere“ angetreten. Die Anwendung wird einmalig in plattformunabhängigen Bytecode übersetzt und kann anschließend auf jeder Plattform ausgeführt werden, für die es eine JVM (Java Virtual Machine) gibt.

Man kann sich die JVM wie einen virtuellen Prozessor vorstellen. Für diesen JVM Prozessor gibt es ca. 200 Instruktionen, wie beispielsweise die Instruktion „iadd„, um 2 Ganzzahlen zu addieren. Jede Instruktion wird durch ein Byte repräsentiert, etwa der Hexadizimalwert 60 für die Instruktion iadd. Der Bytecode einer Anwendung ist also die Gesamtheit aller Instruktionen. Der Java Compiler übersetzt die Java Quelldateien in den entsprechenden Bytecode.

Die JVM lädt den Bytecode einer Anwendung und führt diesen aus. Die Anwendung wäre im Betrieb aber sehr langsam, wenn tatsächlich ihr Bytecode direkt ausgeführt würde. Stattdessen übersetzt die JVM während der Ausführung den Bytecode in nativen plattformabhängigen Maschinencode. Deshalb besteht eine JVM aus

  • einem Interpreter für den JVM Bytecode und
  • idealerweise einem Compiler, um optimierten nativen Code zu erzeugen.

Hinzu kommt in jeder JVM noch der Garbage Collector, der sich um die Speicherfreigabe von ungenutzten Objekten kümmert. All diese Komponenten führen dazu, dass für den Betrieb der JVM signifikante Ressourcen in Form von Speicher und Rechenleistung notwendig sind.

Der erzeugte native Maschinencode wird während der Ausführung optimiert. Dies erfordert eine gewisse Menge an Ausführungen des Codes, um die typischen Nutzungsmuster zu erkennen. Als Faustregel gilt, dass es mindestens 20.000 Aufrufe eines Microservice bedarf, damit die JVM alle Muster erkannt und den Code zum Beispiel durch Inlining von Methodenaufrufen optimiert hat. Erst nach dieser Menge von Aufrufen läuft der Service mit optimaler Performance bei gleichbleibenden Nutzungsmuster. Leider beginnt dieses Spiel mit jedem Neustart der Anwendung oder Deployment einer neuen Version von vorne.

Die Konsequenz dieses Ansatzes ist, dass gerade der Start einer Java Anwendung sehr langsam und rechenintensiv ist, da der Bytecode zunächst nur interpretiert und schrittweise kompiliert und optimiert wird. Auch die ersten Requests sind oft extrem langsam (Faktor 10 und mehr), da noch Klassen nachgeladen werden. Um gerade die langsamen ersten Requests zu vermeiden, habe ich schon diverse Tricks probiert, indem ich etwa versuche einen Spring Boot Service bereits beim Start aufzuwärmen.

Write once, run only once

Das ursprüngliche Versprechen, dass die gleiche Java Anwendung ohne Neuübersetzung auf verschiedenen Plattformen ausgeführt werden kann, ist zumindest für reine Backend Services längst irrelevant. Ich habe es in den letzten 10 Jahren nicht erlebt, dass ein Service parallel auf Linux mit X86 64 Bit Architektur, ARM64 Prozessoren und auf Windows betrieben wird. Auch die parallele Nutzung unterschiedlicher Applikationsserver für ein und dieselbe JVM Anwendung habe ich in der Realität nie gesehen.

Deshalb ist es sinnvoll, die Kompilierung des Bytecodes einer Anwendung in plattformabhängigen nativen Maschinencode vor der Ausführung durchzuführen. Das dabei erzeugte Artefakt kann dann natürlich nur noch auf der Zielplattform, etwa Linux mit X86 64 Bit Architektur, ausgeführt werden. In der Regel benötigt man für die Erzeugung des Artefakts eine Maschine mit der gleichen Architektur wie die Zielplattform. Soweit mir bekannt, ist ein Cross-Compiling momentan nicht möglich. In der Praxis kann dies ein Problem sein, wenn Entwickler etwa mit MacOS arbeiten, die Anwendung am Ende aber auf Linux Servern läuft.

AOT und GraalVM: Erzeugung nativer JVM Anwendungen

Das Erstellen von nativen JVM Anwendungen ist eine technische Meisterleistung von zahlreichen Ingenieuren weltweit! Es funktioniert grob folgendermaßen:

  • der Java Compiler erzeugt wie bisher JVM Bytecode
  • Analyse, welcher eigene Code, Bytecode von Dependencies und vom JDK überhaupt benutzt wird („Static Analysis“ & „Reachability Analysis“)
  • Ahead-of-Time (AOT) Compiler übersetzt den tatsächlich notwendigen JVM Bytecode in Maschinencode für eine spezifische Plattform
  • Erstellung Heap Snapshot der initialisierten Objekte
  • Linking, Paketieren und Erzeugung des finalen Artefakts

Am Ende erhält man eine einzige ausführbare Datei, die alle notwendigen Komponenten umfasst:

  • Anwendungslogik
  • alle benötigten Abhängigkeiten
  • benötigte Teile der Java Laufzeitbibliothek (JRE)
  • SubstrateVM (Multi-Threading, Speicherverwaltung, Anbindung Garbage Collector)
  • Garbage Collector (etwa Serial GC oder G1 GC)

Und diese Datei ist überraschend klein. Ein REST Service auf Basis des Quarkus Frameworks wiegt weniger als 100MB und startet in unter 100ms.

Nachteile von nativen JVM Anwendungen

Abgesehen von der Tatsache, dass man eine native JVM Anwendung nur auf der Zielplattform ausführen kann, gibt es noch ein paar Dinge, die man bedenken sollte.

Während der Laufzeit einer nativen JVM Anwendung findet keine weitere Optimierung des Maschinencodes anhand von Nutzungsmustern statt. Deshalb kann es durchaus sein, dass eine native JVM Anwendung langsamer ist, als eine zur Laufzeit optimierte klassische JVM Anwendung. In der Realität beträgt der Unterschied weniger als 5% Performance Einbuße und ist sicher nur in Umgebungen mit kritischen Zeitanforderungen (etwa Arbitrage Trading) oder bei sehr vielen Millionen Nutzern relevant (man bräuchte entsprechend mehr Server).

Man kann in einer nativen JVM Anwendung keinen Code während der Laufzeit nachladen bzw. die Möglichkeiten sind extrem eingeschränkt (Stichwort: Reflection). In der Praxis habe ich nie erlebt, dass man wirklich während der Laufzeit den Datenbanktreiber austauschen will, aber natürlich nutzen viele Java Frameworks Reflection mehr oder weniger stark.

Das Erstellen einer nativen JVM Anwendung dauert signifikant länger, als die reine Bytecode Kompilierung. Bei einem typischen Quarkus Projekt dauert es bis zu 10 mal länger eine native Anwendung zu erstellen, als nur ein JAR mit dem Bytecode.

Weiterhin unterstützen nicht alle Java Frameworks die Erstellung nativer JVM Anwendungen gleichermaßen gut. Insbesondere bei SpringBoot hakt es an diversen Stellen. Hier sind modernere Frameworks wie Quarkus klar im Vorteil, da sie (noch) nicht Ballast aus 20 Jahren Entwicklung mitschleppen.

Fazit

Seit ca. 1,5 Jahren betreibe ich mehrere native JVM Anwendungen in produktivem Einsatz. Alle Anwendungen basieren auf dem JVM Framework Quarkus. Mit der Umwandlung eines bestehenden einfachen SpringBoot Service hingegen hatte ich vor knapp 2 Jahren wenig Erfolg und habe die Sache dann wieder verworfen.

Meine Quarkus Services starten innerhalb von maximal 200ms rasend schnell, während ein SpringBoot Service in der Realität mindestens 10 Sekunden benötigt. Und selbst dann sind die ersten Requests um den Faktor 10 langsamer.

Da die JVM wesentlich weniger Aufgaben in einer nativen Anwendung hat, benötigt sie auch signifikant weniger Speicher. Docker Containern mit SpringBoot Services habe ich eigentlich immer 1GB und mehr Speicher zugestanden. Kleine Quarkus Services laufen hingegen in Produktion mit gerade mal 256MB problemlos.

Der geneigte Leser hat es sicher gemerkt: Ich bin von nativen JVM Anwendungen begeistert :-)

Zählpixel

3 Kommentare to “Native JVM Anwendungen”

  1. Matin sagt:

    „Seit ca. 1,5 Jahren betreibe ich mehrere native JVM Anwendungen in produktivem Einsatz.“

    Im Rahmen eines beruflichen Projekts oder privat?

  2. Sebastian sagt:

    Im professionellen Umfeld, sonst würde ich es nicht erwähnen :-)

  3. […] Native Image während der Laufzeit verbindet. Da ich aber zunächst erläutern wollte, was ein JVM Native Image überhaupt ist, wurde der Post immer länger und ich habe den Inhalt lieber auf 2 Posts aufgeteilt. […]

Schreiben sie ein Kommentar