Skip to main content

Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native

A Spring Boot 4.1 service started 3.9 s on a plain JDK 25 and 2.0 s with the JDK AOT cache, from one extra flag. Measured against AppCDS, Spring AOT and a GraalVM native image, with 24 ways the cache gets silently rejected (rebuilt jar, copied files, ZGC, compact headers), the Docker consequences, and what is and is not known about Netflix’s production use.

Spring Boot’s slow start is mostly the JVM loading and linking tens of thousands of classes. Since JDK 24 the JVM can do that work once, save the result in a file, and reuse it. In JDK 25 creating that file is one flag. On the Spring Boot 4.1 service I measured, the time from process start to the first successful HTTP response fell from about 3.9 s to 2.0 s with the JDK’s AOT cache, and to 1.4 s when combined with Spring’s own AOT output. A GraalVM native image on the same service answered in 0.13 s, at a price. This post shows the commands, the measurements, and the part most articles leave out: the many ways the cache is silently refused and the JVM carries on at full cold-start speed.
Versions and limits. Spring Boot 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24), Temurin 25.0.4.1 (LTS) and Temurin 27+35 for comparison, GraalVM CE 25.0.2 for the native image. One small service (Spring MVC, Jackson, Bean Validation, the actuator, an in-memory store, no database) on one 2 vCPU, 8 GB Linux machine. Treat the ratios as the result, not the milliseconds. Docker had no daemon in my sandbox, so the image-related parts are reasoning and arithmetic, not a build. All code and every raw transcript is in the aot-cache module of my spring-boot-demo repository.

Three JEPs, one cache file

The pieces arrived across three releases, and it helps to keep them apart:
  • JEP 483, ahead-of-time class loading and linking (JDK 24). Stores classes after they were read, parsed, loaded and linked, so the next start skips that work. The JEP’s own example is Spring PetClinic, about 21,000 classes, starting in 4.486 s instead of 2.604 s on its test machine.
  • JEP 514, command-line ergonomics (JDK 25). Creating the cache becomes a single flag, -XX:AOTCacheOutput=app.aot, instead of a two-step record-then-create process. Using it is -XX:AOTCache=app.aot. The JEP notes the one-step form needs about twice the heap while creating the cache.
  • JEP 515, ahead-of-time method profiling (JDK 25). Stores the method-execution profiles from the training run in the same cache so the JIT can start optimizing sooner. The JEP page I read lists release 25 but shows status “Targeted”. On JDK 25 the profiles are part of the cache and cannot be switched off separately, so I could not isolate their contribution.

Making and using the cache

This is the training command from the repository’s scripts/train.sh, run against the extracted jar (java -Djarmode=tools -jar app.jar extract). The system property makes Spring start the context and exit, which is enough for a first cache:
$ java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar app.jar
$ java -XX:AOTCache=app.aot -jar app.jar
Training wall times and file sizes from the same run set:
AppCDS  (ArchiveClassesAtExit)       4741 ms   appcds.jsa (38 MB)
AOT cache, fat jar, context-only     8219 ms   fat.aot (56 MB)
AOT cache, context-only training     7916 ms   refresh.aot (59 MB)
AOT cache, traffic training         10157 ms   traffic.aot (66 MB)
AOT cache + Spring AOT, context-only   7164 ms   springaot.aot (57 MB)
AOT cache + Spring AOT, traffic      8733 ms   springaot-traffic.aot (64 MB)
AOT cache on JDK 27, context-only    9292 ms   refresh27.aot (62 MB)
AOT cache on JDK 27, trained under ZGC   9426 ms   zgc27.aot (62 MB)
Budget 60 MB of disk per service for the cache and 8 to 10 seconds of build time. “Traffic training” means starting the app for real and sending it a handful of requests (valid and invalid orders, a 404, the health endpoint) before stopping it, so classes used only on the request path are cached too.

Results: median of ten runs

Milliseconds from starting the process to the first 200 from GET /ping, ten interleaved rounds per configuration:
Plain JVM3920 Spring AOT only3162 AppCDS2583 AOT cache (context only)2022 AOT cache (traffic)1949 AOT cache + Spring AOT1436 GraalVM native image133
ConfigurationReady (ms)First POST /orders (ms)RSS (MB)
Plain JVM, extracted jar3,920173189
AppCDS2,583164195
AOT cache, context-only training2,022143218
AOT cache, training with traffic1,94968199
AOT cache + Spring AOT, training with traffic1,43689184
GraalVM native image1335.3120
Four things to read from it:
  • The AOT cache beats AppCDS by about 560 ms here, and a plain JVM by about 1.9 s.
  • Training with traffic barely changes time to ready (2,022 to 1,949) but halves the first request: 143 ms to 68 ms. That first request is where lazily loaded classes and uncompiled code used to cost you.
  • Spring AOT (the build-time pre-computation of bean definitions) and the JDK cache stack: 1,436 ms. It costs a more involved build.
  • Native is in another league on start-up, 133 ms, and also uses the least memory, but I paid for it with a 5 minute 18 second build, 4.7 GB peak build memory, and the restrictions of closed-world compilation. Those figures are in the repo’s 03-native-build.txt.
