A new JVM instance does the same work every time it starts: scan the same JARs, parse the same class files, load and link the same types, then spend the first seconds collecting profiles so the JIT can compile the hot methods. That is fine for a long-lived server. It is painful for a cold Cloud Run instance, a Lambda custom runtime, or anything that scales by starting more JVMs.
CDS has been the JDK answer since 2004, but the flags were fiddly (-Xshare, class lists, SharedArchiveFile) and the archive stopped at parsed metadata. Project Leyden’s AOT cache is the same idea with a cleaner workflow: train once, then run with the cache. You still have a HotSpot JVM. You do not get a GraalVM native image.
On Java 25 the whole training step is one flag:
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App
java -XX:AOTCache=app.aot -cp app.jar com.example.App
This is not a native image
GraalVM native-image compiles a closed world to a standalone binary. There is no HotSpot at runtime, no JIT, and a long list of reachability config for reflection, proxies, and resources. Peak throughput often lands lower than a warmed JVM; startup is near instant.
An AOT cache does none of that. Production still launches java. The JIT still compiles. Reflection still works. Classes that the training run never saw still load the ordinary way. What the cache does shift earlier is the work HotSpot used to redo on every start: reading, parsing, loading, and linking the classes that actually showed up, and — from Java 25 — the method profiles that used to delay the first compilations.
Same bytecode, same JIT, same debugger. Faster start and a shorter warmup, not a different runtime.
When it shipped
| Release | What landed | Spec |
|---|---|---|
| Java 24 | AOT cache: class loading and linking | JEP 483 |
| Java 25 LTS | One-step AOTCacheOutput; method profiles in the cache | JEP 514, JEP 515 |
| Java 26 | Cached objects work with any GC, including ZGC | JEP 516 |
No preview flag on any of these. The -XX:AOT* options are ordinary HotSpot flags. The -XX:AOT* names are, for the most part, macros over the older CDS options; the new names exist so “share” is not the story anymore.
Note: The cache file format is unspecified and can change between JDK releases. Train and run on the same JDK, same OS, same CPU architecture.
Mental model
Three files, two jobs.
| Artifact | Who writes it | Who reads it |
|---|---|---|
AOT configuration (.aotconf) | Training run (AOTMode=record) | Cache-creation run |
AOT cache (.aot) | Cache-creation run (AOTMode=create) | Production |
| Your app | Unchanged | Unchanged |
The configuration is a temporary observation log: which classes loaded, how they linked, and (from Java 25) which methods ran hot. The cache is the reusable product. Production loads that cache and skips the discovery work.
Two properties are worth internalizing early. Training quality is the feature — a cache is only as useful as the classes and profiles the training run actually exercised. And mismatch is not fatal by default: if the cache is missing or inconsistent, HotSpot warns and continues with a cold start, unless you asked it not to.
Lab setup
The whole lab is one class and one JDK 24+ install (25+ for the one-step command). The program is short, but Stream pulls in hundreds of JDK classes — enough for a cache to matter.
// HelloStream.java
import java.util.List;
import java.util.stream.Collectors;
public class HelloStream {
public static void main(String... args) {
var words = List.of("hello", "fuzzy", "world");
var greeting = words.stream()
.filter(w -> !w.contains("z"))
.collect(Collectors.joining(", "));
System.out.println(greeting);
}
}
Compile it into an output directory so the original source stays untouched:
javac -d out HelloStream.java
A cold run is the baseline. Time it once so the later cache run has something to beat:
java -cp out HelloStream
hello, world
JEP 483 measured this program at 31 ms on JDK 23 and 18 ms on JDK 24 with a cache — 42% faster, for an 11.4 MB cache. Your numbers will move with hardware; the shape should not.
Train and create: two steps (Java 24)
Java 24 made the cache real with two launcher invocations. The first run is your application: it records what happened. The second run is not: it only materializes the cache.
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
-cp out HelloStream
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -cp out
create does not execute main. That is easy to miss if you copy the first command and forget to drop the class name. After it finishes you have app.aot; app.aotconf is leftover and can be deleted.
Production then names the cache and otherwise looks like a normal launch:
java -XX:AOTCache=app.aot -cp out HelloStream
To prove the JVM is actually using it, turn default warnings into a hard failure:
java -XX:AOTCache=app.aot -XX:AOTMode=on -cp out HelloStream
If the cache is missing, the class path drifted, or a JVMTI agent rewrote classes, this form exits instead of starting cold. Do not ship AOTMode=on in production unless you own every extra VM flag — a cloud agent that hooks ClassFileLoadHook will refuse the launch.
Train and create: one step (Java 25)
JEP 514 collapses the two invocations when you only need the common case. -XX:AOTCacheOutput tells the launcher to record, then create, and to throw away the temporary configuration file:
java -XX:AOTCacheOutput=app.aot -cp out HelloStream
Production is unchanged:
java -XX:AOTCache=app.aot -cp out HelloStream
The two-step workflow is still there for a reason. Cache creation can want a bigger heap than training, and the one-step form runs both sub-invocations with the same heap size — memory needed is roughly double -Xmx. Train on a small instance that looks like production; create on a large one:
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
-Xmx512m -cp out HelloStream
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -Xmx4g -cp out
When you want the one-step workflow but the create half needs different flags, set JDK_AOT_VM_OPTIONS. Syntax matches JAVA_TOOL_OPTIONS; those options apply only to cache creation, not to the training run:
export JDK_AOT_VM_OPTIONS='-Xmx4g'
java -XX:AOTCacheOutput=app.aot -Xmx512m -cp out HelloStream
Profiles come along for the ride (Java 25)
JEP 515 adds method-execution profiles to the same cache. There is no extra flag. A training run that actually exercises hot methods stores the tallies; a production run hands those profiles to the JIT on day zero instead of waiting for a warmup window.
That is the chicken-and-egg fix. The JIT still profiles in production — behaviour can diverge from training — but it starts compiling with a statistical picture already in hand. JEP 515’s own HelloStream-style example dropped from 90 ms to 73 ms once profiles were in the cache, for about 250 KB extra.
Cached profiles are not AOT-compiled native code. The JEP is explicit that compiling hot methods ahead of time is future work. What you get in Java 25 is an earlier, more accurate JIT, not a binary.
Any GC, including ZGC (Java 26)
Through Java 25 the cached Java objects (Class instances, strings, byte arrays) were stored in a GC-specific heap layout that Serial, Parallel, and G1 could map directly. ZGC’s coloured pointers and region rules did not fit, so you had to choose: low-latency GC, or an AOT cache.
JEP 516 stores those objects in a GC-agnostic format (references as logical indices) and streams them into the heap. JEP 483 already let training and production pick different collectors among Serial, Parallel, and G1; Java 26 adds ZGC to that list.
HotSpot picks the on-disk layout from the training run:
| Training run looks like | Cache format | Why |
|---|---|---|
ZGC, -XX:-CompressedOops, or heap > 32 GB | Streamable, GC-agnostic | Spare cores assumed; streaming hides disk latency |
-XX:+UseCompressedOops (heap < 32 GB, not ZGC) | Mappable, GC-specific | Constrained box; mapping from the filesystem cache is cheaper |
Force the streamable layout when the heuristic would map:
java -XX:AOTCacheOutput=app.aot -XX:+AOTStreamableObjects \
-XX:+UseCompressedOops -cp out HelloStream
The JDK also ships two baseline caches of its own, one of each format, so a process with no application cache can still map or stream the common JDK classes.
Why Spring Boot and cloud functions care
JEP 483 timed Spring PetClinic 3.2.0 at 4.486 s on JDK 23 and 2.604 s on JDK 24 with a cache — again about 42% — for ~21,000 classes and a 130 MB cache. That is the motivation, not a Boot tutorial: a framework that discovers @Bean and @Configuration at startup pays the class-loading bill on every new JVM, and scale-to-zero products create new JVMs constantly.
Train against a classpath of JARs that the built-in class loaders will see. JEP 483 does not cache classes from user-defined class loaders, so a Spring Boot nested fat-jar launcher is the wrong training shape. Explode the application (or use the layout your framework already documents for CDS) and pass a -cp of real JAR files. Directories on the class path are not supported: HotSpot cannot cheaply check them for consistency.
A useful training run looks like production through startup: load the same types, hit the same code paths, then exit. A second main class that boots the app against a local config and a mocked database is a common pattern. A huge test suite that pulls in JUnit and never-used adapters just bloats the cache.
Consistency rules
To use the cache, subsequent runs must be essentially similar. The JEP’s list is short and strict.
- Same JDK release, same OS, same hardware architecture (
x64vsaarch64). - Same class path of JAR files; later runs may append extra entries, nothing else.
- Same
-m/--module/-p/--module-path/--add-modules/--enable-native-accessarguments. - No
--add-exports,--add-opens,--add-reads,--illegal-native-access,--limit-modules,--patch-module, or--upgrade-module-path. - No JVMTI agents that rewrite class files or inject bootstrap/system class-path entries.
Two deliberate exceptions: training and production may use different garbage collectors, and they may use different main classes — that is how a trainer main is legal.
If a class cannot be AOT-loaded (user-defined loader, signed class, old verifier), HotSpot loads it just-in-time and does not AOT-link other classes to it. The cache is a fast path, not a closed world.
Gotchas
AOTMode=auto is the default. Missing or unusable cache → warning, cold start, process still runs. on fails fast; off ignores the cache.
One-step doubles the heap. -XX:AOTCacheOutput plus -Xmx4g wants about 8 GB for the combined record+create. Use two steps or JDK_AOT_VM_OPTIONS when RAM is tight.
Train the startup you will actually run. Classes loaded only in production are a cache miss, not an error. -verbose:class during training is the way to see what you captured.
-XX:-AOTClassLinking at create time turns the cache back into CDS-style parsed metadata: useful for measuring how much of a win is linking versus reading, not something to ship.
CPU features travel with the machine you trained on. A cache built on a host with extra SIMD extensions can crash on a smaller production CPU. Train on the same class of hardware you deploy to.
Cheat sheet
Java 24 / JEP 483: AOT cache (load + link), two-step record then create
Java 25 / JEP 514: -XX:AOTCacheOutput=file (one-step; temp .aotconf deleted)
Java 25 / JEP 515: method profiles stored in the same cache (no extra flag)
Java 26 / JEP 516: GC-agnostic object cache; ZGC allowed; +AOTStreamableObjects
Two-step (any 24+)
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -cp app.jar Main
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -cp app.jar
One-step (25+)
java -XX:AOTCacheOutput=app.aot -cp app.jar Main
JDK_AOT_VM_OPTIONS='-Xmx4g' # create-half only
Production
java -XX:AOTCache=app.aot -cp app.jar Main
java -XX:AOTCache=app.aot -XX:AOTMode=on ... # fail if cache unusable
-XX:AOTMode=off | auto | on | record | create
Measure / inspect
-verbose:class
-XX:-AOTClassLinking # at create; CDS-like, no AOT load/link
Java 26 layout
-XX:+AOTStreamableObjects # force GC-agnostic streaming format
Training with ZGC / -CompressedOops / >32g heap => streamable by default
Training with +UseCompressedOops => mappable by default
Not a native image: still HotSpot, still JIT, still dynamic
Same JDK + OS + arch; JAR class path only; built-in loaders only
Do:
- Prefer
-XX:AOTCacheOutputon Java 25+ unless you need split heaps. - Train a smoke path that loads the same classes production loads at startup.
- Pass a class path of JARs, not directories, and keep it stable across train and run.
- Use
-XX:AOTMode=onin CI to catch a broken cache before production swallows the warning.
Don’t:
- Confuse this with GraalVM
native-image— there is no closed world and no standalone binary. - Train on a Spring Boot nested fat jar and expect application classes to be cached.
- Ship
AOTMode=onnext to a monitoring agent that rewrites classes. - Expect ZGC + AOT cache before Java 26.
- Treat the
.aotfile as portable across JDK versions or CPU families.
Wrap-up
Leyden’s AOT cache is CDS with the fiddly bits renamed and the missing steps filled in. Java 24 stores loaded and linked classes. Java 25 makes the training command one flag and puts method profiles in the same file, so the JIT does not wait for a warmup window. Java 26 stops making you pick between that cache and ZGC.
The workflow is train, then run. The runtime is still HotSpot. Reach for it when a new JVM is the unit of scale — Boot services, functions, scale-to-zero containers — and you want seconds back without signing up for a closed-world native image.