How a Spring Boot native image works: the closed-world assumption and Spring AOT

A native image is a standalone executable produced ahead of time by GraalVM's native-image tool. The tool starts from the application's entry points and performs static points-to analysis to find every class, method and field that is reachable. Anything it cannot prove reachable is left out. This is the closed-world assumption: no class may appear at runtime that the analysis did not see at build time. Classes can also be initialized during the build, with the resulting objects stored in the image heap. That shortens startup but fixes their state at build time.

This conflicts with how the JVM normally lets frameworks work. A class name passed to Class.forName, a JDK dynamic proxy interface, a package found through classpath scanning, a file loaded with getResource or bytecode generated at runtime is not visible to static analysis. Each must be declared as metadata, or the build must avoid needing it.

Spring AOT runs during the build to provide that metadata. It refreshes the application context far enough to compute the bean definitions, but it does not instantiate the beans. It then generates Java source that registers those definitions directly, so no scanning is needed at runtime. It generates CGLIB proxy classes ahead of time. It also emits reflection, resource, JDK proxy and serialization hints as reachability metadata.

source ──► Spring AOT ──► generated sources + hint files
                                  │
                                  ▼
                       native-image static analysis
                                  │
                                  ▼
                            native binary

AOT evaluates conditions during the build, so the active profiles and the results of @ConditionalOnProperty are fixed into the binary. Changing them at runtime does not add or remove beans. Each configuration variant that changes which beans exist can need its own build.

┌─────────────────────────────────────────────┐
│ Application source and configuration        │
└──────────────────────┬──────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│ Spring AOT processing (during the build)    │
│  - refresh context to bean definitions      │
│  - beans are not instantiated               │
│  - profiles, @ConditionalOnProperty fixed   │
└───────┬──────────────┬───────────────┬──────┘
        ▼              ▼               ▼
 ┌─────────────┐┌─────────────┐┌───────────────┐
 │ Generated   ││ CGLIB proxy ││ Hint files:   │
 │ source for  ││ classes     ││ reflection,   │
 │ bean        ││             ││ resource,     │
 │ definitions ││             ││ JDK proxy,    │
 │             ││             ││ serialization │
 └──────┬──────┘└──────┬──────┘└───────┬───────┘
        └──────────────┼───────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│ native-image points-to static analysis      │
│  - drop anything not provably reachable     │
│  - build-time class init -> image heap      │
└──────────────────────┬──────────────────────┘
                       ▼
┌─────────────────────────────────────────────┐
│ Native binary (closed world)                │
│  profiles and conditions fixed at build     │
└─────────────────────────────────────────────┘

During the build, Spring AOT turns the application context into generated bean-definition source, pre-generated CGLIB proxies and reachability hints, which native-image static analysis then uses to produce a closed-world binary with its profiles and conditions fixed at build time.

Why teams use it: native startup time and memory, measured properly

Native images are used because they start in a fraction of the time and hold less memory while idle. Both claims are easy to overstate, so measure them in three separate phases:

  • Process start: from exec until main runs. Under a native image, this is mostly the work of mapping a pre-initialized heap.
  • Context refresh: until the application context is ready, which Spring logs as the startup time.
  • First-request latency: until the first real response returns. On a JVM, this request often runs in the interpreter, so it is slower than steady state.

Compare against a tuned JVM baseline, not a default java -jar. The baseline should run the same AOT-processed code in JVM mode (-Dspring.aot.enabled=true) with a class data sharing archive. That combination closes part of the startup gap, and the gap that remains is the one you are actually paying a native build to remove.

Idle RSS drops for two reasons. The binary has no JIT compiler threads and no code cache. It also carries less class metadata, because only reachable classes exist and their structures are laid out at build time.

The garbage collector is different too. Common distributions default to a simpler serial collector rather than HotSpot's defaults. Set the heap limit at runtime by passing -Xmx to the binary, then load-test at realistic throughput. Allocation rate and GC behaviour under load decide your container limit. Idle RSS does not.

Illustrative estimate (assumed numbers, not a benchmark): daily saving = cold starts per day × seconds saved per start. Assume 2,000 scale-from-zero starts and 3 s saved per start. That gives 6,000 s, or about 100 minutes of avoided cold-start latency per day. For a service that starts twice a day, the same formula yields 6 seconds, which rarely justifies the build cost.

