DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Five Java Developer Must-Haves for Ultra-Fast Startup Solutions
  • A Maven Story
  • Keep Your Application Secrets Secret
  • Providing Enum Consistency Between Application and Data

Trending

  • Lambda-Driven API Design: Building Composable Node.js Endpoints With Functional Primitives
  • Mastering Fluent Bit: Beginners' Guide for Contributing to our CNCF Project Docs
  • Why AI-Generated Code Breaks Your Testing Assumptions
  • Spring Boot Done Right: Lessons From a 400-Module Codebase
  1. DZone
  2. Coding
  3. Java
  4. A Spring Boot App With Half the Startup Time

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.

By 
Sven Loesekann user avatar
Sven Loesekann
·
Jun. 12, 26 · Tutorial
Likes (1)
Comment
Save
Tweet
Share
162 Views

Join the DZone community and get the full member experience.

Join For Free

The 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:

Shell
 
./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:

Shell
 
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:

Shell
 
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:

Shell
 
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:

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:

YAML
 
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:

YAML
 
    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:

YAML
 
    - 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:

YAML
 
    - 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:

YAML
 
    - 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:

YAML
 
    - 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:

YAML
 
    - 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:

YAML
 
- 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:

YAML
 
    - 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:

YAML
 
    - 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.

Java Development Kit application Java (programming language) Spring Boot

Opinions expressed by DZone contributors are their own.

Related

  • Five Java Developer Must-Haves for Ultra-Fast Startup Solutions
  • A Maven Story
  • Keep Your Application Secrets Secret
  • Providing Enum Consistency Between Application and Data

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook