Startup and the AOT Cache
Learn where a Java service spends its start-up time and use class data sharing and the Java 25 AOT cache to start faster without changing code.
java -XX:AOTCacheOutput=app.aot -jar app.jar # training run (Java 25)
java -XX:AOTCache=app.aot -jar app.jar # use the cacheStart-up time matters when a service scales out under load, in serverless functions, in CI pipelines that start the application for tests, and for command-line tools. A typical framework-based service spends its start-up time loading and linking thousands of classes, running static initialisers, scanning for components and then warming up in the JIT.
Class data sharing
Class data sharing (CDS) stores parsed classes in an archive that the JVM maps straight into memory. The JDK ships a default archive for its own classes and uses it automatically. Application CDS extends this to your classes and libraries:
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # training run
java -XX:SharedArchiveFile=app.jsa -jar app.jar # later runsThe AOT cache
Java 24 introduced the ahead-of-time (AOT) cache, from Project Leyden. It goes further than CDS: it stores classes already loaded and linked, as they were during a training run. Java 25 made it easier to create, with a single option, and also stores method profiles in the cache so the JIT can optimise hot methods sooner.
# 1. Training run: exercise the app, then exit normally
java -XX:AOTCacheOutput=app.aot -jar app.jar
# 2. Production runs
java -XX:AOTCache=app.aot -jar app.jarThe longer Java 24 form still works: -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf for the training run, then -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot to build the cache from that configuration.
Rules that trip people up
- The cache must be used with the same JDK build and the same class path as the training run. Build it in the Dockerfile with the exact runtime image you ship.
- Classes must come from JAR files. A class path containing a directory of classes is rejected with an error when the cache is created.
- If the cache cannot be used, the JVM by default starts without it, only slower, with at most a warning in the log. Add
-XX:AOTMode=onto make start-up fail instead, so a stale cache cannot go unnoticed. - A training run that only starts and stops records start-up classes; one that also serves a few representative requests captures more.
Your own code
- Classes are loaded and initialised on first use. Expensive static initialisers run in whichever request touches the class first, unless you trigger them at start-up on purpose.
- Do slow work once: compile regular expressions into
static final Patternfields, build lookup tables at start-up. - Defer what is rarely needed, such as a report engine, with the holder idiom.
- For the fastest start, GraalVM Native Image compiles the whole application ahead of time to a native executable, at the cost of longer builds and limits on reflection.
Example
When to use it
- Add an AOT cache training run to the Docker build of a service so new pods are ready faster when the autoscaler adds them.
- Cut the start-up time of an integration test suite that boots the application many times by sharing an application CDS archive.
- Move regex compilation and lookup-table building out of request handlers into static final fields initialised once.
More examples
Deferring expensive set-up with the holder idiom
The engine is built on the first call to instance(), not at start-up, and the JVM guarantees class initialisation runs once even with many threads. Use this for heavy objects that most runs never need.
static final class ReportEngine {
private ReportEngine() {
IO.println("report engine built");
}
private static class Holder {
static final ReportEngine INSTANCE = new ReportEngine();
}
static ReportEngine instance() {
return Holder.INSTANCE;
}
String monthly(String month) {
return "report for " + month;
}
}
void main() {
IO.println("server ready");
IO.println(ReportEngine.instance().monthly("2026-01"));
IO.println(ReportEngine.instance().monthly("2026-02"));
}
// Output:
// server ready
// report engine built
// report for 2026-01
// report for 2026-02Compile a pattern once
String.matches compiles the pattern each time it is called, which adds up in a per-request validator. A static final Pattern is compiled once when the class initialises and is safe to share between threads.
import java.util.regex.Pattern;
static final Pattern ORDER_ID = Pattern.compile("ORD-\\d{4}-[A-Z]{3}");
static boolean slowCheck(String id) {
return id.matches("ORD-\\d{4}-[A-Z]{3}"); // compiles the regex on every call
}
static boolean fastCheck(String id) {
return ORDER_ID.matcher(id).matches();
}
void main() {
String[] ids = {"ORD-2026-ABC", "ord-2026-abc", "ORD-26-ABC"};
for (String id : ids) {
IO.println(id + " " + slowCheck(id) + " " + fastCheck(id));
}
}
// Output:
// ORD-2026-ABC true true
// ord-2026-abc false false
// ORD-26-ABC false falseWatching classes load on first use
Creating one HTTP client pulls in a large number of JDK classes the first time, and a framework does this thousands of times during start-up. That loading and linking work is exactly what CDS and the AOT cache save on later runs.
import java.lang.management.ClassLoadingMXBean;
import java.lang.management.ManagementFactory;
import java.net.http.HttpClient;
void main() {
ClassLoadingMXBean classes = ManagementFactory.getClassLoadingMXBean();
long before = classes.getTotalLoadedClassCount();
HttpClient client = HttpClient.newHttpClient();
long loaded = classes.getTotalLoadedClassCount() - before;
IO.println("first HttpClient loaded more than 100 classes: " + (loaded > 100));
IO.println("client version: " + client.version());
}
// Output:
// first HttpClient loaded more than 100 classes: true
// client version: HTTP_2
Discussion