A Java service that takes eight seconds to start is not doing eight seconds of useful work. It is reading and verifying thousands of class files, linking them, running static initialisers, interpreting bytecode while the JIT compiler watches and learns, and only then compiling the hot paths. It repeats that work, identically, every time it starts. Project Leyden is the OpenJDK effort to stop repeating it.

Leyden does not replace the JVM with something else. It lets a training run do part of the startup work once, saves the results in an ahead-of-time (AOT) cache, and lets later runs reuse them. This article explains the model from first principles, what each shipped JDK release puts in the cache, how to build and ship a cache, the rules that decide whether the JVM trusts it, and when Leyden is the right tool versus CRaC or GraalVM native images.

Advertisement

First principles: where JVM startup time goes

Before main does anything interesting, the JVM must find each class on the class path, read and parse the class file, verify the bytecode, create runtime structures, and link: resolve symbolic references to other classes, methods and fields. Frameworks multiply this. A Spring Boot application commonly loads well over ten thousand classes, and annotation scanning, proxies and lambdas add more generated classes on top. The mechanics of loaders and linking are covered in JVM class loading architecture.

Then comes warmup. Methods start in the interpreter, collect profiles (how often each branch is taken, which receiver types appear at a call site), and are compiled by the tiered JIT compilers once they are hot. Until that happens, the service answers requests at a fraction of its peak speed. Startup is the time to first useful response; warmup is the time to peak throughput. Both matter for autoscaling, serverless platforms and rolling deploys, where every new instance starts cold.

The key observation is that most of this work gives the same answer on every run. The same classes are loaded from the same jars, linked the same way, and the same methods become hot. Recomputing it each time is waste.

The Leyden model: shift computation, constrain the program

Leyden's design documents describe two levers. Shifting computation moves work from run time to an earlier phase, either later-bound (from startup into a training run) or earlier (into the build). Constraining the program lets you promise things the JVM otherwise cannot assume, such as a fixed class path, which is what makes shifted results safe to reuse. The project's original design talked about condensers: build-time transformers that shift computation and emit a new, more constrained program image. Condensers remain a design direction; what has shipped so far is the simpler, fully compatible piece of the idea.

That piece is the training run. You run the application once under a representative workload while the JVM records what it did. An assembly step turns the recording into an AOT cache file. Production runs map the cache and skip the recorded work. Unlike a native image, the program is still ordinary Java on HotSpot: dynamic class loading, reflection and the JIT all still work, and anything not in the cache is simply done the normal way.

Project Leyden: move work out of production startup into a training runTraining runreal workload, -XX:AOTMode=recordAOT configurationclasses seen, profiles, linksAssembly-XX:AOTMode=createAOT cache fileapp.aot, mapped at startupOne step since JDK 25-XX:AOTCacheOutput=app.aot runs training and assembly togetherProduction run with -XX:AOTCache=app.aotClassesparsed, loaded, linkedHeap objectsany GC since JDK 26Method profilesJIT starts informedCompiled codein developmentvalidated, then mappedIf the JDK, OS, architecture, class path or module options differ, the cache is rejectedand the JVM starts the slow way. Nothing breaks, it is just not faster.
A training run records what the application loads, links and profiles; assembly writes the AOT cache; production runs validate the cache and reuse its contents. Compiled code in the cache is still in development.
Advertisement

What has shipped, release by release

Leyden ships incrementally through JEPs, each widening what the cache holds. The foundation is class data sharing (CDS), the long-standing mechanism for archiving parsed class metadata and mapping it at startup; the AOT cache is built on that machinery and extends it.

ReleaseJEPWhat it moves into the cache
JDK 24483 Ahead-of-Time Class Loading and LinkingClasses read, parsed, loaded and linked in the training run; the AOT cache format and the record and create modes
JDK 25514 Ahead-of-Time Command-Line ErgonomicsNothing new in the cache; one command that trains and assembles, plus JDK_AOT_VM_OPTIONS
JDK 25515 Ahead-of-Time Method ProfilingMethod execution profiles, so the JIT can compile hot methods without waiting to observe them
JDK 26516 Ahead-of-Time Object Caching with Any GCCached heap objects in a GC-neutral format that is streamed in, so ZGC users get the cache too
Not yet shippedAOT code compilationNative code from training, available at startup; a stated goal with no release date

The numbers in the JEPs are useful for calibration, as long as you read them as the authors' own benchmarks. JEP 483 reports Spring PetClinic starting in 2.604 seconds instead of 4.486, about 42 percent faster, from class loading and linking alone. JEP 515 reports a small demo program finishing in 73 milliseconds instead of 90 once profiles were cached, for about 250 KB of extra cache. Your gain depends on how much of your startup is class loading versus your own initialisation, such as opening connection pools.

Inside the AOT cache

Think of the cache as a snapshot of JVM-internal data, not of your application's state. It holds class metadata in its loaded and linked form, so the JVM does not parse, verify or resolve those classes again. It holds pre-built heap objects that the JVM itself needs for those classes. Since JDK 25 it also holds method profiles, so the JIT can start compiling methods that training showed to be hot instead of waiting to rediscover them. Your application's static fields, open sockets and caches are not in it: static initialisers of your classes still run in production.

The heap-object part explains the JDK 26 change. Earlier releases stored cached objects in a layout that could be mapped straight into the heap, which tied the format to how particular collectors lay out memory, and ZGC users could not benefit from cached objects. JEP 516 stores objects in a neutral format with logical indices instead of addresses and streams them into the heap at startup, so the cache works with any collector, including ZGC. Mapping is faster where it applies; streaming is portable.

Worked example: building and using a cache

Take a service packaged as app.jar with its dependencies in lib/. On JDK 24 the workflow has two explicit steps: record, then create.

