TREND-02b · VERTIEFUNG ZU PUNKT S-04

MZP, Metaspace und Native Image

Was GraalVM Native Image fuer den mzone-publisher tatsaechlich brachte, was es dich kostet — und warum die Antwort auf deinen Absturz vom 31. Juli mit hoher Wahrscheinlichkeit nicht Native Image heisst.

Erstellt
2026-08-06
Anlass
OutOfMemoryError: Metaspace, mzone-publisher, 2026-07-31 04:18 UTC
Vorgaenger
TREND-02, Empfehlung S-04
Empfehlung
Native Image NICHT jetzt. Vier billigere Schritte kommen zuerst.
Korrektur
Meine Freemarker-Hypothese vom Juli war vermutlich falsch — siehe Abschnitt 2
VORWEG · KORREKTUR

Im Juli habe ich dir als wahrscheinliche Ursache ein Classloader-Leak durch generierte Freemarker-Template-Klassen genannt. Das war ein schlecht gepruefter Schuss ins Blaue.

FreeMarker generiert keinen Bytecode. Es parst Templates in einen Objektbaum (AST) und interpretiert diesen. Ein Template, das tausendfach neu geparst wird, belegt Heap, nicht Metaspace. Meine damalige Diagnose zeigte also in die falsche Richtung — und die Empfehlung im TREND-02-Report baute darauf auf.

Das aendert die Analyse grundlegend: Wenn wir nicht wissen, was den Metaspace fuellt, ist es unseriös, eine 20-Stunden-Massnahme dagegen zu empfehlen.

1 · Was Metaspace ueberhaupt ist

Metaspace ist der Bereich ausserhalb des Heaps, in dem die JVM Klassen-Metadaten ablegt: Methodensignaturen, Konstantenpools, Bytecode, Feld-Layouts. Er waechst mit der Anzahl geladener Klassen, nicht mit der Menge deiner Daten.

Ein OutOfMemoryError: Metaspace heisst deshalb genau eine Sache: Deine Anwendung hat mehr verschiedene Klassen geladen, als in das gesetzte Limit passen. Es gibt nur zwei Erklaerungen dafuer, und sie fuehren zu voellig verschiedenen Massnahmen.

Alles Weitere in diesem Report haengt daran, welcher der beiden Faelle vorliegt. Das ist in unter einer Stunde messbar — und bisher hat es niemand gemessen.

2 · Vier Verdaechtige, nach Wahrscheinlichkeit

  1. V-01 Das Limit war schlicht zu tief — hoechste Wahrscheinlichkeit
    Ein Container-JVM ohne explizites -XX:MaxMetaspaceSize begrenzt sich in der Praxis ueber den Compressed-Class-Space. Eine ausgewachsene Spring-Boot-Anwendung laedt typischerweise 15.000 bis 25.000 Klassen. Kommen MCP-Tooling, Freemarker, Jackson, JPA und Web-Layer zusammen, ist das eine bewusst zu kalkulierende Zahl — kein Fehler. Test: jcmd <PID> VM.native_memory summary oder ein Blick auf /actuator/metrics/jvm.memory.used?tag=id:Metaspace ueber mehrere Tage. Bleibt die Kurve nach dem Hochlauf flach, ist der Fall erledigt.
  2. V-02 Hidden Classes aus Lambdas und MethodHandles
    Jeder Lambda-Aufrufpunkt erzeugt beim ersten Durchlauf eine versteckte Klasse. Das ist normalerweise eine endliche Menge. Unbegrenzt wird es nur, wenn zur Laufzeit dynamisch neue Aufrufpunkte entstehen — etwa bei Reflection-lastigem Mapping ueber wechselnde Strukturen. Da MZP beliebige Context-JSONs verarbeitet, ist das nicht auszuschliessen. Test: -Xlog:class+load=info mitschreiben und pruefen, ob Klassennamen mit /0x-Suffix ueber die Zeit zunehmen.
  3. V-03 Wiederholte Spring-Context-Erzeugung
    Der klassische Metaspace-Leak. Wird irgendwo im Betrieb ein zweiter ApplicationContext hochgezogen und nicht sauber geschlossen, bleibt dessen Classloader samt aller CGLIB-Proxies fuer immer erreichbar. Bei einer Publisher-Anwendung eher unwahrscheinlich — aber wenn du irgendwo Templates in isolierten Kontexten renderst, waere es der Treffer.
  4. V-04 Freemarker
    Praktisch ausgeschlossen als Metaspace-Ursache, siehe Korrektur oben. FreeMarker interpretiert einen AST. Aber: Wenn du Templates aus der Datenbank bei jedem Request neu parst statt sie ueber Configuration.getTemplate() cachen zu lassen, hast du sehr wohl ein Problem — nur eben im Heap und in der CPU-Zeit. Das lohnt sich unabhaengig zu pruefen.

3 · Was Native Image wirklich liefert

