Where Spring Boot startup time goes, and how to see it on your own app

Before your application serves its first request, the JVM has to load, parse, verify and link every class it touches. That includes JDK classes, Spring Framework and Spring Boot classes, and classes from every dependency on the classpath. Each class is read from a jar, its bytecode is checked by the verifier, and its symbolic references are resolved. A typical Spring Boot service triggers this for thousands of classes. On top of that, Spring scans for components, evaluates auto-configuration conditions, processes bean definitions, and refreshes the application context. Most of that work creates still more classes, including proxies and generated code.

None of this work depends on your request traffic. It is the same on every start, which makes it cacheable. The JVM redoes it from scratch each time only because nothing tells it otherwise.

Measure your own app

Don't rely on numbers from talks or blog posts. Measure your own service:

  • Time-to-ready: Spring Boot logs a Started ... in X seconds line when the context is ready. Record it across several cold starts, not one.
  • Loaded classes: Run with -Xlog:class+load and count the output lines. This is a rough count of the classes loaded during startup.
  • Bean-level detail (optional): Spring's ApplicationStartup support records startup steps if you need to see where context refresh spends its time.

Illustrative estimate (assumed numbers, not a benchmark):

Class-loading cost ≈ classes loaded × average cost per class.

With 12,000 classes at an assumed 0.1 ms each, the estimate is about 1.2 s. Substitute your own class count and timings to get a figure for your service.

That repeated, deterministic class-loading and linking work is what Class Data Sharing and the JDK AOT cache target.

#!/usr/bin/env bash
# Measure cold-start time-to-ready and loaded-class count for a Spring Boot jar.
# Usage: ./measure-startup.sh target/my-service-0.0.1-SNAPSHOT.jar [runs]
set -euo pipefail

JAR="$1"
RUNS="${2:-5}"

for i in $(seq 1 "$RUNS"); do
  APP_LOG="run-$i-app.log"
  CLASS_LOG="run-$i-classload.log"

  # Send class+load output to its own file so the app log stays readable.
  # server.port=0 picks a free port so repeated runs don't collide.
  java -Xlog:class+load:file="$CLASS_LOG" \
       -jar "$JAR" --server.port=0 > "$APP_LOG" 2>&1 &
  PID=$!

  # Wait (up to 120 s) for Spring Boot's "Started ... in X seconds" line.
  for _ in $(seq 1 240); do
    grep -q 'Started .* in .* seconds' "$APP_LOG" && break
    sleep 0.5
  done

  kill "$PID" && wait "$PID" 2>/dev/null || true

  # Time-to-ready as reported by Spring Boot itself.
  READY=$(grep -o 'Started .* in [0-9.]* seconds' "$APP_LOG" | head -n 1)

  # Each loaded class produces one line with a 'source:' field.
  TOTAL=$(grep -c 'source:' "$CLASS_LOG" || true)
  # Classes served from a CDS archive show 'source: shared objects file'.
  SHARED=$(grep -c 'source: shared objects file' "$CLASS_LOG" || true)

  echo "run $i: $READY | classes loaded: $TOTAL (from CDS archive: $SHARED)"
done

A shell script that starts a Spring Boot jar several times with -Xlog:class+load, records the 'Started ... in X seconds' line, and counts total loaded classes and how many came from a CDS archive, so you get a baseline from your own service.

How Class Data Sharing and the JDK AOT cache work

…JDK AOT cache are designed to do once instead of on every start.

Class Data Sharing (CDS) stores parsed class metadata in an archive file that the JVM memory-maps at startup. Mapped classes skip reading, parsing and most verification, and read-only parts of the archive can be shared between JVM processes on the same host. The JDK includes a default archive for core library classes. AppCDS extends this to your application and dependency classes, which is where a Spring Boot service spends most of its class-loading time.

The dynamic archive workflow has two steps. A training run with -XX:ArchiveClassesAtExit=app.jsa writes every loaded class to the archive when the JVM exits. Production starts then pass -XX:SharedArchiveFile=app.jsa. The classpath must match the training run, and archived classes have to come from JAR files. This is why the next section extracts the Spring Boot jar instead of running the nested fat jar.

The Project Leyden AOT cache goes further. It stores classes already loaded and linked, and it records more of what the training run did. The workflow has three steps:

  1. Record with -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf.
  2. Create with -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot.
  3. Use with -XX:AOTCache=app.aot.