# 1. Training run: execute a real workload and record what the JVM does
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
     -cp app.jar:lib/* com.example.Main --training

# 2. Assembly: does not run the application, only builds the cache
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
     -XX:AOTCache=app.aot -cp app.jar:lib/*

# 3. Production: same JDK, same class path
java -XX:AOTCache=app.aot -cp app.jar:lib/* com.example.Main

From JDK 25, JEP 514 collapses steps 1 and 2. A single -XX:AOTCacheOutput=app.aot run trains, then launches the assembly as a second sub-invocation, using a temporary configuration file that it deletes afterwards. Options meant only for assembly go in the JDK_AOT_VM_OPTIONS environment variable.

What does --training do? That is your job. The training run should exercise the startup path and the common request paths, then exit. Frameworks help: Spring Boot documents -Dspring.context.exit=onRefresh, which starts the application context and exits, and that captures most framework class loading. For warm profiles you want more: a short scripted load against an in-process server backed by test fixtures, never production databases. A multi-stage container build keeps it reproducible:

FROM eclipse-temurin:25-jdk AS train
WORKDIR /app
COPY target/app.jar app.jar
COPY target/lib/ lib/
# Train against fixtures, then exit; produces app.aot next to the jar
RUN java -XX:AOTCacheOutput=app.aot -Dapp.profile=training \
        -cp app.jar:lib/* com.example.Main --training

# Same JDK build as the training stage, or the cache is rejected
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY --from=train /app /app
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-cp", "app.jar:lib/*", "com.example.Main"]

Both stages use the same JDK image on purpose. A JRE image from the same vendor and version line is not automatically the same build; if you switch the runtime stage to a slimmer image, compare the full version strings in both stages, pin image digests, and let the strict-mode smoke test below catch a mismatch.

The consistency rules, and what happens when they break

A cache records decisions that are only valid under the conditions of the training run, so the JVM validates it at startup. Per JEP 483, the production run must use the same JDK release, hardware architecture and operating system; the class path must be the same as in training, although extra entries may be appended at the end; module options must be consistent; and JVMTI agents that rewrite class files are not allowed, because a rewritten class is not the class that was cached. A different main class is allowed.

In the default mode, a cache that fails validation is ignored with a warning and the JVM starts normally. That is safe but sneaky: a patch-level JDK update in the base image, a reordered class path or a new instrumentation agent silently removes your startup gain, and nothing fails. There is also a strict mode that refuses to start without a usable cache; its spelling has changed between releases, so check the documentation for your JDK. Use strict mode in a CI smoke test, and the default mode in production, so a stale cache degrades performance rather than availability. To see what the JVM decided, enable unified logging for the cache (the cds tags, plus aot tags on newer releases; java -Xlog:help lists the tags your build has).

Failure modes in practice

  • No speedup after an upgrade. The cache was built on a different JDK build or class path. Rebuild the cache in the same pipeline step that produces the image, never reuse one across builds.
  • Instrumentation agent disables it. APM agents that transform classes break the no-rewrite rule. Measure with and without the agent before promising a gain.
  • Narrow training, narrow gains. Classes and methods never touched in training are handled the ordinary way. If training only started the context, first requests still pay for their code paths.
  • Skewed profiles. Cached profiles reflect the training workload. HotSpot keeps profiling and can deoptimise and recompile, so a skewed profile costs some warmup rather than correctness, but training traffic should resemble production traffic.
  • Training with side effects. A training run that talks to real queues or databases can send messages or write rows at build time. Point it at fixtures and fail the build if it reaches the network.
  • Image size. Caches for large applications can be tens of megabytes or more. Put the cache in its own layer after the dependencies so it only changes when they change.

Leyden versus CRaC versus native images

All three attack startup, with different contracts. Leyden keeps full Java semantics and asks only for a stable class path and JDK. CRaC restores a whole warmed-up process from a checkpoint, which is faster still but captures live state that the application must close and reopen through checkpoint hooks. Native images compile the application ahead of time under a closed-world assumption.

ApproachStartup and warmupWhat you give up
Leyden AOT cacheLarge startup gain, better warmup; still a full JVM with JITA training step in the build and a cache pinned to one JDK build and class path
CRaCNear-instant restore of an already warm processCheckpoint hooks for sockets, files and secrets; a snapshot that contains live state; platform support
GraalVM Native ImageFastest startup and smallest footprintClosed-world analysis, reflection configuration, a different peak-performance profile

For most server teams on a recent JDK, the AOT cache is the lowest-risk first step: no code changes, no semantic changes, and a failure mode that falls back to normal startup. Reach for CRaC or native images when measured startup after Leyden is still too slow for your platform.

What to do next

  1. Measure baseline startup (time to first successful health check) and warmup (time to steady-state latency) for one service.
  2. On JDK 25 or later, build a cache with -XX:AOTCacheOutput using a startup-only training run and measure again.
  3. Extend the training run with a short scripted load against fixtures, and compare warmup with and without it.
  4. Build the cache inside the image build, in the same stage family and JDK build as the runtime image, and give it its own layer.
  5. Add a CI smoke test that starts the image in strict mode, so a rejected cache fails the pipeline instead of silently slowing production.
  6. Check every Java agent you ship for class rewriting, and measure its effect on the cache.
  7. If you run ZGC, plan for JDK 26 or later before expecting cached objects; record the JDK build in your deployment metadata either way.
Key takeaway: Project Leyden makes Java start faster by shifting repeatable startup and warmup work into a training run and reusing it from an AOT cache. JDK 24 caches loaded and linked classes, JDK 25 adds one-step creation and method profiles, and JDK 26 makes cached objects work with any collector; compiled code is still in development. The cache is only trusted under the same JDK, platform, class path and module options, so build it in the same pipeline as the image, train on realistic fixtures, and test for silent rejection.