All 12 configurations (medians of 10 runs, min and max in brackets)
configuration                    ready (exec->200)    Boot 'Started in'    1st POST /orders     next 50 (median)   RSS
1 jvm-fat-jar                    4630.5 [4281..4931]  3683.5 [3455..3954]  204.0 [166.6..230.7] 4.5                194.0
2 jvm-extracted                  3919.5 [3486..4018]  3242.0 [2890..3400]  173.3 [146.6..191.7] 4.8                189.0
3 jvm+spring-aot                 3162.0 [2991..3500]  2552.0 [2419..2907]  201.8 [165.3..254.3] 4.5                183.0
4 appcds                         2582.5 [2436..2730]  2181.0 [2022..2330]  164.3 [133.9..170.2] 4.8                195.0
5 aot-cache fat-jar              3247.0 [3096..3507]  2553.5 [2432..2787]  156.6 [144.9..186.6] 4.7                235.0
6 aot-cache context-only         2021.5 [1948..2253]  1568.0 [1495..1785]  142.8 [116.1..160.0] 4.8                218.0
7 aot-cache traffic              1948.5 [1777..2142]  1623.5 [1406..1775]  67.7 [47.5..101.8]   4.5                198.5
8 aot-cache+spring-aot           1588.5 [1482..1674]  1120.5 [1030..1218]  146.6 [131.4..163.9] 4.5                188.0
9 aot-cache+spring-aot traffic   1436.0 [1372..1651]  1069.0 [1015..1280]  89.3 [65.9..113.0]   4.2                184.0
10 jdk27 plain                   3779.5 [3595..4205]  3122.5 [2919..3408]  164.8 [139.9..176.8] 4.6                234.0
11 jdk27 aot-cache               1983.0 [1840..2181]  1571.5 [1444..1763]  138.2 [117.9..191.0] 4.2                195.5
12 native image                  133.0 [127..171]     101.5 [90..133]      5.3 [4.6..6.0]       1.0                120.0
Two details worth noticing. Row 5 shows that a cache trained on the fat jar is much less effective (3,247 ms) than one trained on the extracted layout (2,022 ms), so extract first. And “next 50 (median)”, the steady-state request time, is 4 to 5 ms in every JVM configuration: the cache changes how you start, not how fast you run.

An independent re-run today

I rebuilt the module and re-ran three configurations, seven interleaved rounds each, on the machine used for this session. The values are milliseconds to the first 200 from /ping:
appcds: 2423 2564 2435 2465 2362 2639 2392
plain: 3478 3832 4024 3787 3862 3761 3592
aot: 2075 2028 1930 1934 2014 2064 1987
Medians: plain 3,787 ms, AppCDS 2,435 ms, AOT cache 2,014 ms. They agree with the table above to within about 150 ms and the same ordering, which is the check that matters.

The part that bites: silent rejection

The cache records the JVM build, the exact jar files (path, size and modification time) and some JVM settings. If the run does not match, the JVM by default prints log lines and starts anyway at cold-start speed. The process still works, so nothing alerts anyone. scripts/mismatch.sh tries 24 situations. These are the ones that matter, each showing the ready time:
What changed between training and runCache used?Ready (ms)
Nothing (control)yes1,987
Application jar rebuilt after a one-line changeno4,124
Same bytes, files copied with plain cp (new timestamps)no4,013
Cache trained on the fat jar, run from the extracted layoutno3,900
Application directory moved (same bytes)yes2,005
JDK 25’s cache used by JDK 27no4,221
-XX:+UseCompactObjectHeaders added at run time onlyno4,138
SerialGC or ParallelGC at run time (trained under G1)yes1,916 / 1,957
ZGC at run time (trained under G1)no3,981
Different heap size (-Xmx256m)yes2,058
The surprises:
  • File timestamps matter, paths do not. Moving the directory is fine; copying it without preserving modification times kills the cache. In a CI pipeline that copies artifacts between stages, this alone can make the cache useless.
  • ZGC is not interchangeable. JEP 483 says the garbage collector may differ between training and run. I measured something stricter: switching a G1-trained cache to ZGC disabled it, because ZGC runs without compressed oops (the JVM said so: “The saved state of UseCompressedOops (1) is different from runtime (0)”). Train under ZGC and run under ZGC and it works (1,988 ms). Serial and Parallel were fine. If you run ZGC in production, train with ZGC.
  • Compact object headers must match on both sides, on JDK 25 and 27.
  • A cache that does not exist, or does not match, with -XX:AOTMode=on makes the JVM exit with code 1 instead of falling back. For production start-up, that flag turns silent slowness into a loud failure, which is usually what you want in a deployment pipeline, with the trade-off that a bad cache now means the service does not start.