Zuerst das Ehrliche in Richtung Native Image: Es loest das Problem restlos, und zwar nicht durch Tuning, sondern durch Abschaffung der Kategorie. Ein Native Image hat keinen Metaspace, weil es zur Laufzeit keine Klassen laedt. Die Menge der erreichbaren Klassen wird beim Bauen abschliessend bestimmt und in das Image-Heap-Abbild eingebacken. Ein OutOfMemoryError: Metaspace ist danach so unmoeglich wie ein Nullpointer in einem Bild.

Und der Grundspeicher faellt drastisch. Das ist auf einem VPS mit begrenztem RAM, auf dem sieben Dienste nebeneinander laufen, kein kosmetischer Punkt.

Typische Groessenordnungen aus veroeffentlichten Spring-Boot-Benchmarks (REST + JPA), keine MZP-Messwerte. Die Spanne ist gross, weil sie von Anwendung und Konfiguration abhaengt — die Richtung ist aber ueber alle Quellen hinweg konsistent.

Startzeit bis zur ersten beantworteten Anfrage. Der mittlere Balken ist der interessante: JDK 25 mit Leyden-AOT-Cache halbiert die Startzeit ohne jede Codeaenderung und ohne die Einschraenkungen der geschlossenen Welt. Fuer MZP ist Startzeit ohnehin fast egal — der Wert liegt im Speicher, nicht in der Sekunde.

4 · Die Rechnung — was es dich kostet

Hier wird es fuer dein Setup spezifisch unangenehm, und zwar an einer Stelle, die in keinem der Begeisterungsartikel steht.

DER K.-O.-PUNKT · DEINE BUILD-PIPELINE

GraalVM Native Image kann nicht cross-kompilieren. Es gibt keinen GOOS=linux GOARCH=amd64-Schalter wie bei Go. Das Ziel-Betriebssystem und die Ziel-Architektur muessen die des Build-Rechners sein.

Du entwickelst auf macOS mit Apple Silicon und deployst nach eigener ADR-011-Entscheidung lokal gebaut, manuell uebertragen auf einen linux/amd64-VPS. Diese Kombination geht mit Native Image nicht. Der Docker-Weg ueber --platform=linux/amd64 laeuft auf ARM unter QEMU-Emulation — Berichte sprechen dort von etwa zwanzigfacher Verlangsamung. Ein Build von 6 Minuten wird zu zwei Stunden.

Das heisst: Native Image erzwingt einen Wechsel deiner Deployment-Strategie auf GitHub Actions oder einen amd64-Build-Host. Das ist eine Architekturentscheidung, keine Compiler-Option — und sie widerspricht ADR-011.

Die vollstaendige Kostenaufstellung

Erstaufwand bis zum ersten lauffaehigen Image (Hints, Reflection-Metadaten, CI-Umbau)1–3 Tage
Build-Dauer je Deployment statt bisher ~30 Sekunden5–10 min
Arbeitsspeicher auf dem Build-Rechner, den native-image braucht≥ 8 GB
Binaergroesse gegenueber dem Fat-JAR~85 MB / ~30 MB
Spitzendurchsatz, weil kein JIT mehr nachoptimiert−10 bis −20 %
Laufende Steuer: jede neue Abhaengigkeit kann Reachability-Metadaten brauchendauerhaft
Aenderung an ADR-011 (Build-Pipeline) erzwungenja

Der Verlust, der dich am meisten treffen wuerde

Native Images unterstuetzen JVMTI nicht. Das ist die Schnittstelle, auf der praktisch das gesamte Java-Diagnosewerkzeug aufsitzt. Nach der Umstellung verlierst du:

WerkzeugWofuer du es brauchstNach Native Image
jcmd / jmap / jstackKlassen-Histogramm, Thread-Dumps — die Werkzeuge, mit denen du diesen Absturz aufklaeren wuerdestWEG
Heap-DumpsSpeicherlecks findenNUR MIT ZUSATZFLAG
JDWP-DebuggerBreakpoints im laufenden ContainerWEG
Java Flight RecorderProfiling im BetriebEINGESCHRAENKT
Spring-Profile zur LaufzeitVerhalten ueber Umgebungsvariablen umschaltenBUILD-ZEIT

Die Ironie ist zentral fuer diese Entscheidung: Du wuerdest ein Problem, das du noch nicht diagnostiziert hast, dadurch loesen, dass du dein Diagnosewerkzeug abschaffst. Sollte MZP spaeter ein anderes Speicherproblem entwickeln — eines im Heap, das Native Image nicht beruehrt — stehst du ohne Instrumente da.

5 · Der billigere Weg, den es seit JDK 24 gibt

Native Image war jahrelang die einzige Antwort auf JVM-Startzeit und -Grundspeicher. Das stimmt seit Project Leyden nicht mehr. Der AOT-Cache (JEP 483 in JDK 24, ergaenzt durch JEP 514 und 515 in JDK 25) laesst die JVM das Lesen, Parsen, Verifizieren und Verlinken der Klassen einmal in einem Trainingslauf erledigen und in einer Datei ablegen. Beim Start liest sie die fertig verlinkten Klassen direkt ein.