#!/usr/bin/env bash
# Time-to-first-response and idle RSS: tuned JVM baseline vs native binary.
# Assumes the jar was built with Spring AOT processing (process-aot) and serves /actuator/health.
set -euo pipefail
URL=

# Prepare the JVM baseline: extract the jar, then do a CDS training run that exits after refresh.
java -Djarmode=tools -jar target/app.jar extract --destination jvm
java -XX:ArchiveClassesAtExit=jvm/app.jsa -Dspring.aot.enabled=true \
     -Dspring.context.exit=onRefresh -jar jvm/app.jar

measure() {  # $1 = label, rest = command
  local label=$1; shift
  local t0=$(date +%s%N)
  "$@" > "$label.log" 2>&1 &
  local pid=$!
  # Poll until the first real response; this covers process start + refresh + first request.
  until curl -sf -o /dev/null "$URL"; do sleep 0.01; done
  local t1=$(date +%s%N)
  echo "$label first response after launch: $(( (t1 - t0) / 1000000 )) ms"
  # Context refresh time as Spring reports it.
  grep -m1 'Started .* in ' "$label.log" || true
  sleep 5  # let startup work settle before reading idle RSS
  echo "$label idle RSS: $(ps -o rss= -p "$pid") KiB"
  kill "$pid"; wait "$pid" 2>/dev/null || true
}

# Same heap cap for both, so the comparison is fair.
measure jvm java -Xmx256m -XX:SharedArchiveFile=jvm/app.jsa \
  -Dspring.aot.enabled=true -jar jvm/app.jar
measure native ./target/app -Xmx256m

# Idle RSS is only a starting point: repeat the RSS reading under a realistic
# load test before setting the container memory limit.

A shell harness that compares an AOT-processed, CDS-enabled JVM run against the native binary. Both runs use the same -Xmx. It reports time to first response, Spring's logged refresh time and idle RSS for each.

Implementing reflection hints where Spring AOT cannot infer them

Spring AOT evaluates your bean definitions at build time and generates reflection, resource and proxy metadata for what it can see. It cannot see what is decided at runtime. Typical gaps:

  • Jackson DTOs reached through generics or Object. A handler that returns ResponseEntity<Object> or deserializes into a type taken from a Class<?> variable gives AOT no concrete type to register.
  • Libraries that load classes by name. Class.forName(config.get("impl")) works on the JVM and fails in a closed world.
  • JDBC drivers and plugins wired through string configuration, such as a driver class name in a properties file or a service selected by key.
  • Resources read through non-literal paths, such as getResource("templates/" + tenant + ".json").

For one-off DTOs, annotate a configuration class or controller with @RegisterReflectionForBinding(OrderEvent.class). This registers the constructors, fields and accessors that data binding and Jackson need. For everything else, implement RuntimeHintsRegistrar. In registerHints(RuntimeHints hints, ClassLoader cl), call hints.reflection().registerType(...) with the member categories you need and hints.resources().registerPattern(...) for resource paths. Then activate the registrar with @ImportRuntimeHints(MyHints.class) on a @Configuration class.

Test hints on the JVM, where feedback is fast. Instantiate the registrar, call registerHints on a fresh RuntimeHints, and assert with RuntimeHintsPredicates.reflection().onType(...) or RuntimeHintsPredicates.resource().forResource(...).

This test matters because a missing hint rarely breaks startup. The failure appears later, on one rarely used path: a ClassNotFoundException when a fallback driver loads, or a missing-serializer error on an error response nobody exercised before release.

// NativeHintsConfig.java
@Configuration
@ImportRuntimeHints(NativeHintsConfig.Hints.class)  // activate the registrar for AOT
@RegisterReflectionForBinding(OrderEvent.class)     // DTO only reached via ResponseEntity<Object>
class NativeHintsConfig {

  static class Hints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
      // Loaded by name from application.properties, so AOT never sees it
      hints.reflection().registerType(
          TypeReference.of("com.example.db.FallbackDriver"),
          MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
      // Read via getResource("templates/" + tenant + ".json")
      hints.resources().registerPattern("templates/*.json");
    }
  }
}