[training run] --> [app.jsa / app.aot] --> [production start: mmap archive, skip repeated work]

Default CDS AppCDS dynamic archive AOT cache
Stores Parsed JDK classes Parsed app and library classes Loaded and linked classes, plus more training-run data
Workflow None, ships with the JDK Train, then run Record, create, then run
Maturity Long-established Established Newer, still evolving
Relative size Small Medium Largest

Flag availability depends on your JDK. Confirm support with java -XX:+PrintFlagsFinal or the documentation for your JDK release.

 TRAINING RUN (once, at build or deploy time, extracted jar)
 +---------------------------------------------+
 | AppCDS:  java -XX:ArchiveClassesAtExit=     |
 |               app.jsa ...                   |
 |                                             |
 | AOT:     java -XX:AOTMode=record            |
 |               -XX:AOTConfiguration=app.aotconf
 |          java -XX:AOTMode=create            |
 |               -XX:AOTConfiguration=app.aotconf
 |               -XX:AOTCache=app.aot          |
 +----------------------+----------------------+
                        |
                        v
 +---------------------------------------------+
 | ARCHIVE / CACHE FILE                        |
 |  app.jsa : parsed app + library classes     |
 |  app.aot : loaded + linked classes,         |
 |            plus more training-run data      |
 |  (default JDK CDS archive ships with JDK)   |
 +----------------------+----------------------+
                        | memory-mapped at startup
                        v
 +---------------------------------------------+
 | PRODUCTION START (same classpath as train)  |
 |  java -XX:SharedArchiveFile=app.jsa ...     |
 |    or java -XX:AOTCache=app.aot ...         |
 |  -> skip read, parse, most verification     |
 |  -> AOT cache also skips repeated linking   |
 +---------------------------------------------+

A training run produces an AppCDS archive (app.jsa) or a Leyden AOT cache (app.aot), which production JVMs memory-map at startup to skip repeated class parsing and verification, and with the AOT cache, linking.

Who should use it, and when to avoid it

A good fit

CDS and the AOT cache suit teams that want faster startup without giving up the standard HotSpot JVM. Reflection, dynamic proxies, agents, and debugging tools all behave as they do today. The gain shows up in three places:

  • Autoscaled services, where a shorter boot time means new replicas take traffic sooner during a load spike.
  • Batch and scheduled jobs, where startup can be a large share of total runtime.
  • Frequently restarted containers, such as rolling deploys or preview environments, where startup cost is paid over and over.

Set expectations clearly. The archive speeds up class loading, linking, and, with the AOT cache, some early initialization. It does little for JIT warm-up, so the time to reach peak throughput stays mostly the same. First-request latency may also improve less than startup, because the first request still runs through code the JIT hasn't compiled yet and through lazily initialized components such as connection pools.

When to avoid it

  • Image pull time dominates cold starts. If the node spends most of a cold start downloading layers, a larger image with an archive in it can cancel out the gain.
  • You need the smallest memory footprint. CDS shares some metadata between processes, but it is still a full JVM. GraalVM native image is the better fit here.
  • Your build environment can't start the app. The training run boots the application. If that requires databases, secrets, or network access your CI doesn't have, you will need stubs or profiles, and that is extra work.
  • The JDK and dependencies change constantly. The archive is tied to the exact JVM and classpath. If you upgrade often, keeping the archive in sync can cost more than the startup time it saves.

Implementing the training run with an extracted Spring Boot jar

Because the archive is tied to that exact JVM and classpath, the first step is to turn the fat jar into a layout the JVM can archive. Run java -Djarmode=tools -jar app.jar extract. This produces a directory holding a thin application jar and a lib/ folder of dependency jars. The thin jar's manifest lists those dependencies on its Class-Path.

The extraction is required. In a Spring Boot fat jar, dependencies are nested jars loaded by Spring Boot's own launcher classloader. CDS only archives classes that the JVM's built-in loaders read from plain files on the classpath, so classes from nested jars are skipped. The classpath also has to match between training and runtime. The JVM records it in the archive and ignores the archive if it differs, so always run the extracted jar from the same path.

Next comes the training run. Start the app with -Dspring.context.exit=onRefresh. The application context fully refreshes, which loads and initializes most of the classes the app will use, and then exits cleanly.

  • Dynamic CDS: add -XX:ArchiveClassesAtExit=app.jsa to the training run. At runtime, pass -XX:SharedArchiveFile=app.jsa.
  • JDK AOT cache: record with -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf. Create the cache with -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot. At runtime, pass -XX:AOTCache=app.aot.

