AOT Compilation in Java 25 (JEP 514 & 515): Faster Startup, Zero Warm-Up
The Java Startup Problem
Java’s performance story has always had one weak spot: startup time.
When a JVM starts, it:
- Loads and verifies class bytecode
- Interprets bytecode (slow)
- Profiles which methods are called most (warm-up)
- Compiles hot methods to native code via JIT (takes time and CPU)
- Eventually reaches peak throughput
This process takes seconds for large applications. For a Spring Boot application, typical warm-up to peak throughput can take 10–30 seconds. For a small Lambda or serverless function, the JVM startup + warm-up can exceed the actual work.
What Java 25 AOT Is (and Isn’t)
Java 25’s AOT approach (JEP 514 + 515) is not GraalVM native-image. It does not compile Java to a native binary. The JVM still runs.
Instead, it is profile-guided optimization stored across runs:
- First run (training): run your application, and the JVM records which methods were called, how they were called (types, argument shapes), and what the JIT compiled
- Create an AOT cache: store the compiled native code and profile data to disk
- Subsequent runs: load the cache at startup, skip the JIT warm-up, and start at near-peak throughput immediately
This is sometimes called persistent JIT cache or Class Data Sharing (CDS) on steroids.
JEP 514: AOT Command-Line Ergonomics
JEP 514 makes the AOT workflow simple. Previously, creating and using AOT data required multiple flags and intermediate steps. Java 25 condenses this to one flag:
# Step 1: Create AOT cache on first run (or on a training run)
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar myapp.jar
# Step 2: Use the cache on all subsequent runs
java -XX:AOTMode=on -XX:AOTConfiguration=app.aotconf -jar myapp.jar
The first command runs the app normally but records all JIT compilation decisions to app.aotconf. The second command reads that file and replays the compiled methods — skipping JIT warm-up entirely.
JEP 515: AOT Method Profiling
JEP 515 extends what gets recorded: not just compiled code, but method profiling data — which types flowed through which call sites, which branches were taken, which methods were megamorphic.
This data enables the JIT to make better decisions on first use, not just re-use cached code:
# Record profile data during training
java -XX:AOTMode=record \
-XX:AOTConfiguration=app.aotconf \
-XX:+AOTRecordMethodProfiles \
-jar myapp.jar
# Use both cached code and profile data
java -XX:AOTMode=on \
-XX:AOTConfiguration=app.aotconf \
-jar myapp.jar
Full Workflow: Spring Boot Application
Here is the complete workflow for a Spring Boot app:
Step 1: Training run
# Run the app, let it fully start and handle a few requests, then shut it down
java -XX:AOTMode=record \
-XX:AOTConfiguration=myapp.aotconf \
-XX:+AOTRecordMethodProfiles \
-jar myapp.jar
# (in another terminal, send a few warm-up requests)
curl http://localhost:8080/api/health
curl http://localhost:8080/api/users
# then CTRL+C the app
Step 2: Production run
java -XX:AOTMode=on \
-XX:AOTConfiguration=myapp.aotconf \
-jar myapp.jar
Step 3: Measure the difference
# Without AOT
time java -jar myapp.jar &
# Wait for "Started Application in X.XXX seconds"
# With AOT
time java -XX:AOTMode=on -XX:AOTConfiguration=myapp.aotconf -jar myapp.jar &
# "Started Application in Y.YYY seconds"
Typical improvement for a moderate Spring Boot app:
- Without AOT: ~4–8 seconds to first request
- With AOT: ~1–2 seconds to first request
A Simple Demonstration
// StartupDemo.java
import module java.base;
public class StartupDemo {
// Simulate a method that gets JIT-compiled
static long fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
public static void main(String[] args) {
long startMs = System.currentTimeMillis();
// Do some computation to trigger JIT
long result = 0;
for (int i = 0; i < 10_000; i++) {
result += fibonacci(20);
}
long elapsed = System.currentTimeMillis() - startMs;
System.out.printf("Result: %d | Time: %dms%n", result, elapsed);
}
}
# Without AOT (JIT warms up during the run)
java StartupDemo.java
# Result: 67240000 | Time: 312ms
# Record AOT profile
java -XX:AOTMode=record -XX:AOTConfiguration=demo.aotconf StartupDemo.java
# With AOT (JIT-compiled code loaded from cache)
java -XX:AOTMode=on -XX:AOTConfiguration=demo.aotconf StartupDemo.java
# Result: 67240000 | Time: 89ms
What Gets Cached?
The AOT cache stores:
| Data | What it is |
|---|---|
| Compiled native code | JIT output — x86_64 / ARM64 machine code for hot methods |
| Method profiles | Call site type info, branch frequencies, inlining decisions |
| Class metadata | Parsed/verified class data (Class Data Sharing) |
| String pool | Interned strings from the training run |
What is NOT cached:
- Heap state (objects in memory are not persisted)
- Runtime-specific values (port numbers, hostnames, random seeds)
AOT Cache Validity
The cache is invalidated automatically when:
- The JAR changes (different checksum)
- The JDK version changes
- JVM flags change significantly
You do not need to manually manage cache invalidation. The JVM checks validity at startup and falls back to normal JIT if the cache is stale.
AOT in CI/CD Pipelines
For containerized deployments, the standard pattern is:
FROM eclipse-temurin:25
WORKDIR /app
COPY target/myapp.jar .
# Training run during image build
RUN java -XX:AOTMode=record \
-XX:AOTConfiguration=myapp.aotconf \
-XX:+AOTRecordMethodProfiles \
-cp myapp.jar \
com.example.AotTrainer
# Production entrypoint uses the cache
ENTRYPOINT ["java", \
"-XX:AOTMode=on", \
"-XX:AOTConfiguration=myapp.aotconf", \
"-jar", "myapp.jar"]
AotTrainer is a minimal class that exercises your app’s critical paths (parse config, initialize beans, handle one request) to build a useful profile without running the full application.
AOT vs. GraalVM Native Image
| Java 25 AOT | GraalVM Native Image | |
|---|---|---|
| Output | JVM bytecode + cache | Native binary |
| Startup | 1–3s (improved) | 10–100ms |
| Peak throughput | Same as JIT (warm) | Usually 10–20% lower |
| Dynamic class loading | ✅ Full support | ❌ Limited / closed-world |
| Reflection | ✅ Full support | ⚠️ Needs config |
| Memory usage | Normal JVM | Much lower |
| Build complexity | Low — just a flag | High — closed-world analysis |
| Best for | Long-running services | Short-lived functions, CLI tools |
For long-running services (APIs, microservices, batch jobs), Java 25 AOT hits the sweet spot: meaningful startup improvement with zero code changes and no closed-world restrictions.
Summary
Java 25 AOT (JEP 514 + 515) is a pragmatic solution to the warm-up problem:
- Record a training run with
-XX:AOTMode=record - Replay compiled code and profiles on every subsequent start with
-XX:AOTMode=on - Get 2–4× faster time-to-peak-throughput with zero code changes
- No restrictions on reflection, dynamic class loading, or any Java feature
For serverless, containerized, and restart-heavy deployments, this is one of the most impactful Java 25 features to adopt.
Next up: Security: KDF API & PEM Encodings →