JVM Cold Start in 2026: Leyden vs GraalVM Native Image vs CRaC
Key Takeaways
- →Project Leyden's AOT cache (JEP 483 in JDK 24, JEP 514/515 in JDK 25, JEP 516 in JDK 26) shifts class loading, linking, and method profiles into a training run — on the ordinary HotSpot JVM, with reflection and full tooling intact.
- →GraalVM Native Image gives the smallest footprint and instant start by compiling closed-world to a native binary, at the cost of a build-time reachability model and reflection metadata. It moved to a monthly release train starting with the 25.1 line.
- →CRaC restores a warmed-up JVM from an on-disk image in milliseconds with JIT-compiled code already in place — peak performance immediately — but it is Linux-only, leaks process memory into the image, and needs lifecycle code to close sockets and files before the checkpoint.
- →Pick by constraint, not hype — AOT cache when you want a faster start with zero behavioral change, Native Image when memory density and attack surface dominate, CRaC when you need warmed-up peak throughput on the first request.
The autoscaler made it worse. A payments service runs on Kubernetes with an HPA targeting 60% CPU. A marketing push triples traffic in ninety seconds. The HPA does its job and schedules eight new pods — but each Spring Boot replica takes nine seconds to pass its readiness probe, and another twenty to reach peak throughput as the JIT warms up. For the first half-minute the new pods are either failing readiness or running cold, so the existing pods absorb the surge, breach CPU, and the gateway returns 503s. By the time the fleet is warm, the spike is over. The autoscaler reacted correctly and the system still browned out, because the unit of scaling — a cold JVM — is slow to become useful.
This is the JVM cold-start problem, and in 2026 it is no longer a single problem with a single answer. Three distinct technologies attack it from different angles, and they are at very different points of maturity. This article explains the mechanism behind each, gives you commands that actually run, and ends with a decision framework so you can pick the right one for a given service instead of cargo-culting whatever was on the last conference slide.
Why cold start is an architecture problem, not a micro-optimization
The HotSpot JVM is built to get faster the longer it runs. On startup it scans JARs, parses class files, loads and links thousands of classes, runs static initializers, and interprets bytecode while the JIT compiler watches for hot methods to optimize. That design produces excellent peak performance and pays for it with a slow start and a warmup tail.
For a service that runs for weeks, the startup cost amortizes to nothing. The places it hurts are exactly the ones modern infrastructure pushes you toward:
- Scale-to-zero and serverless. If a function scales to zero between requests, every cold invocation pays the full startup-plus-warmup tax on the critical path of a user request. A multi-second cold start is a multi-second p99.
- Aggressive autoscaling. As in the incident above, the time between "scheduler places the pod" and "pod serves traffic at peak" is dead capacity during exactly the window you provisioned it for.
- High replica density. Each JVM carries a baseline memory cost (metaspace, code cache, heap, thread stacks). When you run hundreds of replicas, that baseline is an infrastructure line item, and a smaller footprint per replica consolidates onto fewer nodes.
- Spot and preemptible nodes. Short-lived nodes mean frequent restarts; frequent restarts mean startup cost is paid often.
The three approaches below trade against three axes: how fast the process becomes useful, how much memory it consumes, and how much you give up to get there — behavioral fidelity, build complexity, debuggability, and library compatibility. Keep those axes in mind; the decision framework at the end is built on them.
Three ways to kill JVM cold start in 2026, all real, all at different maturity:
- Project Leyden / AOT cache — a training run records loaded-and-linked classes plus method profiles into a cache the production run maps in at boot. Runs on stock HotSpot; reflection and tooling intact. Shipping incrementally across JDK 24, 25, and 26.
- GraalVM Native Image — closed-world ahead-of-time compilation to a native binary. Smallest footprint, instant start, no JVM at runtime; pays a reachability-metadata and tooling tax. Now on a monthly release train.
- CRaC — checkpoint a warmed-up JVM to disk and restore it in milliseconds with JIT code already in place. Peak performance on the first request; Linux-only, image holds process memory, needs lifecycle code to release resources.
Pick by constraint: behavioral fidelity → AOT cache; memory density and attack surface → Native Image; warmed-up first request → CRaC.
Approach 1: Project Leyden — the AOT cache (CDS, evolved)
Mechanism
Project Leyden is the OpenJDK effort to improve startup, warmup, and footprint by shifting work earlier in time — out of the production run and into a separate training run. Its first shipped form is the AOT cache, which is a direct evolution of Class Data Sharing (CDS).
CDS has existed since JDK 5: it reads and parses JDK class files once, stores the metadata in an archive, and memory-maps that archive into the JVM at startup so the parsing work is skipped. JDK 12+ ships with a built-in CDS archive of common JDK classes, which is why CDS is already helping you even if you have never heard of it.
The AOT cache goes further. Per JEP 483, it stores classes not just read and parsed but loaded and linked — verification done, symbolic references resolved, lambda metafactories instantiated. When the production run starts, those classes are available "as if the JVM did that work at the exact moment requested — though unaccountably fast," in the JEP's words. The work that normally happens lazily, just-in-time, on the request path of a starting application is hoisted into a one-time cache-creation step.
The training-run workflow
The original two-step workflow from JDK 24 (JEP 483) is explicit. First, a training run records what the application does into a configuration file:
# Step 1 (JDK 24): training run records an AOT configuration
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
-cp app.jar com.example.AppThen a second invocation turns that configuration into a cache. This step does not run your application; it just assembles the cache:
# Step 2 (JDK 24): create the cache from the configuration
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -cp app.jarAnd production runs map the cache in:
# Production run uses the cache (all JDK versions)
java -XX:AOTCache=app.aot -cp app.jar com.example.AppJDK 25 made this much less painful. JEP 514 (AOT Command-Line Ergonomics) introduced -XX:AOTCacheOutput, which collapses the training run and cache creation into a single command and cleans up the temporary configuration file for you:
# Step 1 + 2 collapsed into one command (JDK 25+, JEP 514)
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App
# Production run is unchanged
java -XX:AOTCache=app.aot -cp app.jar com.example.AppUnder the hood, -XX:AOTCacheOutput splits into two JVM sub-invocations: the training run, then an "assembly phase" that builds the cache. Per JEP 514, the assembly phase uses its own heap of the same size as the training run, so a one-step workflow with -Xmx4g needs 8 GB to complete. In a memory-constrained CI runner, use the explicit two-step workflow instead, or pass assembly-only options via the JDK_AOT_VM_OPTIONS environment variable.
JEP 515 (AOT Method Profiling), also in JDK 25, extends the cache to carry method execution profiles gathered during the training run. Normally the JIT must watch the application run for a while before it has enough profile data to compile hot methods; cached profiles let the JIT start compiling hot paths essentially at boot, attacking warmup time rather than just startup. Crucially, cached profiles do not freeze behavior — the JVM keeps profiling in production and will reoptimize if the workload diverges from training.
What's new in JDK 26 — and what's still a draft
JEP 516 (AOT Object Caching with Any GC) shipped in JDK 26. Until then, cached Java objects were stored in a GC-specific memory layout that was incompatible with ZGC, forcing a choice between the AOT cache (for startup) and ZGC (for low tail latency). JEP 516 adds a GC-agnostic, streamed object format so the AOT cache works with any collector, including ZGC. You can force the streamable format with -XX:+AOTStreamableObjects.
The fifth piece — Ahead-of-Time Code Compilation, which would cache the actual JIT-generated native code, not just profiles — is tracked as JDK draft JEP 8335368 and, as of this writing, is still in Draft status with no assigned JEP number or target release. Its draft cites vendor figures of 70–80% startup-time reductions for popular frameworks when AOT code is added to the cache; treat that as an illustrative, not-yet-shipped vendor claim. It is the most exciting part of the roadmap and the least safe to plan around today.
This mapping is the single most error-prone part of any Leyden article. Each row is confirmed against the JEP's own OpenJDK page, all of which carry a Status: Closed / Delivered line and a Release field as of their March 2026 revisions:
| JEP | Title | JDK | Status |
|---|---|---|---|
| 483 | Ahead-of-Time Class Loading & Linking | 24 | Closed / Delivered |
| 514 | Ahead-of-Time Command-Line Ergonomics | 25 | Closed / Delivered |
| 515 | Ahead-of-Time Method Profiling | 25 | Closed / Delivered |
| 516 | Ahead-of-Time Object Caching with Any GC | 26 | Closed / Delivered |
| draft 8335368 | Ahead-of-Time Code Compilation | — | Draft (no number, no target) |
The InfoQ Java Trends Report 2025, published December 2025, described JEP 516 as "targeted for JDK 26" and JEP 8335368 as "in draft status." JEP 516 has since been delivered for JDK 26; the AOT Code Compilation JEP remains a draft.
Constraints you must respect
The AOT cache only works if the training run and production runs are "essentially similar." From JEP 483, the binding constraints are:
- Same JDK release, same OS, same CPU architecture (x64 or aarch64) across training and production.
- Consistent class paths — a production run may append class-path entries but otherwise the paths must match, and they must be JAR files (directories on the class path are not supported).
- Consistent module configuration — the same
--module-path,--add-modules, etc. - No class-rewriting JVMTI agents of the kind that arbitrarily transform classfiles.
Two useful exceptions: training and production may use different garbage collectors, and may use different main classes — which is what lets you write a dedicated training entrypoint. If a constraint is violated the JVM issues a warning and ignores the cache by default; add -XX:AOTMode=on in a diagnostic (non-production) run to make a violation a hard error instead.
Spring Boot makes this turnkey
You do not have to drive these flags by hand. Spring Boot and the broader Spring Framework support CDS and the AOT cache directly; the Spring docs show how a buildpack-built image performs a training run and ships the archive so the production launch maps it in. The takeaway: a meaningful chunk of Leyden's benefit is available today with zero code changes, because the framework orchestrates the training run for you.
Approach 2: GraalVM Native Image — closed-world AOT
Mechanism
GraalVM Native Image[GraalVM Native Image Docs] takes the opposite philosophy from the AOT cache: instead of speeding up the JVM, it removes the JVM from the runtime entirely. The native-image tool performs a static analysis under a closed-world assumption — it determines every class and method reachable when the application runs, then compiles that reachable set, the needed standard-library classes, and a minimal language runtime into a single native executable. There is no interpreter, no JIT, and no class loading at runtime.
The payoff, per the GraalVM docs[GraalVM Native Image Docs]: the binary "starts in milliseconds," "delivers peak performance immediately, with no warmup," "uses a fraction of the resources required by the JVM," and "presents a reduced attack surface."
The build command
For a plain class, the workflow is two commands — compile to bytecode, then build the binary:
javac HelloWorld.java
native-image HelloWorld
./helloworldFor real applications you use the build-tool plugins. With Maven, the GraalVM plugin builds the binary during the package phase behind a native profile:
# JAVA_HOME must point at a GraalVM installation
mvn -Pnative package
./target/helloworldWith Gradle:
./gradlew nativeCompile
./app/build/native/nativeCompile/appThese build commands are taken directly from the current GraalVM Native Image reference manual[GraalVM Native Image Docs]. The native-image build itself requires a local C toolchain (
gcc,glibc-devel,zlibon Linux; Xcode command-line tools on macOS; MSVC on Windows) because it statically links native code from the JDK.
The closed-world tax
The same static analysis that makes Native Image fast is what makes it demanding. The analysis does not run your code, so anything it cannot see by analysis it cannot include: reflection, JNI, dynamic proxies, and class-path resources must be declared as reachability metadata (JSON config files, or via the GraalVM build-tool plugins which can autodetect much of it). Ship a binary that hits an undeclared reflective call and it fails at runtime — the failure mode most teams trip over first. The GraalVM ecosystem has invested heavily here (framework integrations in Spring Boot, Micronaut, and Quarkus generate most of the metadata for you), but third-party libraries that lean on reflection remain the long pole.
The other trade-off is peak throughput. Because the AOT compiler cannot observe runtime behavior, it must optimize conservatively; a long-running JIT will typically pull ahead at steady state. Oracle GraalVM's Profile-Guided Optimization (PGO) closes much of that gap by feeding a profiling run's data back into the build, but that adds a representative-workload requirement to your build pipeline.
As of the 25.1 release line, GraalVM ships feature releases monthly rather than every six months, with quarterly Critical Patch Updates folded in when available — confirmed on the GraalVM Release Calendar. The version scheme follows the JDK's MAJOR.MINOR.SECURITY (JEP 223): MAJOR tracks the Java language baseline, MINOR is the monthly feature train (25.1, 25.2, …), and SECURITY is the underlying JDK CPU level. The calendar lists GraalVM 25.1.3 as a feature release on 25 June 2026. Weekly early-access builds continue between GA releases. Practical effect: native-image improvements now reach you in monthly increments, but you also re-baseline more often.
Approach 3: CRaC — restore a warmed-up JVM
Mechanism
CRaC (Coordinated Restore at Checkpoint) is an OpenJDK project that takes a third route entirely. Instead of making startup faster or removing the JVM, it snapshots a fully warmed-up JVM to disk at an arbitrary point ("checkpoint") and then launches new instances from that image ("restore"). Because the snapshot is taken after warmup, the restored process has loaded classes and JIT-compiled code already in place — so it can deliver peak performance on the very first request, not after a warmup tail.
The current OpenJDK implementation builds on CRIU (Checkpoint/Restore In Userspace) on Linux. Unlike a raw CRIU snapshot, CRaC is coordinated: it notifies the application before the checkpoint and after the restore so the program can release resources that cannot be frozen (open files, sockets) and reacquire them — possibly against a different environment — on restore. That coordination is what lets a single checkpoint be restored many times across different machines.
The checkpoint/restore commands
Two JVM flags drive the lifecycle. -XX:CRaCCheckpointTo=PATH both enables checkpointing and names the image directory; -XX:CRaCRestoreFrom=PATH restores from it. The checkpoint itself is triggered out-of-band with jcmd:
# 1. Start the app with checkpointing enabled (image dir: ./cr)
$JAVA_HOME/bin/java -XX:CRaCCheckpointTo=cr -jar target/app.jar
# 2. Warm it up with representative traffic (another shell)
curl localhost:8080/health
# 3. Trigger the checkpoint by jcmd; the JVM writes the image and exits
jcmd target/app.jar JDK.checkpoint
# 4. Later, restore — starts from the warmed-up image in milliseconds
$JAVA_HOME/bin/java -XX:CRaCRestoreFrom=crThese commands are taken from the CRaC project's own step-by-step documentation. The checkpoint will abort if the process holds an open socket or file at the moment JDK.checkpoint runs — by design, since a frozen handle would diverge from reality after restore. (Note a quirk: jcmd always reports success; checkpoint errors surface in the application's console, not jcmd's.)
The coordination API
To pass that open-resource check, you register a Resource whose callbacks fire around the checkpoint. The portable API lives in org.crac (a compatibility shim that uses reflection to find the real implementation at runtime, so the same code runs on a non-CRaC JDK against a no-op). Here is a small inventory service the three fast-start techniques all apply to, wired for CRaC:
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
// A deliberately small service the three fast-start techniques apply to.
// It warms a price cache on startup, then serves lookups. The CRaC Resource
// callbacks release/reacquire the warmable state around a checkpoint so the
// image can be restored cleanly.
public final class InventoryApp implements Resource {
private final Map<String, Long> priceCents = new ConcurrentHashMap<>();
public InventoryApp() {
// Register for checkpoint/restore notifications on the global context.
Core.getGlobalContext().register(this);
}
// Simulates the warmup an autoscaled replica pays on every cold start:
// build a derived in-memory view the request path depends on.
void warm(List<String> skus) {
for (String sku : skus) {
priceCents.put(sku, basePriceCents(sku));
}
}
long priceFor(String sku) {
Long cents = priceCents.get(sku);
if (cents == null) {
throw new IllegalArgumentException("unknown sku: " + sku);
}
return cents;
}
private static long basePriceCents(String sku) {
// Stand-in for an expensive derivation (FX, tax tables, rules engine).
return 100L + Math.abs(sku.hashCode() % 9000);
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// Drop derived state that must not be frozen into the image. Here it is
// cheap; in a real service this is where you close pools, flush buffers,
// and release sockets and files so the checkpoint can succeed.
priceCents.clear();
}
@Override
public void afterRestore(Context<? extends Resource> context) {
// Re-derive against the restored environment.
warm(List.of("SKU-1", "SKU-2", "SKU-3"));
}
public static void main(String[] args) {
var app = new InventoryApp();
app.warm(List.of("SKU-1", "SKU-2", "SKU-3"));
// Touch the Stream API so a meaningful chunk of the JDK is loaded and
// linked at startup -- exactly the work an AOT cache shifts out of the
// production run.
String report = List.of("SKU-1", "SKU-2", "SKU-3").stream()
.map(sku -> sku + "=" + app.priceFor(sku))
.collect(Collectors.joining(", "));
System.out.println(report);
}
}Build against org.crac:crac and the beforeCheckpoint/afterRestore hooks fire automatically when you trigger the checkpoint. The pattern generalizes: any resource that holds an OS handle implements Resource, closes in beforeCheckpoint, and reopens in afterRestore.
Spring does the coordination for you
If you are on Spring Boot 3.2+ you rarely write Resource implementations by hand. Spring maps checkpoint/restore onto its Lifecycle contract: on checkpoint it stops running beans (giving them a chance to release resources via Lifecycle.stop), and on restore it restarts them. There are two modes, per the Spring Framework checkpoint/restore docs:
- On-demand, on a warmed-up JVM, via
jcmd app.jar JDK.checkpoint. The restored JVM is fully warmed up — peak performance immediately. This requiresorg.crac:crac(version 1.4.0+) on the classpath and the-XX:CRaCCheckpointToflag. - Automatic at startup, with
-Dspring.context.checkpoint=onRefresh. A checkpoint is taken during theLifecycleProcessor.onRefreshphase — after singletons are instantiated but before the lifecycle starts. This "fast-forwards" startup but, as Spring's docs note, does not produce a fully warmed-up JVM (no JIT-compiled hot paths yet).
Three things bite teams in production:
- The image contains process memory. Per the Spring docs, the checkpoint files hold a representation of JVM memory and "may contain secrets and other sensitive data" — anything the JVM has seen, including environment-sourced config. If you ship the image inside a container, treat it as a secret-bearing artifact and assess access controls accordingly.
- Cross-CPU restore needs configuration. Restore on a different CPU than the checkpoint and you may hit a CPU-features error; CRaC requires
-XX:CPUFeatures=...to be set at checkpoint time for portable images. - Scheduled tasks fire on restore. With on-demand checkpoint/restore, a
@Scheduled(fixedRate=...)task will run all the executions it "missed" between checkpoint and restore in a burst. UsefixedDelayor a cron expression, which are computed after each execution.
The comparison table
Numbers below are illustrative orders of magnitude synthesized from vendor and project documentation, not a controlled benchmark — startup and footprint depend heavily on the application, JDK build, hardware, and configuration. Where a primary figure exists, it is cited inline elsewhere in this article. Treat the table as a shape, and measure your own workload before committing.
| Dimension | Stock JVM (baseline) | Leyden AOT cache | GraalVM Native Image | CRaC |
|---|---|---|---|---|
| Cold start | Slow (seconds for a framework app) | Faster — class load/link and (JDK 25+) profiling shifted to training | Fastest — milliseconds, no JVM bootstrap | Fast — restore in tens of ms from image |
| Time to peak throughput | Slowest — full JIT warmup | Improved — cached profiles warm the JIT sooner (JDK 25+) | Immediate, but a lower peak unless PGO is used | Immediate at the checkpointed warmth level |
| Peak throughput | Highest (JIT speculates on live data) | Same as JVM (it is the JVM) | Typically lower than JIT; PGO recovers much of it | Same as JVM at checkpoint time |
| Memory footprint | Highest | ~JVM, plus the mapped cache | Lowest — fraction of JVM RSS[GraalVM Native Image Docs] | ~JVM (restores the full heap from the image) |
| Build / pipeline complexity | None | Low — add a training run; framework can automate | High — long native build, reachability metadata, C toolchain | Medium — orchestrate checkpoint, ship image, Linux runner |
| Observability / debuggability | Full JVM tooling (JFR, agents, thread dumps) | Full JVM tooling | Reduced — limited agents, partial JFR | Full JVM tooling (it is a real JVM) |
| Library / framework compatibility | Universal | Universal (no closed-world limits; some agent restrictions) | Constrained — reflection/JNI/proxies need metadata | Good, but resources must coordinate via the CRaC API |
| Platform support | All | All (per supported JDK) | Linux / macOS / Windows | Linux only (CRIU-based) |
| Behavioral fidelity vs plain JVM | Identical | Identical | Closed-world model can differ | Identical, plus checkpoint/restore lifecycle |
| Maturity (mid-2026) | n/a | Shipping incrementally (JDK 24→26); code-caching still draft | Mature; monthly releases from 25.1 | Usable; framework-integrated; operational caveats |
A decision framework
Start from the constraint that actually hurts, not from the technology you find most interesting. Use the interactive tool below to weigh your constraints, then review the detailed guidelines for each technology.
Interactive JVM Startup Finder
Decision MatrixAnswer the environmental constraints below to find the best JVM startup optimization path:
Reach for the Leyden AOT cache when…
- You want a faster start with zero behavioral risk. It is the ordinary HotSpot JVM, so reflection, dynamic class loading, JFR, agents, and every library you already use keep working unchanged.
- You are already on JDK 24+ (ideally 25+ for the one-command workflow and cached profiles) and can add a training step to your build, or you are on a framework (Spring Boot) that orchestrates it for you.
- You need to preserve full observability and cannot give up JFR, debuggers, or bytecode agents.
- You run ZGC for tail latency and are on JDK 26+ (JEP 516 makes the cache GC-agnostic; before that the cache and ZGC were mutually exclusive).
This is the lowest-risk option and, for most teams, the right first move. It will not match Native Image's footprint, but it asks almost nothing of you.
Reach for GraalVM Native Image when…
- Memory density is the cost driver. A fraction of the JVM's RSS[GraalVM Native Image Docs] per replica consolidates a large fleet onto fewer nodes.
- Scale-to-zero or very short pod lifetimes make instant start non-negotiable and the warmup tail unacceptable, and your workload is short enough that lower steady-state peak throughput does not dominate.
- Attack surface matters — a self-contained binary with no interpreter and no dynamic class loading is a smaller target.
- You can absorb the build cost and metadata work: a multi-minute native build in CI, a C toolchain, and reflection/JNI metadata. Frameworks like Spring Boot, Micronaut, and Quarkus carry most of that weight, so this is far easier on a supported framework than on a reflection-heavy bespoke stack.
Avoid it for long-running, throughput-critical, or reflection-heavy services, and never put nativeCompile in your local edit-compile-test loop — the JVM is dramatically faster for development iteration.
Reach for CRaC when…
- You need peak throughput on the first request after restore, not just a fast start — e.g., a latency-SLA service that cannot afford a warmup tail even briefly. This is CRaC's unique strength: it restores warmed-up code.
- You are on Linux (hard requirement) and can run the checkpoint orchestration in your pipeline.
- You are on a framework that integrates CRaC (Spring Boot 3.2+, Micronaut, Quarkus), so resource coordination is mostly handled for you.
- You can treat the checkpoint image as a sensitive artifact and manage CPU-feature compatibility across checkpoint and restore hosts.
Avoid it if you are not on Linux, cannot accept a memory-bearing image as a deployment artifact, or have a sprawl of un-coordinated resources that would each need beforeCheckpoint/afterRestore handling.
They are not mutually exclusive
These approaches compose. The AOT cache is the JVM's default-on baseline you should adopt regardless. CRaC restores a JVM that itself benefits from CDS. And the broader point from the InfoQ Java Trends Report 2025 is that Leyden, after a slow start, has begun shipping real features in JDK 24 and 25 — fast start is no longer something you must leave the JVM to get. Native Image remains the choice when absolute footprint and a JVM-free runtime are the goal; the rest of the time, the gap it once owned is closing from inside HotSpot.
A concrete migration path
If you are staring at the nine-second-startup incident from the top of this article, here is the order of operations that minimizes risk:
- Measure first. Capture cold-start time to readiness, time to peak throughput, and steady-state RSS per replica. You cannot evaluate a fix without a baseline, and the right answer depends on which of those three numbers is actually breaking your SLA.
- Turn on the AOT cache. It is the cheapest win with no behavioral change. On Spring Boot, enable the framework's CDS/AOT support; on plain Java (JDK 25+), add a
-XX:AOTCacheOutputtraining run to CI and-XX:AOTCacheto the launch. Re-measure. - If footprint is still the problem, evaluate Native Image on a representative service — budget time for reachability metadata and a PGO workload, and keep a JVM build for local development.
- If the warmup tail (not just startup) is the problem, evaluate CRaC on Linux with on-demand checkpointing of a warmed-up instance, so restored pods serve at peak immediately.
- Re-baseline on every JDK and GraalVM bump. Leyden ships incrementally across JDK 24→26 and GraalVM now releases monthly; the trade-offs in the table above are moving in your favor over time, so revisit the decision at least once a release cycle.
The cold-start problem used to have one expensive answer. In 2026 it has three, each with a clear niche. Match the technique to the constraint that is actually costing you — behavioral fidelity, memory, or warmed-up latency — and the autoscaler stops working against you.
Keep Reading
- GraalVM Native Images in Production: From 5-Second Startup to 50ms — the deep dive on Native Image's reachability metadata, PGO, and the closed-world gotchas summarized here
- Java Virtual Threads: Project Loom, Pinning Hazards, and Production Migration — the other half of the modern-JVM scaling story, and what pins a virtual thread in production
- Go vs Java in 2026: An Honest Performance Comparison for Backend Services — how fast-start JVM techniques change the startup and memory comparison against Go
Frequently Asked Questions
What is the difference between Project Leyden's AOT cache and GraalVM Native Image?
The Leyden AOT cache runs on the standard HotSpot JVM. A training run records loaded-and-linked classes (and, since JDK 25, method profiles) into a cache that a production run maps in at startup, so reflection, dynamic class loading, and full debugging tooling keep working. GraalVM Native Image compiles your application ahead of time into a self-contained native binary under a closed-world assumption — there is no JVM, no interpreter, and no JIT at runtime, which gives the smallest footprint but requires explicit metadata for reflection, JNI, and proxies.
Is CRaC production-ready in 2026?
CRaC is usable today and is integrated into Spring Boot (3.2+), Micronaut, and Quarkus, but it carries real operational constraints. It is Linux-only, the checkpoint image contains a snapshot of process memory (which can include secrets), and the application must release open files and sockets before the checkpoint via the CRaC API or framework lifecycle hooks. Restoring on a different CPU than the one used for the checkpoint requires configuring CPU features.
Which JDK version do I need for the AOT cache?
Ahead-of-Time Class Loading and Linking shipped in JDK 24 (JEP 483). JDK 25 added AOT Method Profiling (JEP 515) and the one-command workflow via AOT Command-Line Ergonomics (JEP 514). JDK 26 added AOT Object Caching with Any GC (JEP 516), which unblocks the cache for ZGC. Use JDK 25 or later for the practical single-step workflow.
Does the AOT cache change my application's behavior?
No. Per JEP 483, the timing and ordering of class loading and linking is unobservable to Java code, so shifting that work into a training run produces identical behavior — just a faster start. The only observable difference is that low-level side effects (the timing of filesystem access, log messages, CPU/memory usage) move earlier; applications that depend on those subtle effects are rare.
Can I use these techniques together?
Yes. The AOT cache is effectively a default-on JVM feature and benefits any JVM, including one restored by CRaC. The main exclusivity to know about is historical: before JDK 26, the AOT cache could not be used with ZGC, which JEP 516 fixed. Native Image is the one approach that replaces the JVM rather than augmenting it, so it does not combine with the AOT cache or CRaC.
Was this article helpful?
Your feedback directly shapes our editorial depth and technical accuracy.
Engineering Team
A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.
Read Next
GraalVM Native Images in Production: From 5-Second Startup to 50ms
From 5-second Spring Boot cold starts to 50ms with GraalVM native images. The real gotchas, wins, and whether it's worth it.
Java Virtual Threads: Project Loom, Pinning Hazards, and Production Migration
Java 21 virtual threads: M:N scheduling, pinning hazards, ThreadLocal pitfalls, JFR detection, and what migration really takes.
Go vs Java in 2026: An Honest Performance Comparison for Backend Services
An honest Go (Gin) vs. Java (Spring Boot) comparison for backend services in 2026: memory behavior, cold starts, GC pauses, and cost math — built on documented runtime behavior plus a copy-paste benchmark harness, not unverifiable numbers.