In this blog post, we take a look at how to improve the startup time of Spring Boot applications. We compare several strategies, including AOT cache (Leyden), Spring AOT, and CRaC checkpoint/restore, using the Spring Petclinic sample application as a benchmark.
Reducing the startup time of Spring Boot applications can be beneficial in several scenarios, such as when you need to scale up and down frequently, or when you want to reduce the time to serve the first request after a deployment.
For this benchmark Petclinic is built with and runs on the Azul Zulu OpenJDK 26 JVM. The benchmark measures the wall-clock time from docker run until the first successful HTTP 200 response from the application.
Baseline ¶
We start with a baseline executable fat jar built with Spring Boot and launched with java -jar. The Dockerfile uses a multistage build to clone the Petclinic source code, build the jar with Maven, and then copy the jar into the runtime image.
RUN chmod +x mvnw && ./mvnw -q -DskipTests package
RUN cp target/spring-petclinic-*.jar /workspace/application.jar
FROM ${RUNTIME_IMAGE}
WORKDIR /application
COPY --from=build /workspace/application.jar /application/application.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/application/application.jar"]
This is the simplest packaging model. The fat jar contains all the application classes and dependencies in a single archive. Running directly from a nested archive has some overhead because the JVM needs to unpack the jar and load classes from it. This is why this approach has the longest startup time among the strategies we will compare.
In addition to the startup overhead, the fat jar layout is not the most container-friendly because it does not take advantage of Docker layer caching. Any change to the application code or dependencies will invalidate the entire jar layer, leading to longer build times and less efficient use of Docker's caching mechanism.
Startup time: 6280.90 ms (median)
Extract ¶
To solve the layering and nested archive issues, Spring Boot includes a jarmode=tools option that can be used to extract the jar into a more Docker-friendly layout.
The application first builds the fat jar as before, but then runs it with -Djarmode=tools and the extract command to unpack the layers into separate directories. The runtime image then copies those directories instead of the single jar.
RUN chmod +x mvnw && ./mvnw -q -DskipTests package
RUN cp target/spring-petclinic-*.jar /workspace/application.jar
RUN java -Djarmode=tools -jar /workspace/application.jar extract --layers --destination /workspace/extracted
FROM ${RUNTIME_IMAGE}
WORKDIR /application
COPY --from=build /workspace/extracted/dependencies/ ./
COPY --from=build /workspace/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "application.jar"]
In addition to being more Docker-friendly, this layout also removes some of the overhead of running directly from a nested archive, leading to an improvement in startup time compared to the baseline fat jar. In this example and setup the median startup time decreases by about 15.7%.
Startup time: 5295.53 ms (median)
Read more about the layering feature in the Spring Boot documentation.
AOT cache ¶
Project Leyden is an OpenJDK project that has the primary goal of improving the startup time, time to peak performance, and footprint of Java programs. One of the key features of Project Leyden is AOT cache, which allows the JVM to cache class data and method profiles to speed up subsequent startups. The AOT cache was introduced in Java 24 with JEP 483 and has been further enhanced in Java 25 with JEP 514: Ahead-of-Time Command-Line Ergonomics which makes it easier to create and use AOT caches. Another improvement was introduced in Java 26 with JEP 516: Ahead-of-Time Object Caching with Any GC, which allows the AOT cache to be used with any garbage collector.
Like in the previous example, the application is first built as a fat jar and then extracted to the layered layout. Then a training run is performed with -XX:AOTCacheOutput=app.aot to generate the AOT cache file. Spring Boot supports this kind of training runs with the -Dspring.context.exit=onRefresh option, which causes the application to exit after the context is initialized and refreshed. The refresh phase happens after the application context is fully initialized but before the application lifecycle starts, so for example no database connections are opened yet.
The runtime image then copies the generated app.aot file and launches the application with -XX:AOTCache=app.aot to take advantage of the cached class data and method profiles.
RUN chmod +x mvnw && ./mvnw -q -DskipTests package
RUN cp target/spring-petclinic-*.jar /workspace/application.jar
RUN java -Djarmode=tools -jar /workspace/application.jar extract --layers --destination /workspace/extracted
RUN cp -R /workspace/extracted/dependencies/. /workspace/runtime-root/ \
&& cp -R /workspace/extracted/spring-boot-loader/. /workspace/runtime-root/ \
&& cp -R /workspace/extracted/snapshot-dependencies/. /workspace/runtime-root/ \
&& cp -R /workspace/extracted/application/. /workspace/runtime-root/
WORKDIR /workspace/runtime-root
RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar
FROM ${RUNTIME_IMAGE}
WORKDIR /application
COPY --from=build /workspace/extracted/dependencies/ ./
COPY --from=build /workspace/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/extracted/application/ ./
COPY --from=build /workspace/runtime-root/app.aot /application/app.aot
EXPOSE 8080
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "application.jar"]
With this application and setup, the AOT cache provides a significant improvement in startup time compared to the previous step, with a median startup time decrease of about 49.0%. This is because the JVM can skip some of the class loading and initialization work during startup by using the cached class data and method profiles.
Startup time: 2698.56 ms (median)
For more information check out the Spring Boot documentation on AOT cache.
Another JEP from Project Leyden introduced in Java 25 is JEP 515: Ahead-of-Time Method Profiling. This JEP is not targeted at improving startup time, but to reduce the warmup time. The way the JVM works is that it starts with an interpreted mode and then, as the application runs, it identifies hot methods and compiles them to native code for better performance. JEP 515 allows to profile the method execution during a training run and then use that profile to identify hot methods. The JVM can then use that profile to identify hot methods earlier in the run and compile them sooner. The tricky part is that the training run needs to be representative of the actual application usage, otherwise the profile may not be accurate and the performance improvement may not be as expected. So a simple training run with -Dspring.context.exit=onRefresh is not enough to take advantage of this JEP, and a more realistic training run with representative traffic is needed to see the benefits of method profiling.
Spring AOT ¶
To reduce the startup time even further, Spring introduced Spring AOT, which is a build-time optimization that generates code to reduce the amount of work that needs to be done at runtime. Spring traditionally relies on reflection and dynamic class loading to provide features like dependency injection, auto-configuration, and runtime proxies. Spring AOT analyzes the application at build time and generates code that can be executed directly by the JVM without the need for reflection or dynamic class loading.
Check out this Spring Boot documentation on AOT processing for more details on how Spring AOT works. Spring AOT is a prerequisite for building a GraalVM native image, but it can also be used to improve the startup time of a traditional JVM application without going all the way to a native image.
To build a Spring AOT-enabled application, the native profile needs to be activated during the build. The native profile tells the Spring Boot plugin to generate the AOT code during the build. Spring AOT can be combined with the AOT cache because they target different layers of the stack. Spring AOT generates code to reduce the amount of work that needs to be done at runtime, while AOT cache allows the JVM to cache class data and method profiles to speed up subsequent startups.
To enable Spring AOT the runtime launch command needs to include -Dspring.aot.enabled=true to tell Spring Boot to use the generated AOT code. The training run also needs to include that option to ensure that the AOT code is used during the training and the generated AOT cache file is optimized for the Spring AOT code.
RUN chmod +x mvnw && ./mvnw -q -Pnative -DskipTests package
RUN cp target/spring-petclinic-*.jar /workspace/application.jar
RUN java -Djarmode=tools -jar /workspace/application.jar extract --layers --destination /workspace/extracted
RUN cp -R /workspace/extracted/dependencies/. /workspace/runtime-root/ \
&& cp -R /workspace/extracted/spring-boot-loader/. /workspace/runtime-root/ \
&& cp -R /workspace/extracted/snapshot-dependencies/. /workspace/runtime-root/ \
&& cp -R /workspace/extracted/application/. /workspace/runtime-root/
WORKDIR /workspace/runtime-root
RUN java -Dspring.aot.enabled=true -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar
FROM ${RUNTIME_IMAGE}
WORKDIR /application
COPY --from=build /workspace/extracted/dependencies/ ./
COPY --from=build /workspace/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/extracted/application/ ./
COPY --from=build /workspace/runtime-root/app.aot /application/app.aot
EXPOSE 8080
ENTRYPOINT ["java", "-Dspring.aot.enabled=true", "-XX:AOTCache=app.aot", "-jar", "application.jar"]
spring-aot-aot-cache.Dockerfile
The startup time reduces by a further 13.8% compared to the previous step.
Startup time: 2326.35 ms (median)
The Spring AOT engine is designed to handle as many use cases as possible, with no code change in applications. However, keep in mind that some optimizations are made at build time based on a static definition of the beans. If your application relies on dynamic bean definitions or runtime classpath scanning, you may need to make some adjustments to ensure that the AOT engine can generate the necessary code. The Spring documentation lists some best practices to follow to get the most out of Spring AOT.
CRaC ¶
CRaC (Coordinated Restore at Checkpoint) is a technology that saves a snapshot of a running JVM application and allows it to be restored later.
There are some limitations to be aware of when using CRaC. The checkpoint and restore process is not transparent to the application, so the application needs to be designed to be checkpoint/restore-aware. For example, the application needs to be able to release any resources it holds during the checkpoint and re-acquire them during the restore. CRaC is also not supported on all platforms; currently it is only supported on Linux and it requires a special JVM build with CRaC support.
Fortunately Azul provides OpenJDK builds with CRaC support, so using CRaC is as simple as using the azul/zulu-openjdk:26-jre-crac-latest image instead of the standard runtime image.
The Spring Framework also has built-in support for CRaC and can automatically create a checkpoint during the application context refresh phase when the -Dspring.context.checkpoint=onRefresh option is set. The only requirement is to include the org.crac:crac dependency in the application. This is a library that enables the application to interact with the CRaC API and perform the checkpoint and restore operations. The Petclinic sample does not include this dependency by default, so the Docker build adds it to the pom.xml before building the application. For more information on Spring's CRaC support, check out the Spring Framework documentation on CRaC.
The application is built and extracted in the normal way, there is no special compile flag needed. In this example we ignore the AOT cache and Spring AOT optimizations, because CRaC will create a snapshot of a running JVM application and we don't need to optimize the startup path at all.
RUN awk ' \
/<dependencies>/ && !inserted { \
print; \
print " <dependency>"; \
print " <groupId>org.crac</groupId>"; \
print " <artifactId>crac</artifactId>"; \
print " <version>1.5.0</version>"; \
print " </dependency>"; \
inserted = 1; \
next; \
} \
{ print } \
' pom.xml > pom.xml.tmp && mv pom.xml.tmp pom.xml
RUN chmod +x mvnw && ./mvnw -q -DskipTests package
RUN cp target/spring-petclinic-*.jar /workspace/application.jar
RUN java -Djarmode=tools -jar /workspace/application.jar extract --layers --destination /workspace/extracted
FROM ${CRAC_IMAGE}
WORKDIR /application
COPY --from=build /workspace/extracted/dependencies/ ./
COPY --from=build /workspace/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/extracted/application/ ./
RUN mkdir -p /checkpoint
EXPOSE 8080
ENTRYPOINT ["java", "-XX:CRaCMinPid=128", "-XX:CRaCCheckpointTo=/checkpoint", "-Dspring.context.checkpoint=onRefresh", "-jar", "application.jar"]
To create a CRaC checkpoint, the Dockerfile builds a special checkpoint image whose default entrypoint runs the application with -XX:CRaCCheckpointTo=/checkpoint, -XX:CRaCMinPid=128, and -Dspring.context.checkpoint=onRefresh.
You then need to run this image once with --privileged so the JVM can write the checkpoint and exit. Finally, the stopped container is committed as the final benchmark image, changing the entrypoint to ENTRYPOINT ["java","-XX:CRaCRestoreFrom=/checkpoint"] to restore from the checkpoint on startup. In this example this is all done with a Go helper that uses the Docker Go SDK to perform the privileged checkpoint run and docker commit step, but you could also do it manually with docker run and docker commit commands.
Running this docker image, the startup time is significantly reduced compared to the previous strategies, with a median startup time decrease of about 56.6% compared to the Spring AOT plus AOT cache variant, and a decrease of about 83.9% compared to the baseline fat jar. This is because CRaC allows the application to skip the entire startup process and restore directly from a checkpointed state.
Startup time: 1010.33 ms (median)
This can also reduce the warmup time until the hot code is compiled to machine code. But for that you need to make sure that the checkpoint is taken after the application has warmed up, so the checkpoint needs to be taken at a later point in the application lifecycle, for example after the application has started and is serving traffic. In this example we take the checkpoint at the Spring context refresh boundary, which is before the application lifecycle starts.
Benchmark summary ¶
Here is the summary of the benchmark results for all the strategies mentioned in this post. I ran the benchmark on Debian 13 on a Hetzner ccx23 instance, and the metric is the wall-clock time from docker run until the first successful HTTP 200 response from /owners/1. Each variant was run 15 times.
| Variant | Image Size | Median | p95 | Mean | Min | Max |
|---|---|---|---|---|---|---|
| baseline | 180.79 MB | 6280.90 ms | 6636.85 ms | 6274.85 ms | 6034.50 ms | 6636.85 ms |
| extract | 180.62 MB | 5295.53 ms | 5545.78 ms | 5298.58 ms | 5059.56 ms | 5545.78 ms |
| aot-cache | 211.23 MB | 2698.56 ms | 2791.18 ms | 2692.39 ms | 2559.09 ms | 2791.18 ms |
| spring-aot-aot-cache | 209.26 MB | 2326.35 ms | 2392.98 ms | 2302.29 ms | 2181.15 ms | 2392.98 ms |
| crac | 208.34 MB | 1010.33 ms | 1155.85 ms | 1013.24 ms | 916.23 ms | 1155.85 ms |
Wrapping up ¶
We have seen that with a few optimizations built into the JVM and Spring Boot, we can significantly reduce the startup time of a Spring Boot application.
If you simply deploy your application and leave it running, startup time may not be a big concern, but if you need to scale up and down frequently, or if you want to reduce the time to serve the first request after a deployment, then these optimizations can be very useful. Some optimizations only need a small change to the Dockerfile, like using the extract option or enabling AOT cache, while others may require more changes to the application code or build process, like using Spring AOT or CRaC.
Also worth mentioning is the GraalVM native image strategy, that I left out for this blog post, because I wanted to focus on JVM optimizations. But definitely worth checking out if reducing the startup time is very important for your use case and you are ok with the tradeoffs of native images. See the Spring Boot documentation on native images for more information on that topic.