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

ReleaseWhat landedSpec
Java 24AOT cache: class loading and linkingJEP 483
Java 25 LTSOne-step AOTCacheOutput; method profiles in the cacheJEP 514, JEP 515
Java 26Cached objects work with any GC, including ZGCJEP 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.

ArtifactWho writes itWho reads it
AOT configuration (.aotconf)Training run (AOTMode=record)Cache-creation run
AOT cache (.aot)Cache-creation run (AOTMode=create)Production
Your appUnchangedUnchanged

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 likeCache formatWhy
ZGC, -XX:-CompressedOops, or heap > 32 GBStreamable, GC-agnosticSpare cores assumed; streaming hides disk latency
-XX:+UseCompressedOops (heap < 32 GB, not ZGC)Mappable, GC-specificConstrained 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 (x64 vs aarch64).
  • Same class path of JAR files; later runs may append extra entries, nothing else.
  • Same -m / --module / -p / --module-path / --add-modules / --enable-native-access arguments.
  • 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:AOTCacheOutput on 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=on in 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=on next to a monitoring agent that rewrites classes.
  • Expect ZGC + AOT cache before Java 26.
  • Treat the .aot file 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.

Next optional step in the series Stop mutating final fields with reflection. Final Means Final: Stop Mutating final Fields With Reflection