In both cases the runtime command is java <flag> -jar on the extracted jar.

Context refresh also creates every eager bean. Datasources open connections, migration tools run against the schema, message broker clients connect, and remote config clients fetch settings. A build environment usually can't reach these systems, and it shouldn't. Use a dedicated training profile that disables those integrations or points them at embedded stand-ins. Alternatively, mark those beans lazy so they are never created during the training run.

#!/usr/bin/env bash
set -euo pipefail

# 1. Extract the fat jar into a thin jar plus lib/ so the JVM loads classes
#    from plain files on the classpath instead of nested jars.
java -Djarmode=tools -jar target/my-app.jar extract --destination application
cd application   # run training and runtime from this same path

# Training runs use a dedicated profile that turns off datasources,
# migrations, broker clients and remote config. Define it in
# application-training.properties.
TRAIN_OPTS="-Dspring.profiles.active=training -Dspring.context.exit=onRefresh"

# --- Option A: dynamic CDS archive ---
# The context refreshes, then exits. The loaded classes are dumped at exit.
java -XX:ArchiveClassesAtExit=app.jsa $TRAIN_OPTS -jar my-app.jar

# Runtime uses the identical jar and classpath.
java -XX:SharedArchiveFile=app.jsa -jar my-app.jar

# --- Option B: JDK AOT cache (JDK 24+) ---
# Record a training configuration.
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf $TRAIN_OPTS -jar my-app.jar

# Build the cache from the configuration. The app does not serve traffic here.
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar my-app.jar

# Runtime uses the identical jar and classpath.
java -XX:AOTCache=app.aot -jar my-app.jar

This example extracts a Spring Boot fat jar and runs a training start with spring.context.exit=onRefresh under an isolated training profile. It then builds either a dynamic CDS archive or a JDK AOT cache and launches the extracted jar from the same path.

Building the archive into a container image

Run the training step inside the image build so the archive is produced in the same environment that will load it. A multi-stage build keeps this tidy. The first stage copies in the fat jar and uses Spring Boot's jar mode to extract it into a launcher jar plus a lib/ directory. The second stage starts from the same JDK base image that will run in production, copies the extracted layout into the final working directory, and runs the application once with the training profile and the flag that writes the archive. The application exits after context refresh, and the archive is written next to the jars.

The archive records the exact JVM build, the classpath, and several heap-layout decisions. If any of these differ at runtime, the JVM rejects the archive and starts normally without it. It may only log a warning, so the failure is easy to miss. Keep these identical between the training run and runtime:

  • The base image digest, not just the tag
  • The garbage collector
  • Heap settings that affect compressed oops
  • The directory paths

Set these flags once, in a shared environment variable, rather than repeating them in two places.

CI builders often have no network access and no secrets. The training profile must therefore start the application cleanly without either. Never pass credentials as build arguments to make training work, because they can be recovered from the image layers.

The archive increases image size. The JDK AOT cache is noticeably larger than a basic CDS archive because it stores linked and profiled state in addition to parsed classes. The archive changes on every application build, so put it in its own final layer.

Check your launch scripts and health checks. Anything that runs java -jar app.jar against the original fat jar presents a different classpath, and the archive will be silently ignored.

# Pin by digest: the archive is only valid for this exact JVM build.
ARG JDK_IMAGE=registry.example.com/jdk:21@sha256:<pinned-digest>

FROM ${JDK_IMAGE} AS extract
WORKDIR /build
COPY target/app.jar app.jar
# Spring Boot 3.3+ tools jar mode: writes a launcher app.jar plus lib/
RUN java -Djarmode=tools -jar app.jar extract --destination extracted

# Shared base: one definition of the image, flags and path for training and runtime.
FROM ${JDK_IMAGE} AS base
ENV JVM_FLAGS="-XX:+UseG1GC -Xmx512m"
WORKDIR /app

FROM base AS train
COPY --from=extract /build/extracted/lib/ lib/
COPY --from=extract /build/extracted/app.jar app.jar
# The training profile must start with no network access and no secrets.
# The app exits after context refresh; the JVM writes app.jsa on exit.
# (JDK 25+ AOT cache: use -XX:AOTCacheOutput=app.aot here, -XX:AOTCache=app.aot at runtime.)
RUN java $JVM_FLAGS -XX:ArchiveClassesAtExit=app.jsa \
      -Dspring.context.exit=onRefresh \
      -Dspring.profiles.active=training \
      -jar app.jar