// NativeHintsConfigTest.java: runs on the JVM, no native build needed
class NativeHintsConfigTest {

  @Test
  void registersHintsForRuntimeOnlyPaths() {
    RuntimeHints hints = new RuntimeHints();
    new NativeHintsConfig.Hints().registerHints(hints, getClass().getClassLoader());

    assertThat(RuntimeHintsPredicates.reflection()
        .onType(TypeReference.of("com.example.db.FallbackDriver"))
        .withMemberCategory(MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS))
        .accepts(hints);

    // Check a concrete tenant file to confirm the pattern matches it
    assertThat(RuntimeHintsPredicates.resource()
        .forResource("templates/acme.json"))
        .accepts(hints);
  }
}

A RuntimeHintsRegistrar wired with @ImportRuntimeHints covers a driver loaded by name and tenant templates read through computed paths, @RegisterReflectionForBinding covers a DTO returned as Object, and a JVM unit test checks the hints with RuntimeHintsPredicates.

Finding missing metadata systematically and testing the native binary

…reads. Guessing at hints until those paths stop failing wastes days, so collect metadata from real executions instead.

Let the tracing agent record what actually runs

Start the JVM build with the GraalVM tracing agent attached, for example java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/com.example/app -jar app.jar, then drive it with realistic traffic: integration tests, replayed requests, error cases and fallback configurations. The agent writes reflection, resource, proxy and serialization entries for every dynamic access it observes. It only records what executed. A path you never hit produces no metadata, so the agent is only as complete as the traffic you send through it. Review its output before you commit it, because it also captures incidental access from test tooling.

Reuse metadata others have already written

For third-party libraries, rely on the shared community reachability metadata repository. The GraalVM Native Build Tools plugin can apply it automatically. If a library already has an entry there, you do not need to trace it yourself.

Test the binary, not the JVM

Passing JVM tests prove nothing about the native executable. Run the suite compiled ahead of time with ./gradlew nativeTest, or ./mvnw -PnativeTest test on Maven. This catches missing hints in CI rather than in production.

Library compatibility is uneven. Libraries that generate bytecode at runtime, use reflection in unusual ways or depend on JNI may need hand-written metadata or a replacement library. Every dependency upgrade can bring these failures back, so keep native tests in the upgrade pipeline.

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

# Where the agent writes metadata. native-image picks up anything under
# META-INF/native-image on the classpath at build time.
META_DIR=src/main/resources/META-INF/native-image/com.example/app

# 1. Build the regular JVM jar. The agent runs on the JVM, not the binary.
./gradlew bootJar

# 2. Start the app with the tracing agent attached.
#    config-merge-dir adds to existing files instead of overwriting them,
#    so several traffic runs can accumulate into one set of metadata.
java -agentlib:native-image-agent=config-merge-dir="$META_DIR" \
  -jar build/libs/app-0.0.1-SNAPSHOT.jar &
APP_PID=$!
sleep 15  # crude wait for startup; poll a health endpoint in real scripts

# 3. Exercise realistic paths. Anything not hit here gets no metadata.
curl -fsS  > /dev/null
curl -sS -X POST -H 'Content-Type: application/json' \
  -d '{"sku":""}'  > /dev/null  # validation error path

# 4. Stop the app cleanly so the agent flushes its output on exit.
kill -TERM "$APP_PID"
wait "$APP_PID" || true

# 5. Review what was recorded before committing it.
git diff --stat -- "$META_DIR"

# 6. Compile the test suite ahead of time and run it as a native binary.
./gradlew nativeTest
# Maven equivalent with the Spring Boot parent:
# ./mvnw -PnativeTest test

Collecting reachability metadata by running the JVM build under the GraalVM tracing agent with realistic traffic, then running the test suite as a native binary with nativeTest.

Impact on development cost: builds, CI and platform-specific binaries

Native tests are expensive, and the reason is the compiler. Building a native image runs whole-program static analysis over your code and every dependency, then compiles everything ahead of time. That takes minutes rather than seconds and needs several gigabytes of memory. CI runners sized for JVM builds may run out of memory or slow down sharply.