Gemessene Wirkung in einem Spring-Boot-Projekt: Startzeit von 4,9 auf 2,4 Sekunden — rund die Haelfte, mit drei Kommandos und ohne eine Zeile Codeaenderung. Entscheidend fuer dich: Die geschlossene Welt entfaellt. Reflection funktioniert, dynamisches Klassenladen funktioniert, jcmd funktioniert, der JIT bleibt als Netz darunter.

Dazu kommt in JDK 25 ein zweiter Hebel ohne Trainingslauf: Compact Object Headers (JEP 519) verkleinern jeden Objektkopf von 12 auf 8 Byte. Ein Schalter, kein Umbau.

KONKRET FUER MZP

Der Trainingslauf braucht das entpackte JAR, weil die JVM die Bibliotheken flach sehen muss:

java -Djarmode=tools -jar mzp.jar extract --destination extracted

Danach ein Lauf mit -XX:AOTCacheOutput=mzp.aot unter realistischen Bedingungen — echte Datenbank, ein paar echte Renderings — und im Betrieb -XX:AOTCache=mzp.aot. Der Cache wandert als zusaetzliche Schicht ins Docker-Image.

Aufwand realistisch: ein halber Tag inklusive Dockerfile-Umbau. Kein CI-Umbau, kein Cross-Compile-Problem, kein Verlust an Werkzeug. Und der Weg bleibt offen: Wer den AOT-Cache eingerichtet hat, hat die Vorarbeit fuer Native Image trotzdem nicht umsonst gemacht.

6 · Aufwand gegen Nutzen

Waagerecht: geschaetzter Aufwand in Stunden. Senkrecht: erwarteter Nutzen fuer das konkrete Problem — naechtliche Ausfaelle von MZP — auf einer Skala von 0 bis 10. Links oben ist gut. Native Image liegt weit rechts, und sein Nutzen ist gedeckelt, weil es die Verfuegbarkeit nicht besser absichert als ein Neustart-Reflex plus Alarm.

7 · Empfohlene Reihenfolge

SCHRITT 1 · HEUTE · ~1 STUNDE

Guardrails, damit der Ausfall aufhoert wehzutun

Unabhaengig von der Ursache: -XX:MaxMetaspaceSize=512m setzen, dazu -XX:+ExitOnOutOfMemoryError und in Compose restart: unless-stopped. Damit stirbt der Container beim naechsten Mal sauber und kommt sofort wieder — statt in einem halbtoten Zustand zu haengen und Fehler zu werfen. Mit Beszel dazu bekommst du gleichzeitig den Alarm. Das loest nicht die Ursache, aber es loest den Schaden, und zwar heute.

SCHRITT 2 · DIESE WOCHE · ~1 STUNDE

Messen, ob die Kurve flach ist

Actuator-Metrik fuer Metaspace ueber mehrere Tage aufzeichnen. Flache Kurve nach dem Hochlauf bedeutet V-01: Deckel war zu tief, Fall geschlossen, kein weiterer Aufwand noetig. Steigende Kurve bedeutet echtes Leak — dann -Xlog:class+load=info und schauen, welche Klassennamen nachwachsen. Erst nach diesem Schritt ist ueberhaupt eine begruendete Entscheidung ueber alles Weitere moeglich.

SCHRITT 3 · WENN DER SPEICHER DRUECKT · ~4 STUNDEN

JDK 25 mit AOT-Cache und Compact Headers

Lohnt sich unabhaengig vom Metaspace-Befund, wenn dein VPS-RAM knapp ist. Kein Risiko, kein Werkzeugverlust, reversibel durch Weglassen eines Flags. Beszel liefert dir vorher und nachher die Zahl, an der du siehst ob es etwas gebracht hat.

SCHRITT 4 · NUR UNTER EINER BEDINGUNG · 1–3 TAGE

Native Image

Technisch begruendet ist es erst, wenn Schritt 2 ein echtes, nicht behebbares Klassen-Leak zeigt und Schritt 3 den Speicherdruck nicht ausreichend senkt. Diese Konstellation halte ich fuer unwahrscheinlich.

Es gibt allerdings einen zweiten, voellig legitimen Grund, und den solltest du nicht mit dem technischen verwechseln: Portfolio. Ein produktiv laufender Spring-Boot-Dienst als Native Image, mit dokumentierten Reflection-Hints und einer CI-Pipeline, ist ein vorzeigbares Stueck Arbeit — und passt zu deinem Ziel. Wenn du es aus diesem Grund machst, mach es bewusst und nenne es so. Dann ist der Aufwand keine Verschwendung, sondern Investition in etwas anderes als Verfuegbarkeit.

FAZIT

Native Image ist ein hervorragendes Werkzeug fuer ein Problem, das du wahrscheinlich nicht hast. Es beseitigt Metaspace als Kategorie — aber es kostet dich einen Tag bis drei, einen Bruch mit deiner Build-Strategie, und ausgerechnet die Diagnosewerkzeuge, mit denen du die naechste Ueberraschung aufklaeren wuerdest.

Die ehrliche Antwort auf den 31. Juli ist: eine Stunde Guardrails, eine Stunde Messung. Danach weisst du, ob ueberhaupt etwas zu loesen ist. Alles davor ist Loesung ohne Diagnose — und genau das war schon meine Freemarker-Vermutung im Juli.