GraalVM Native Images: Fallstricke und Lösungen
Vier Jahre später: Was wir gelernt haben
Als wir 2020 unseren ersten Artikel zu GraalVM geschrieben haben, war Native Image für Spring-Boot-Anwendungen noch weitgehend unpraktikabel. Seitdem hat sich vieles verändert. GraalVM Community Edition ist seit 2023 in das OpenJDK-Projekt integriert, Spring Boot 3.3 liefert ausgereiften Native-Support, und wir bei encircle360 haben mittlerweile mehrere Services produktiv als Native Images im Einsatz.
Aber Native Image ist kein Selbstläufer. Die grundlegende Herausforderung -- die Closed-World-Annahme, bei der zur Build-Zeit alles bekannt sein muss -- hat sich nicht aufgelöst. Sie äußert sich nur in anderen, subtileren Problemen als noch vor vier Jahren. Dieser Artikel ist eine Sammlung der Fallstricke, auf die wir in realen Projekten gestoßen sind, und der Lösungen, die wir dafür gefunden haben.
Reflection: Der Klassiker, der nicht verschwindet
Spring Boot 3.x mit seiner AOT-Engine nimmt einem den Großteil der Reflection-Konfiguration ab. Das haben wir in unserem Spring-Boot-3-Artikel beschrieben. Was die Engine aber nicht automatisch erkennen kann, sind Reflection-Zugriffe in eigenem Code und in Drittanbieter-Bibliotheken, die außerhalb des Spring-Kontexts laufen.
JPA-Entities sind ein typisches Beispiel. Hibernate braucht Zugriff auf Konstruktoren und Felder, und obwohl Spring Data die meisten Fälle abdeckt, gibt es Randfälle -- etwa bei @Embeddable-Klassen oder Entities, die über native Queries in DTOs projiziert werden. Wenn zur Laufzeit plötzlich ein ClassNotFoundException oder ein kryptischer InstantiationException-Fehler auftritt, fehlt fast immer eine Reflection-Registrierung.
Die sauberste Lösung in Spring Boot 3.3 ist @RegisterReflection:
@RegisterReflection(classes = {InvoiceProjection.class, AddressEmbeddable.class},
memberCategories = {MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS,
MemberCategory.DECLARED_FIELDS})
@Configuration
public class NativeImageConfig {
}
Für komplexere Fälle, etwa wenn eine Bibliothek intern Klassen per Reflection instanziiert, ist ein RuntimeHintsRegistrar der richtige Weg:
public class JacksonNativeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection()
.registerType(CustomDeserializer.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)
.registerType(ApiResponse.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS,
MemberCategory.DECLARED_FIELDS);
}
}
Registriert wird der Hint über @ImportRuntimeHints(JacksonNativeHints.class) an einer beliebigen @Configuration-Klasse. Unsere Empfehlung: eine dedizierte Konfigurationsklasse für alle Native-Hints anlegen. Das hält die Übersicht.
Ressourcen: Dateien, die im Binary fehlen
Ein Fehler, der uns mehr als einmal in die Irre geführt hat: Class.getResourceAsStream() gibt null zurück, obwohl die Datei im Classpath liegt. Der Grund ist einfach -- der Native-Image-Compiler packt nur Ressourcen ein, die er explizit kennt. Alles andere ist im Binary schlicht nicht vorhanden.
Spring Boot registriert application.properties, application.yml und die gängigen Template-Pfade automatisch. Aber eigene Dateien -- SQL-Migrationsskripte, JSON-Schemas, Zertifikate -- müssen manuell konfiguriert werden. Das geht entweder über den RuntimeHintsRegistrar:
hints.resources().registerPattern("db/migration/*.sql");
hints.resources().registerPattern("schemas/*.json");
Oder über eine resource-config.json im Verzeichnis META-INF/native-image/:
{
"resources": {
"includes": [
{"pattern": "db/migration/.*\\.sql$"},
{"pattern": "schemas/.*\\.json$"}
]
}
}
Beide Wege funktionieren, aber die RuntimeHints-Variante ist in Spring-Boot-Projekten vorzuziehen, weil sie im selben Ökosystem lebt und testbar ist. Die JSON-Konfiguration ist nützlich für Bibliotheken, die keinen Spring-Kontext haben.
JNI und native Bibliotheken
Sobald eine Abhängigkeit über JNI auf native Bibliotheken zugreift, wird es ungemütlich. Das betrifft zum Beispiel bestimmte Kryptographie-Provider, SQLite-Bindings oder Bildverarbeitungs-Libraries. Der Native-Image-Compiler muss wissen, welche JNI-Methoden aufgerufen werden, und die nativen Shared Libraries müssen für die Zielplattform des Binaries vorliegen.
In der Praxis bedeutet das: Wenn das Native Image auf Linux in einem Container laufen soll, müssen die .so-Dateien für Linux/amd64 vorliegen, auch wenn der Build auf macOS stattfindet. Wir lösen das, indem wir Native-Image-Builds konsequent in CI laufen lassen -- in einem Container, der der Zielumgebung entspricht. Lokale Native Builds auf Entwicklerrechnern nutzen wir nur zum Testen, nicht für Deployments. Die Jib-basierte Build-Pipeline hilft hier, weil das reguläre JVM-Image weiterhin als Fallback dient.
Build-Zeit vs. Laufzeit-Initialisierung
Ein Fallstrick, der sicherheitsrelevant werden kann: Klassen, die zur Build-Zeit initialisiert werden, backen ihren Zustand in das Binary ein. Wenn eine Klasse in einem statischen Initializer einen Zufallswert generiert, eine Umgebungsvariable liest oder einen Timestamp speichert, ist dieser Wert im Binary eingefroren -- jede Instanz des Binaries startet mit demselben Wert.
Das klingt abstrakt, hat aber konkrete Auswirkungen. Ein statisch initialisierter SecureRandom liefert bei jedem Start die gleiche Sequenz. Ein zur Build-Zeit gelesenes Datenbank-Passwort steckt im Binary. Ein eingebetteter Timestamp zeigt immer die Build-Zeit an.
GraalVM versucht, möglichst viele Klassen zur Build-Zeit zu initialisieren, weil das die Startzeit weiter verkürzt. Man kann das pro Klasse steuern:
--initialize-at-run-time=com.example.security.TokenGenerator
In Spring Boot geht das über die native-image.properties oder als Argument im Gradle/Maven-Plugin. Unsere Faustregel: Alles, was Secrets, Zufallswerte oder externe Konfiguration liest, muss zur Laufzeit initialisiert werden.
Serialisierung: Der vergessene Konfigurationsbereich
Neben Reflection und Ressourcen gibt es einen dritten Konfigurationsbereich, der gerne übersehen wird: Serialisierung. Klassen, die über java.io.Serializable serialisiert werden, brauchen eine eigene Konfiguration. Das betrifft insbesondere Session-Objekte, Cache-Einträge und alles, was über RMI oder bestimmte Messaging-Systeme transportiert wird.
In den meisten modernen Projekten ist Java-Serialisierung selten geworden -- JSON über Jackson oder Protocol Buffers haben sie weitgehend abgelöst. Aber wenn man beispielsweise Spring Session mit Redis nutzt und die Default-Serialisierung nicht auf JSON umgestellt hat, schlägt das Native Image zur Laufzeit fehl. Die Fehlermeldung ist dabei selten hilfreich.
Garbage Collection und Speicherverhalten
Das Speicherverhalten eines Native Image unterscheidet sich fundamental von einer HotSpot-JVM. Native Images verwenden standardmäßig den Serial GC, der für kleine Heaps optimiert ist. Für Services mit wenig Speicherbedarf -- und das sind viele Microservices -- ist das ideal. Der niedrige Speicher-Overhead ist schließlich einer der Hauptgründe, warum man Native Images baut.
Aber für Services, die größere Datenmengen im Speicher halten, kann der Serial GC zum Engpass werden. Seit GraalVM 22.3 steht der G1 GC als Alternative zur Verfügung, muss aber explizit aktiviert werden:
--gc=G1
Wir haben gute Erfahrungen mit dem Serial GC für Services gemacht, deren Heap unter 256 MB bleibt. Darüber lohnt es sich, den G1 GC zu testen und die Pausenzeiten zu vergleichen.
Debugging ohne JVM-Tooling
Ein Punkt, der in der Praxis oft unterschätzt wird: Das gewohnte JVM-Debugging-Toolkit funktioniert mit Native Images nicht. Kein jstack für Thread-Dumps, kein jmap für Heap-Dumps, kein JMX für Remote-Monitoring, kein VisualVM. Das Binary ist eben keine JVM mehr.
Für Heap-Analyse bei OutOfMemoryErrors gibt es einen Build-Flag:
-H:+DumpHeapAndExit
Signal-basierte Thread-Dumps lassen sich aktivieren, und GraalVM unterstützt seit einigen Versionen grundlegendes Monitoring über JFR (Java Flight Recorder) auch in Native Images. Aber die Tooling-Lücke ist real und spürbar, besonders wenn man in Produktion einem sporadischen Problem auf der Spur ist.
Unsere Konsequenz: Wir betreiben jeden Native-Image-Service parallel als JVM-Variante in einer Staging-Umgebung. Wenn ein Problem auftritt, das wir im Native Image nicht diagnostizieren können, reproduzieren wir es dort. Das ist ein Kompromiss, aber ein pragmatischer.
Drittanbieter-Kompatibilität prüfen
Nicht jede Java-Bibliothek funktioniert in einem Native Image. Bibliotheken, die extensiv Reflection, Bytecode-Generierung zur Laufzeit oder sun.misc.Unsafe nutzen, sind potenzielle Problemkandidaten. Die GraalVM-Community pflegt eine Kompatibilitätsliste, und viele populäre Bibliotheken liefern mittlerweile eigene GraalVM-Metadaten mit -- das GraalVM Reachability Metadata Repository auf GitHub ist hier die zentrale Anlaufstelle.
Unsere Empfehlung: Bevor man eine neue Abhängigkeit in ein Projekt aufnimmt, das als Native Image gebaut wird, kurz prüfen, ob GraalVM-Metadaten existieren. Fünf Minuten Recherche sparen Stunden Debugging.
Fazit: Lohnt es sich?
Nach vier Jahren und mehreren produktiven Services als Native Images ist unsere Antwort: Ja, aber mit offenen Augen. Die Startzeiten von unter 100 Millisekunden und der niedrige Speicherverbrauch sind in Kubernetes-Umgebungen mit Autoscaling ein echter Vorteil. Die Build-Zeiten sind lang, das Debugging ist eingeschränkt, und jede neue Abhängigkeit muss auf Kompatibilität geprüft werden.
Spring Boot 3.3 hat die Hürde massiv gesenkt. Was 2020 eine Forschungsarbeit war, ist 2024 ein realistischer Produktionsbetrieb. Die Closed-World-Annahme bleibt das zentrale Constraint, aber man lernt, damit umzugehen. Für neue Microservices evaluieren wir Native Image inzwischen standardmäßig. Für bestehende, komplexe Services mit vielen Abhängigkeiten bleibt die JVM oft die pragmatischere Wahl.
Der entscheidende Punkt ist, die Fallstricke zu kennen, bevor man in Produktion geht -- nicht danach.
Geschrieben von
Patrick HütterGrü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.
Das könnte dich auch interessieren
Vom Manifest zur Produktion: Wie ADL, A2A und das Inference Gateway die Agenten-Infrastruktur revolutionieren
05.07.2026 · 12 Min. Lesezeit
Agent-Orchestrierung mit Java: Wie Sie LLM-Agenten produktionsreif in die JVM bringen
04.07.2026 · 6 Min. Lesezeit
Spring AI: Wie Java-Entwickler endlich KI-Features ohne Umwege integrieren
25.03.2026 · 5 Min. Lesezeit