FROM base
# Dependencies change least often, so they go first for layer caching.
COPY --from=extract /build/extracted/lib/ lib/
COPY --from=extract /build/extracted/app.jar app.jar
# The archive changes on every build, so it gets its own final layer.
COPY --from=train /app/app.jsa app.jsa
# Run the extracted launcher jar, not the original fat jar. Point launch
# scripts and health checks here too. Shell form lets $JVM_FLAGS expand.
CMD ["sh", "-c", "exec java $JVM_FLAGS -XX:SharedArchiveFile=app.jsa -jar app.jar"]

A multi-stage Dockerfile that extracts a Spring Boot fat jar, runs a training start during the build to produce a CDS archive, and ships that archive in its own final layer, with the base image digest, JVM flags and /app path shared between training and runtime.

Verifying the archive is used and measuring the gain honestly

By default the JVM treats an unusable archive as a hint, not an error. It logs a warning, loads every class from the jar, and starts normally, only slower. Use -Xshare:on in integration tests and in a smoke test against the built image. That flag turns a rejected archive into a startup failure. Keep production on the default so that a bad archive degrades performance rather than causing an outage.

Confirm classes come from the archive

Run once with -Xlog:class+load. Count lines marked source: shared objects file against the total number of classes loaded. Add -Xlog:cds to see why an archive was rejected, such as a classpath mismatch, a different JVM build, or incompatible GC or heap flags. When you use the JDK AOT cache, enable its own log output at startup, which states whether the cache was loaded. A share well below what the training run produced means the runtime classpath has drifted.

Why archives rot

An archive is valid only for the exact JDK build, classpath, and relevant flags it was created with. A base-image patch, a dependency bump, or a new JAVA_TOOL_OPTIONS entry silently invalidates it. Add a CI step to fail the build if the shared-class share falls below a threshold you choose.

Measuring

Time from container start to the first 200 from /actuator/health/readiness. Repeat over many cold starts, with and without the archive, under production CPU and memory limits. Drop the page cache or use fresh nodes between runs. A warm cache flatters both variants, and an unthrottled laptop hides most of the difference.

Illustrative estimate (assumed inputs, not a measurement): Assume an 8 s time-to-ready on 1 vCPU. Assume class loading and linking account for 40% of that, and the archive removes half of that cost. The result would be roughly 8 × 0.4 × 0.5 = 1.6 s saved. Use your own profile to replace each input.

Alternatives, combinations, and impact on cost and engineering time

These techniques are not mutually exclusive. What matters is which ones combine with an archive and which ones change how the application behaves at runtime.

Option Startup effect Behavior change Combines with CDS/AOT cache Maintenance cost
AppCDS / JDK AOT cache Cuts class loading and linking None n/a Training run, rebuild per JDK
Spring AOT on the JVM Removes bean-definition work at runtime Bean conditions fixed at build time Yes Profiles and conditions frozen
Lazy initialization Defers bean creation Errors surface on first request Yes, but less is trained Latency spikes after startup
Trimming auto-configuration Fewer conditions evaluated Only if you remove something in use Yes Ongoing review
Smaller classpath Fewer classes to scan and load Usually none Yes, smaller archive Dependency hygiene
GraalVM native image Largest reduction Closed world, no runtime class loading No Reflection metadata, separate toolchain

Spring AOT combines best with an archive. It removes framework work, and the archive removes JVM work, so their savings stack. Lazy initialization works against training: beans that are never created during the training run never get archived.

The costs sit in the build. Builds take longer and gain a training step that needs a startable app. Any JDK upgrade or classpath change invalidates the archive. When that happens the JVM ignores the archive and starts without it, so a stale archive degrades startup without failing anything. As the JDK AOT cache evolves, flags need revisiting. Images also grow, which affects registry storage and pull time.

Illustrative estimate (assumed numbers, not a benchmark): extra pull time = archive size ÷ pull bandwidth. A 60 MB archive pulled at 100 MB/s adds 0.6 s per cold node. Compare that with the startup saving.

On the other side of the ledger, adopting an archive needs no code rewrites and no reflection configuration. You keep full JVM tooling, including profilers, agents and heap dumps, and the work stays in the pipeline instead of spreading across application teams.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot cds startup time, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.