What the JVM actually logs: a rebuilt jar, and AOTMode=on
== the application jar was rebuilt after a one-line code change, default AOTMode=auto
   result:  started, ready after 4124 ms   (Started AotCacheApplication in 3.453 seconds)
   log:    [warning][aot] This file is not the one used while building the AOT cache: 'app.jar', timestamp has changed, size has changed
   log:    [error  ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
   log:    [error  ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure)
   log:    [error  ][aot] Unable to map shared spaces

== the same, with -XX:AOTMode=on
   result:  did not start (exit code 1)
   log:    [warning][aot] This file is not the one used while building the AOT cache: 'app.jar', timestamp has changed, size has changed
   log:    [error  ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure)
   log:    [error  ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
   log:    [error  ][aot] unrecoverable error

== JDK 25's cache used by JDK 27
   result:  started, ready after 4221 ms   (Started AotCacheApplication in 3.537 seconds)
   log:    [warning][aot] The AOT cache version 0x13 does not match the required version 0x14.
   log:    [warning][aot] The AOT cache has the wrong version.
   log:    [error  ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
   log:    [error  ][aot] Loading static archive failed.
   log:    [warning][aot] The AOT cache version 0x13 does not match the required version 0x14.
Note that the log lines are labelled “error” yet the application starts normally. Alert on [aot] lines in start-up logs, because the exit code will not tell you.
What the JVM skipped while writing the cache (122 warning lines)
warning lines: 122
     96 [warning][aot] Skipping <class>: Unsupported location
      4 [warning][aot] Skipping <class>: Unlinked class not supported by AOTClassLinking
      4 [warning][aot] Skipping <class>: Failed verification
      3 [warning][aot] Skipping <class>: JFR event class
      2 [warning][aot] Skipping <class>: Not in loaded state
Most skipped classes were in an “unsupported location”. Those classes load the old way and the cache still works for the rest.

What this means for Docker and CI

Because the cache is tied to exact jar files and the exact JVM, it belongs in the last stage of the build, created by the same JVM that will run it, after the final COPY. Rebuilding the application changes only the Docker “application” layer of a layered Spring Boot jar (the arithmetic from 05-layers.txt: dependencies 22,878 KB identical, application 8 KB changed), but the 61 MB cache is stale after any such change and must be regenerated on top of it. That is the real cost: a layer of about 15 MB gzipped (61,016 KB uncompressed, 15,362 KB gzipped for the context-only cache) that changes on every build. A Dockerfile following this pattern is in the repository, with a header stating plainly that I could not build it.

What about Netflix?

Netflix presented “Java AOT in Production at Netflix” (Martin Chalupa and Ian Brown) at JavaOne 2026, published on Inside.java on 23 May 2026 under the talk title “Draw the Rest of the Owl: Leyden in Production and the Infrastructure Needed to Get It There”. The abstract says they used Project Leyden to improve start-up time for critical services and built tooling and a software delivery process around it. I could read only that abstract: the talk is a video and I found no transcript, so I have no Netflix numbers to report and will not guess them. The title is itself a useful hint, though. It says what my mismatch table says: the hard part is not the flag, it is the infrastructure that creates a cache per build, keeps it matched to the JVM and the jar, and notices when it is not used. If you adopt this, plan for that pipeline, not just for the flag.

Decision guide

Your situationTry
JVM service, you control the build, JDK 25 or laterAOT cache trained with a few requests, -XX:AOTMode=on in production
Need still lower start-up and can afford a Spring AOT build stepAdd Spring AOT output (1.4 s here)
Scale-to-zero or CLI-like start-up neededNative image, accepting its build time and restrictions
Build copies artifacts between stages or machinesPreserve timestamps (or train in the final stage)
Running ZGCTrain with ZGC

Caveats

  • One small service on one 2-CPU machine. A real service with a database and JPA has more classes and usually gains more from the cache, but I did not measure one.
  • JEP 515 is part of the cache on JDK 25 and cannot be switched off, so I cannot say how much of the gain is profiles.
  • No container image was built, so image sizes and registry behavior are inferred from file arithmetic.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.