Faster Startup with CDS and AOT

Cut JVM startup time with class data sharing, the JDK's AOT cache and Spring's ahead-of-time processing, without moving to a native image.

Syntaxjava -Djarmode=tools -jar app.jar extract --destination application java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application/app.jar java -XX:AOTCache=app.aot -jar application/app.jar

A Spring Boot API typically starts in a few seconds. That rarely matters for a long-running service, but it matters when Kubernetes scales out under load, when scale-to-zero platforms start an instance per burst, and when every deploy restarts dozens of pods. Three techniques shorten startup while keeping the normal JVM.

Class data sharing (CDS)

Much of startup is the JVM loading, parsing and verifying thousands of classes. CDS does that once in a training run, writes the result to an archive, and maps the archive into memory on later starts. The training run should start the application context and exit, which Spring supports with -Dspring.context.exit=onRefresh.

CDS only works with a plain classpath of jar files, not with classes nested inside the executable jar. Extract it first with the tools jar mode:

java -Djarmode=tools -jar app.jar extract --destination application
java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar application/app.jar
java -XX:SharedArchiveFile=app.jsa -jar application/app.jar

The AOT cache in Java 24 and later

JDK 24 introduced an AOT cache (JEP 483) that stores classes already loaded and linked, which saves more than CDS. Java 25 made it a one-step training run with -XX:AOTCacheOutput (JEP 514) and also records method profiles so the JIT warms up sooner (JEP 515). You then start with -XX:AOTCache=app.aot. Since the course uses Java 25, this is the option to prefer.

Spring AOT on the JVM

Spring's AOT engine runs at build time: it evaluates your configuration, decides which beans exist, and generates plain Java code that registers them, so startup does less reflection and condition evaluation. Add the process-aot goal of the Spring Boot Maven plugin and start with -Dspring.aot.enabled=true. The gain on the JVM is moderate; it is mandatory for native images.

The price is a closed world. Bean definitions are fixed at build time, so @Profile and @ConditionalOnProperty on beans are decided when you build, not when you run. Property values can still change at runtime; which beans exist cannot.

Mistakes to avoid

  • Training on one JDK and running on another: the archive is silently ignored. Build and run in the same image.
  • Changing the classpath after training, including the jar order: the archive no longer matches.
  • A training run that needs the database. Flyway and JPA start during refresh, so give the build a database or disable them for the training run.

Measure with the Started ... in X seconds log line before and after; on a typical REST service CDS or the AOT cache cuts startup noticeably.

Example

Example · dockerfile
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -q -DskipTests package \
 && java -Djarmode=tools -jar target/app.jar extract --destination /app

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app /app

# Training run: start the context, record the AOT cache, exit.
# No database is reachable during docker build: Flyway is switched off here, and an
# app using JPA also needs Hibernate told not to read JDBC metadata, or a database.
RUN java -XX:AOTCacheOutput=app.aot \
         -Dspring.context.exit=onRefresh \
         -Dspring.flyway.enabled=false \
         -jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "app.jar"]

When to use it

  • A service on a scale-to-zero platform takes several seconds to answer the first request after idling; an AOT cache shortens the cold start.
  • Kubernetes scale-out during a traffic spike adds pods that become ready noticeably sooner with CDS baked into the image.
  • A team moves to Spring AOT and discovers a @ConditionalOnProperty switch no longer works at runtime, and replaces it with a runtime choice.

More examples

Generating Spring AOT code in the Maven build

The <code>process-aot</code> goal runs your application's configuration at build time and generates source code for the bean definitions, which is compiled into the jar. Nothing changes until you start with <code>-Dspring.aot.enabled=true</code>, so you can ship the same jar and switch it on per environment. Profiles active at build time are the ones baked in, so build with the profiles production uses.

Example · xml
<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>process-aot</id>
      <goals>
        <goal>process-aot</goal>
      </goals>
    </execution>
  </executions>
</plugin>

<!-- Run with: java -Dspring.aot.enabled=true -jar target/app.jar -->

Deciding at runtime instead of with a build-time condition

With <code>@ConditionalOnProperty</code> on the two gateways, Spring AOT would pick one at build time and setting <code>app.payments.fake</code> in production would do nothing. Registering both and choosing in code keeps the decision at runtime, which works the same with or without AOT. Use this pattern only where a switch really must be made per environment; most conditions are fine fixed at build time.

Example · java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

interface PaymentGateway {
    String charge(long cents);
}

@Component
class StripeLikeGateway implements PaymentGateway {
    public String charge(long cents) {
        return "live-" + cents;
    }
}

@Component
class FakeGateway implements PaymentGateway {
    public String charge(long cents) {
        return "fake-" + cents;
    }
}

@Component
public class PaymentRouter {

    private final PaymentGateway live;
    private final PaymentGateway fake;
    private final boolean useFake;

    PaymentRouter(StripeLikeGateway live, FakeGateway fake,
                  @Value("${app.payments.fake:false}") boolean useFake) {
        this.live = live;
        this.fake = fake;
        this.useFake = useFake;
    }

    public String charge(long cents) {
        // Both beans exist in every build; the property picks one when the app runs.
        return (useFake ? fake : live).charge(cents);
    }
}

Classic CDS and checking that it works

Classic CDS works on Java 17 and later, so it is the option for teams not yet on Java 25. The class-load log shows where each class came from; a high count from the shared objects file proves the archive was used. A count near zero means the classpath or JDK differs from the training run.

Example · bash
# Extract once, then train and write the archive.
java -Djarmode=tools -jar target/app.jar extract --destination application
java -XX:ArchiveClassesAtExit=application/app.jsa \
     -Dspring.context.exit=onRefresh \
     -jar application/app.jar

# Start with the archive and log whether classes come from it.
java -XX:SharedArchiveFile=application/app.jsa \
     -Xlog:class+load:file=classload.log \
     -jar application/app.jar

grep -c 'source: shared objects file' classload.log

Discussion

  • Be the first to comment on this lesson.