Because of that cost, developers iterate on the JVM and run native builds less often. This widens the gap between what they test and what ships. A missing reflection hint can pass every JVM test and fail only in the native binary. Run native builds and tests on every merge to the main branch, not only at release time.

Building for the target platform

A native binary is tied to one OS and CPU architecture, so you must build on the target platform or in a matching container. Buildpacks (./gradlew bootBuildImage with the GraalVM Native Build Tools plugin applied) or a multi-stage Dockerfile handle this. In a multi-stage build, a GraalVM builder stage runs ./mvnw -Pnative native:compile, and the binary is copied into a minimal runtime image.

Multi-architecture publishing is harder than with a portable JAR. Building amd64 and arm64 images means either cross-architecture emulation, which is very slow for a compiler this heavy, or native runners for each architecture plus a manifest-list step.

Caching

  • Cache the Maven or Gradle dependency directories between builds.
  • Order Dockerfile layers so dependency resolution sits above source copying.
  • Persist the Buildpacks cache volume.
  • Run native builds only on branches that will actually ship.

Operational differences: throughput, latency and observability

A native binary is fully optimized at build time, before it has seen real traffic. A HotSpot JVM keeps profiling while it runs. It inlines hot paths, speculates on the types it actually observes and recompiles when its assumptions change. For CPU-heavy services, this means a warmed-up JVM can reach higher steady-state throughput. Under sustained load, its tail latency can also be better than the native build's. Some GraalVM distributions offer profile-guided optimization, which narrows the gap by feeding a recorded profile into the build. The result is only as good as that profile, though, so it must come from representative traffic. Benchmark your own workload at realistic concurrency, and compare p99 latency, not only means.

Diagnostics change more than performance does. A native image has no JVMTI, so you cannot attach an agent to a running process. Tools that rely on bytecode instrumentation at load time either do not work or work only in restricted forms. JFR, heap dumps and debugging have some native support, but each typically has to be enabled at build time and behaves differently from its JVM counterpart. Verify support for each tool against the distribution you ship.

Some approaches carry over unchanged:

  • Micrometer metrics
  • OpenTelemetry through library instrumentation rather than the Java agent
  • Structured logs
Capability JVM Native image
Steady-state throughput Profile-guided JIT, strong after warm-up Fixed at build time; PGO may help
Dynamic agent attach (JVMTI) Supported Not available
Java agents Broadly supported Limited or unsupported
JFR Full support Partial, build-time opt-in
Heap dumps Standard tooling Limited, different workflow
Debugging JDWP, IDE attach Native debuggers with debug info
Micrometer / OTel library instrumentation / logs Supported Supported

Who should use it, when to avoid it, and the alternatives

Good fits

  • Scale-to-zero services and serverless functions. Cold starts are on the request path, so millisecond startup is directly visible to users.
  • CLIs and short-lived jobs. The process exits before JIT warm-up would pay off, so peak throughput is irrelevant.
  • Dense multi-tenant deployments. Lower resident memory per instance means more instances per node, provided the dependency tree is small enough to keep metadata manageable.

Poor fits

  • Long-running, CPU-bound services, where the JIT's profile-guided optimisation usually wins over time.
  • Applications built on runtime classpath dynamism, such as plugin loading, runtime bytecode generation or scanning for classes unknown at build time.
  • One artifact, many environments. Property values still bind at runtime, but @Profile and @ConditionalOnProperty decisions are fixed during AOT processing. Bean wiring therefore cannot change per environment.
  • Teams without CI capacity to build and test native binaries for every target platform.

Alternatives

Spring AOT on the JVM removes much of the reflective context setup and keeps the JIT. Class data sharing archives preloaded classes so they are not parsed on every start. Checkpoint/restore approaches snapshot a warmed-up JVM and resume from it. These options often recover a large share of the startup gain with fewer constraints, and you can combine them.

Option Startup Memory Build cost Flexibility Tooling
Native image Fastest Lowest High Low Different, narrower
Spring AOT on JVM Moderately faster Unchanged Low High Standard JVM
Class data sharing Faster Slightly lower Low High Standard JVM
Checkpoint/restore Very fast JVM-level Medium Medium (snapshot state) Standard JVM, OS-dependent

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 graalvm native image, 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.