Part 11 of 12

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:

  1. Loads and verifies class bytecode
  2. Interprets bytecode (slow)
  3. Profiles which methods are called most (warm-up)
  4. Compiles hot methods to native code via JIT (takes time and CPU)
  5. 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:

  1. 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
  2. Create an AOT cache: store the compiled native code and profile data to disk
  3. 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:

DataWhat it is
Compiled native codeJIT output — x86_64 / ARM64 machine code for hot methods
Method profilesCall site type info, branch frequencies, inlining decisions
Class metadataParsed/verified class data (Class Data Sharing)
String poolInterned 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 AOTGraalVM Native Image
OutputJVM bytecode + cacheNative binary
Startup1–3s (improved)10–100ms
Peak throughputSame as JIT (warm)Usually 10–20% lower
Dynamic class loading✅ Full support❌ Limited / closed-world
Reflection✅ Full support⚠️ Needs config
Memory usageNormal JVMMuch lower
Build complexityLow — just a flagHigh — closed-world analysis
Best forLong-running servicesShort-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 →