A Spring Boot App With Half the Startup Time
Learn how Project Leyden and AOT caching can cut Spring Boot startup time in half, improving Kubernetes scaling and application responsiveness.
Join the DZone community and get the full member experience.
Join For FreeThe MovieManager project has been updated to use JDK 25 and the AOT cache from project Leyden. Project Leyden is part of the OpenJDK project and provides cached linking and cached performance statistics. That means the time spent linking at startup is moved to build time, and the statistics are created during a test run at build time as well.
Because of that, the JVM loads the needed classes already linked and starts compiling the hot code paths immediately. The MovieManager application starts in less than half the time with these optimizations without any code changes.
All these advantages come with preconditions:
- Exactly the same JVM version at build time, training time, and run time
- The same OS(Linux is used here) and libc at all steps -> (No Alpine-based Docker Images)
- Same CPU architecture, for example, AMD64 or ARM64
The steps to use Project Leyden:
- Build the Spring Boot Application
- Extract the Spring Boot Application
- Do a training run with the extracted Application to create the AOT cache
- Create the Docker Image with the extracted Application and the AOT cache
Building and Training the Application
The first step is to build the Spring Boot JAR. The MovieManager project has an integrated build that builds the Angular frontend and the Spring Boot backend with this Maven command:
./mvnw clean install -Ddocker=true -Dnpm.test.script=test-chromium
Project Leyden does not support Spring Boot Jars. The Jar has to be extracted to help Project Leyden find the used library jars of the project. To do that, this command needs to be used:
java -Djarmode=tools -jar backend/target/moviemanager-backend-0.0.1-SNAPSHOT.jar extract --destination extracted
The result is the directory ‘extracted’ with the application jar and a sub-directory ‘lib’ that contains the used libraries.
The second step is to create the AOT cache. To do that, the application has to run in production conditions. That means using a real PostgreSQL database with the database driver. That enables the JDK to record all the needed classes of the project and to create realistic performance statistics for the code compilation. To do this, a PostgreSQL database has to be started(done here in a Docker container), and the Application has to do the full startup. These commands are needed:
docker pull postgres:13
docker run --name local-postgres -e POSTGRES_PASSWORD=sven1 -e POSTGRES_USER=sven1 -e POSTGRES_DB=movies -p 5432:5432 -d postgres
java -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UseCompressedOops -XX:+UseCompactObjectHeaders -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=64m -XX:+UseStringDeduplication -Xlog:aot -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -Djava.security.egd=file:/dev/./urandom -jar extracted/moviemanager-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
The Java command runs the application with the parameter ‘-Dspring.context.exit=onRefresh’ that makes Spring Boot do the full startup and exit then. The parameters ‘-Xlog:aot -XX:AOTCacheOutput=app.aot’ enable the logging of the AOT process and the creation of the ‘app.aot’ that is the AOT cache.
The AOT cache contains everything that is needed for a fast startup of the application. If the AOT cache should also contain information to improve production performance, it would have to start up and process realistic production requests. That is beyond the scope of this article.
The third step is to test the new application setup:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UseCompressedOops -XX:+UseCompactObjectHeaders -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=64m -XX:+UseStringDeduplication -Xlog:class+path=info -XX:AOTCache=app.aot -Xlog:aot -Djava.security.egd=file:/dev/./urandom -jar extracted/moviemanager-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
The start-up time of the new setup with the AOT cache can be compared to the start-up time of the Spring Boot jar. On a medium-powered laptop, the times are:
- 9 seconds for the Spring Boot Jar
- 3.5 seconds for the new setup with the AOT cache
Creating a Docker Image
To use the application in production, it needs to be packaged into a Docker image. The Docker image needs to contain the extracted application setup and the AOT cache. The base image needs to have the exact same JDK version, OS, and the same libc. That means small base images like Alpine cannot be used. The created Image can not be small because it contains 180 MB of AOT cache and a larger base image. This can be done with this Dockerfile:
FROM eclipse-temurin:25.0.3_9-jdk-jammy
WORKDIR /application
ARG JAR_FILE=extracted/*.jar
COPY ${JAR_FILE} moviemanager-backend-0.0.1-SNAPSHOT.jar
COPY extracted/ ./
COPY app.aot app.aot
ENV JAVA_OPTS="-XX:+UseG1GC \
-XX:MaxGCPauseMillis=50 \
-XX:+UseCompressedOops \
-XX:+UseCompactObjectHeaders \
-XX:+ExitOnOutOfMemoryError \
-XX:MaxDirectMemorySize=64m \
-XX:+UseStringDeduplication"
ENTRYPOINT exec java $JAVA_OPTS -XX:+AOTClassLinking \
-XX:AOTCache=app.aot \
-Xlog:class+path=info \
-Djava.security.egd=file:/dev/./urandom \
-jar moviemanager-backend-0.0.1-SNAPSHOT.jar
It copies the new application setup in the image and adds the AOT cache. The name of the application jar is in the AOT cache and has to be exactly the same as during the creation of the AOT cache. The ‘JAVA_OPTS’ also have to be the same. If the JDK version in the build environment changes, the version of the base image has to be adjusted accordingly. The parameter ‘-Xlog:class+path=info’ makes analyzing AOT problems much easier. The Docker container size is 705 MB. That makes the container about double the size of a Docker container with a Spring Boot Jar and an Alpine-based JDK image.
Creating a Build Pipeline
Creating Docker images for an application by hand is unsustainable in a production environment. A build pipeline is needed. The MovieManager project is hosted on GitHub; because of that, the project uses a GitHub Workflow as a build pipeline. The complete code for the build pipeline is in the script. The steps of the GitHub pipeline can be recreated in other environments too.
The first step is to set up the PostgreSQL database service to be used in this build:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
env:
POSTGRES_URL: jdbc:postgresql://localhost:5432/movies
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: sven1
POSTGRES_PASSWORD: sven1
POSTGRES_DB: movies
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U sven1 -d movies"
--health-interval=10s
--health-timeout=5s
--health-retries=5
The commands set up the PostgreSQL service in the build pipeline with user, password, dbname, and dbport. The ‘POSTGRES_URL’ is set to access the database later.
The second step is to check out the project:
steps:
- name: Checkout repository
uses: actions/checkout@v3
It checks out the contents of the master branch.
The third step is to provide the JDK:
- name: Setup Java JDK
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 25
JDK version 25 is the minimum to use the project Leyden with linking and performance statistics.
The fourth step builds the Spring Boot Jar:
- name: Build with Maven
if: matrix.language == 'java'
run: |
./mvnw clean install -Ddocker=true
That is the Maven command to build the project.
The fifth step is to find the Spring Boot jar:
- name: Find fat jar
if: matrix.language == 'java'
id: jar
run: |
JAR_PATH=$(find ./backend/target -type f -name "*SNAPSHOT.jar" | head -n 1)
echo "Found JAR: $JAR_PATH"
echo "jar=$JAR_PATH" >> $GITHUB_OUTPUT
The sixth step is to extract the Spring Boot jar:
- name: Unpack fat jar
if: matrix.language == 'java'
id: UNPACK
run: |
java -Djarmode=tools -jar ${{ steps.jar.outputs.jar }} extract --destination extracted
EXTRACTED_PATH=$(find . -type d -name "extracted" | head -n 1)
echo "Found directory: $EXTRACTED_PATH"
echo "extracted=$EXTRACTED_PATH" >> $GITHUB_OUTPUT
The seventh step is to get the name of the extracted application jar:
- name: find extracted jar
if: matrix.language == 'java'
id: EXTRACT
run: |
EXTRACTED_JAR=$(find "${{ steps.UNPACK.outputs.extracted }}" -type f -name "*.jar" | head -n 1)
EXTRACTED_JAR=${EXTRACTED_JAR#./}
echo "Found extracted JAR: $EXTRACTED_JAR"
echo "extracted=$EXTRACTED_JAR" >> $GITHUB_OUTPUT
The eighth step is to create the AOT cache:
- name: Create AOT cache
if: matrix.language == 'java'
id: AOT
env:
JAVA_TOOL_OPTIONS: ""
_JAVA_OPTIONS: ""
JDK_JAVA_OPTIONS: ""
run: |
EXTRACTED_JAR="${{ steps.EXTRACT.outputs.extracted }}"
echo "jar=$EXTRACTED_JAR"
echo "JAVA_TOOL_OPTIONS=$JAVA_TOOL_OPTIONS"
echo "_JAVA_OPTIONS=$_JAVA_OPTIONS"
echo "JDK_JAVA_OPTIONS=$JDK_JAVA_OPTIONS"
JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UseCompressedOops -XX:+UseCompactObjectHeaders -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=64m -XX:+UseStringDeduplication"
java $JAVA_OPTS \
-XX:+AOTClassLinking \
-XX:AOTCacheOutput=app.aot \
-Xlog:aot \
-Dspring.context.exit=onRefresh \
-Dspring.datasource.url="${{ env.POSTGRES_URL }}" \
-Dspring.profiles.active=prod \
-jar "$EXTRACTED_JAR" || echo "AOT Training finished with exit code $?"
This runs the application startup with the PostgreSQL database to create the AOT cache.
The ninth step shows the exact JDK version used in the AOT cache generation:
- name: Show Jdk version
if: matrix.language == 'java'
id: JDK
run: |
JDK_VERSION=$(java -version 2>&1)
VERSION=$(echo "$JDK_VERSION" | sed -n 's/.*build \([^[:space:]]*\)-LTS.*/\1/p')
echo "JDK_VERSION=$JDK_VERSION"
echo "VERSION=$VERSION"
MY_VERSION="jdk=$VERSION"
In case of problems with using the AOT cache. The first check is the version shown here against the JDK version in the Docker base image.
The tenth step creates the Docker image:
- name: Build and push
uses: docker/build-push-action@v6
if: matrix.language == 'java'
with:
context: .
file: ./Dockerfile
build-args: |
JAR_PATH=${{ steps.EXTRACT.outputs.extracted }}
LIB_PATH=${{ steps.aot.outputs.extracted }}
push: false
tags: angular2guy/moviemanager:latest
This step can push the Docker image to an image repository.
Conclusion
The results of using the AOT cache of project Leyden are impressive. Cutting the startup time in half without any code change is amazing. The effort to create the AOT cache and set up the new application is a one-time investment. The impact of the larger Docker Images is low. That makes scaling application instances in Kubernetes clusters up and down much more flexible because the time to the availability of a new application instance is much lower. In Kubernetes environments with scaling of application instances, the AOT cache is a significant step forward and should be used.
For serverless applications 3.5 seconds startup time is too slow. Their project, CrAC or Native Image, would be needed. Project CrAC needs code changes and testing. Native Image has the closed-world assumption, which makes it hard to prove that larger applications work correctly. Alternatives are Node.js with Nest.js and TypeScript, or Go with its libraries.
Project Leyden is not finished in JDK 25. There are plans to add compiled code to the AOT cache in the future. The JVM is an impressive piece of technology that is still improving further.
Opinions expressed by DZone contributors are their own.
Comments