Was erwartet uns in diesem Text?
Ahead-of-Time Class Loading klingt erst mal toll: Klassen werden geladen, bevor sie benötigt werden. Mit JEP 483 geht das noch einen Schritt weiter: Klassen werden nicht einfach nur geladen, bevor sie benötigt werden, sie werden sogar schon geladen, bevor das Programm überhaupt startet!
Das hört sich fantastisch an. Überall liest man, dass dadurch alles schneller wird, Anwendungen starten noch schneller als sowieso schon (Java!), zweistellige Prozentzahlen werden genannt.
Wir schauen uns das genauer an, und zwar nicht mit einer Kommandozeilen-Hello-World-App, sondern mit einer Anwendung, die einen REST-Service anbietet, um den Effekt etwas praxisrelevanter zu untersuchen: Ein praxisnaher Benchmark mit Spring Boot 4 und Quarkus 3.32.
JEP 483: Ahead-of-Time Class Loading & Linking (JDK 24)
JEP 483 erweitert die JVM um einen AOT-Cache. Klassen werden einmalig gelesen, geparst, geladen und verlinkt, und das Ergebnis wird in einer Cache-Datei gespeichert. Bei nachfolgenden Starts stehen diese Klassen sofort bereit, ohne dass die JVM diese Arbeit wiederholen muss.
Wichtig: Das Feature erfordert keine Änderungen am Quellcode, keine spezielle Build-Konfiguration und kein GraalVM. Es funktioniert mit jeder bestehenden JAR-Datei.
Hinweis zur Konsistenz:
Der AOT-Cache ist plattform- und JDK-versionsspezifisch. Er muss bei jedem neuen JAR-Build, JDK-Update oder Plattformwechsel neu erstellt werden. In einer CI/CD-Pipeline gehört der Training-Run deshalb direkt nach dem Maven/Gradle-Build.
Wie funktioniert JEP 483?
Bisher: Klassischer Start der JVM
Beim klassischen Start der JVM werden die Klassen on-demand geladen:
- Klasse A wird geladen
- Klasse A benötigt Klasse B → Class Loader wird aktiviert
- B braucht C → wieder Class Loading
- B braucht auch D → …
Das führt zu vielen kleinen Unterbrechungen: Synchronisation im Class Loader, Verifikation, Linking. I/O-Zugriffe. Diese Arbeit ist über den gesamten Start verteilt und wird bei jedem Programmstart erneut ausgeführt.
Ahead-of-Time Class Loading
Hier wird vor dem ersten Start ein Trainingslauf absolviert. Die JVM zeichnet dabei auf, welche Klassen mit welchen verbunden sind, und wann und in welcher Reihenfolge sie geladen werden.
Die Daten werden in eine Cache-Datei geschrieben.
Bei allen weiteren Läufen kann die JVM nun
- viele Klassen frühzeitig und gebündelt laden
- das in optimierter Reihenfolge tun
- teilweise parallelisieren
- Class-Loader-Locks reduzieren
- I/O-Zugriffe besser bündeln
Die Gesamtarbeit ist also ähnlich (Laden aller Klassen, die benötigt werden), aber sie wird effizienter organisiert.
Der Benchmark
Ab in die Praxis
In der Theorie klingt das wie immer erst einmal durchaus plausibel. Wir wollen aber belastbare Daten.
Unsere Messkandidaten sind zwei Microservices: eine Spring-Boot-App und eine Quarkus-App.
Da wir messen wollen, welchen Einfluss AoT Class Loading auf den Start der Anwendung hat, messen wir die TTFR, die Time-To-First-Request, also die Zeit, die die startende Anwendung benötigt, bis sie den ersten Request verarbeiten kann.
Der Code beider Anwendungen ist so weit wie möglich identisch. Beide bieten zwei REST-Endpoints an, die während unserer Messungen verwendet werden, um die TTFR zu bestimmen.
Messgröße: Time-to-First-Request
Gemessen wird die Time-to-First-Request (TTFR), die Zeit vom Prozessstart bis zur ersten erfolgreichen HTTP-Antwort. Diese Metrik ist für Cloud-Deployments relevanter als die intern gemeldete Startup-Zeit, weil sie das tatsächliche Bereitschaftsfenster aus Sicht des Load Balancers abbildet.
Das Benchmark-Script startet die Anwendung, pollt den Health-Endpoint alle 50 ms und stoppt die Stoppuhr bei der ersten HTTP-200-Antwort. Jede Variante wird 10 Mal gemessen, aus den Ergebnissen werden Median, Standardabweichung und 95%-Konfidenzintervall berechnet (t-Verteilung, df=9, t=2,262). Die Konfidenzintervalle überlappen sich nicht, alle Unterschiede sind statistisch belastbar.
Fairness durch Symmetrie
Spring Boot bringt bereits von Haus aus einen eigenen AOT-Prozessor mit. Ein naiver Vergleich von Spring Boot und Quarkus wäre deshalb methodisch unehrlich: Spring Boot hätte ohne seinen eigenen AOT-Prozessor einen strukturellen Nachteil. Deshalb enthält der Benchmark fünf Varianten:
- Spring Boot ohne AOT: Reiner JVM-Start, Baseline
- Spring Boot + JEP 483: AOT-Cache, aber kein Spring AOT-Processing
- Spring Boot + AOT-Processing + JEP 483: Build-Zeit-AOT via spring-boot-maven-plugin kombiniert mit JVM-Cache
- Quarkus JVM ohne AOT: Reiner JVM-Start, Baseline
- Quarkus JVM + JEP 483: AOT-Cache
Bewusst nicht enthalten ist Quarkus Native Image, denn das ist eine andere Technologiekategorie. Native Image ist Compile-Time-AOT mit Closed-World-Assumption, GraalVM-Abhängigkeit und langen Build-Zeiten. JEP 483 ist Runtime-AOT ohne diese Einschränkungen.
Wichtiger Detailpunkt: Der Warmup-Request
Nach dem Hochfahren der Anwendung im Trainingslauf wird vor dem Shutdown explizit ein Request an den/load-Endpoint gesendet. Ohne diesen Schritt erfasst der Cache nur die Startup-Klassen, nicht die Request-Handler-Infrastruktur (JAX-RS-Dispatcher, Serialisierung, Math-Bibliotheken). Ein unvollständiger Cache kann die Startzeit sogar verschlechtern, weil die JVM den Cache laden muss und die fehlenden Klassen trotzdem nachlädt. In einem früheren Versuch dieses Benchmarks war genau das zu beobachten: Quarkus mit Cache war langsamer als ohne.
Die eigentlichen Aufrufe
Für den Benchmark selbst sind im Kern nur zwei Befehle nötig. Zuerst der Trainingslauf, der den Cache erzeugt:
java -XX:AOTCacheOutput=app.aot -jar app.jar
Die Anwendung startet normal, der Cache wird beim Shutdown geschrieben. Ab dem nächsten Start wird er verwendet:
java -XX:AOTCache=app.aot -jar app.jar
Für Spring Boot kommt optional noch das eigene AOT-Processing dazu, das zur Build-Zeit läuft und den Reflection-Overhead reduziert:
java -Dspring.aot.enabled=true -XX:AOTCache=app.aot -jar app.jar
Ergebnisse
| Variante | Median | Stddev | vs. Baseline | 95%-KI |
|---|---|---|---|---|
| Spring Boot 4.0.3 | ||||
| Spring Boot – ohne AOT | 3054 ms | 50 ms | (Baseline) | 3020 – 3091 ms |
| Spring Boot – JEP 483 | 2106 ms | 72 ms | -31,0% | 2083 – 2186 ms |
| Spring Boot – AOT-Processing + JEP 483 | 1833 ms | 42 ms | -40,0% | 1814 – 1874 ms |
| Quarkus 3.32.1 (JVM-Mode) | ||||
| Quarkus JVM – ohne AOT | 1449 ms | 25 ms | (Baseline) | 1439 – 1475 ms |
| Quarkus JVM – JEP 483 | 553 ms | 19 ms | -61,8% | 547 – 574 ms |
Gemessen auf einem Entwicklerrechner (Windows, JDK 25). n=10 Durchläufe, Median. Quarkus als uber-jar.
Was die Zahlen sagen
Spring Boot: additiver Effekt
Der Vergleich der drei Spring-Boot-Varianten zeigt einen klaren additiven Effekt: JEP 483 allein bringt -31,0%. Die Kombination mit Spring AOT-Processing liefert -40,0%. Die beiden Mechanismen arbeiten auf verschiedenen Ebenen: JEP 483 beschleunigt das Laden und Linken aller Klassen. Spring AOT-Processing reduziert darüber hinaus den Reflection-Overhead beim Aufbau des ApplicationContext.
Quarkus: dramatischer Effekt
Quarkus profitiert mit -61,8% deutlich stärker. Quarkus lädt im JVM-Mode viele Klassen bewusst lazy, das Framework ist primär auf Native Image ausgelegt, nicht auf JVM-Kaltstart-Optimierung. JEP 483 trifft hier also auf maximalen Nachholbedarf: Der Cache übernimmt genau die Arbeit, die Quarkus im JVM-Mode bisher bei jedem Start wiederholt hat.
Das Ergebnis: 553 ms für eine vollständig hochgefahrene Quarkus-REST-Anwendung mit JVM – ohne GraalVM, ohne Native Image, ohne Closed-World-Assumption.
Das ist eine Zahl, die vor JDK 24 undenkbar war.
Einschätzung und Vergleich
Spring Boot mit AOT-Processing und JEP 483 landet bei 1833 ms. Quarkus ohne Cache bei 1449 ms. Auch mit maximaler JVM-Optimierung bleibt Quarkus JVM im Kaltstart schneller als Spring Boot. Das ist eine strukturelle Eigenschaft, Quarkus ist schlanker in seiner Basiskonfiguration.
Was sich aber fundamental verändert hat: Der Abstand ist nicht mehr ‚Spring braucht doppelt so lange‘, sondern ‚Spring braucht 40% länger‘. Das ist für viele Produktionsszenarien der Unterschied zwischen einem Problem und keinem Problem.
Bedeutung für den Cloud-Betrieb
Kubernetes und OpenShift: Readiness Probes
In Kubernetes und OpenShift definiert die Startup-Zeit direkt die initialDelaySeconds in Readiness- und Liveness-Probes. Mit einer TTFR von 553 ms statt 1449 ms können diese Werte aggressiver konfiguriert werden. Rolling Deployments werden schneller, Fehler werden früher erkannt.
Auto-Scaling und Kosten
Cloud-Plattformen berechnen Kosten ab dem Moment, wo eine neue Instanz gestartet wird, nicht erst wenn sie Traffic bedient. Container werden neu gestartet, Services werden skaliert, Pods werden verschoben. Ein Service muss dann möglichst schnell „ready“ werden.
Mit JEP 483 verkürzt sich das ‚tote Fenster‘ zwischen Instanzstart und Bereitschaft erheblich. Bei häufigem Auto-Scaling unter Last summiert sich das direkt in Kosteneinsparungen.
CI/CD-Integration: Der Cache als Build-Artefakt
Der AOT-Cache muss als Teil des Container-Images ausgeliefert werden. Das bedeutet: der Training-Run gehört in die Build-Pipeline. Mit JEP 514 ist das ein einziger zusätzlicher Befehl:
# Im Dockerfile: Training-Run nach dem Kopieren des JARs
RUN java -XX:AOTCacheOutput=app.aot -jar app.jar &
# warten bis ready, dann shutdown triggern
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "app.jar"]
Dieser Ansatz ist einfacher als Native Image in jeder Hinsicht: kein GraalVM im Build-Container, kein 10-minütiger Compile-Schritt, keine Einschränkungen durch Closed-World-Assumption, keine Debugging-Probleme in Produktion.
Spot Instances und Preemptible VMs
AWS Spot Instances, Google Preemptible VMs und Azure Spot VMs werden kurzfristig terminiert und neu gestartet. Je kürzer die Startup-Zeit, desto kleiner das Availability-Gap beim Neustart. JEP 483 macht JVM-basierte Anwendungen für diese kostengünstigen Instanztypen erheblich attraktiver, ohne den operativen Aufwand von Native Image.
Fazit: Tatsächlich ein echtes Feature
JEP 483 ist kein akademisches Experiment und auch kein Hype. Es liefert messbare, reproduzierbare Verbesserungen für echte Anwendungen, ohne Quellcodeänderungen und ohne neue Abhängigkeiten.
Für Spring-Boot-Entwickler
Die Kombination aus Spring AOT-Processing und JEP 483 ist der empfohlene Weg. Beide Mechanismen sind komplementär. Wer Spring Boot 3 nutzt, aktiviert den eigenen AOT-Mechanismus mit process-aot und muss nur noch den Training-Run in die Pipeline integrieren. In Spring Boot 4 ist build-time AOT zwar schon standardmäßig aktiviert, aber davon weiß die JVM nichts, solange man es ihr nicht explizit sagt. -Dspring.aot.enabled=true muss weiterhin beim Start gesetzt werden, sonst ignoriert Spring Boot die generierten Artefakte und fällt auf den Reflection-Pfad zurück. Genau das erklärt den Unterschied zwischen unseren beiden Spring-Boot-Varianten mit JEP 483.
Für Quarkus-Entwickler
JEP 483 macht den JVM-Mode ernsthaft konkurrenzfähig. 553 ms TTFR ist ein Argument, das viele Diskussionen über ‚müssen wir Native Image einsetzen‘ neu bewertet. Native Image bleibt die richtige Wahl für extreme Anforderungen, aber JEP 483 verschiebt die Grenze, ab der Native Image notwendig wird, deutlich nach oben.
Und für alle
Project Leyden, zu dem JEP 483 gehört, hat gerade erst begonnen. Arbeit, die die JVM normalerweise zur Laufzeit erledigt, wird schrittweise in früheren Phasen vorberechnet.
JEP 483 cached Klassen. JEP 515 (AOT Method Profiling) cached JIT-Profile, und mit Java 26 kommt JEP 516: Ahead-of-Time Object Caching with Any GC, das die Nutzung der Features auch mit anderen als dem G1GC ermöglicht.
Die JVM bewegt sich in Richtung einer Welt, in der Startup-Zeit kein struktureller Nachteil mehr ist – sondern eine Konfigurationsfrage.
Der vollständige Benchmark-Code
(Spring-App, Quarkus-App, benchmark.java) ist wie immer auf GitHub verfügbar:
https://github.com/GEDOPLAN/aot-class-loading







