What a native image is and how Spring AOT produces one

A GraalVM native image is a standalone executable produced ahead of time from your application's bytecode. The native-image builder starts at the entry point and runs a points-to analysis to find every class, method and field that can be reached. It works under a closed-world assumption: the builder must know all code at build time. Anything it cannot see, such as a class loaded by name or a method called through reflection, is left out unless you declare it.

Reachable code is compiled to machine code. The builder can also run selected static initializers at build time and store the objects they create in an image heap, a snapshot that is mapped into memory when the process starts. Startup is fast because there is nothing left to do at launch. No bytecode is interpreted. No JIT has to profile hot paths and recompile them. Class loading, verification and linking already happened during the build. The process maps the binary and the heap snapshot and starts running.

Spring Boot depends on runtime reflection, classpath scanning and conditional configuration, so it needs extra work to fit a closed world. Spring AOT processing does that work. During the build it starts the application context without instantiating beans and evaluates @Conditional annotations, active profiles and configuration classes. It then writes Java source that registers the resulting bean definitions directly, which replaces scanning and reflective introspection. It also emits reachability hints, which are reflection, resource, proxy and serialization metadata for the code that still needs it.

The consequence is that conditions and profiles are fixed when you build. If you activate a different profile at runtime, the image will not gain beans that were excluded during AOT processing.

 +----------------------+      +---------------------------------------+
 | App bytecode +       |      | Spring AOT processing                 |
 | Spring config        |----->|  - refresh context, no bean instances |
 | (@Configuration,     |      |  - evaluate @Conditional, profiles    |
 |  @Conditional, ...)  |      |  - conditions/profiles fixed here     |
 +----------------------+      +---------------------------------------+
                                        v                        v
                            +----------------------+  +----------------------+
                            | Generated Java src   |  | Reachability hints   |
                            | (bean definitions)   |  | reflection, resource |
                            +----------------------+  | proxy, serialization |
                                        |             +----------------------+
                                        v                        v
 +-------------------------------------------------------------------+
 | native-image builder (closed-world assumption)                    |
 |  - points-to analysis from entry point: keep reachable code only  |
 |  - compile reachable methods to machine code                      |
 |  - run selected static initializers, snapshot objects to heap     |
 +-------------------------------------------------------------------+
                 v                                  v
 +------------------------------+   +--------------------------------+
 | machine code                 |   | image heap snapshot            |
 +------------------------------+   +--------------------------------+
 --- build time ----------- standalone executable ----------- run time ---
                                   v
 +-------------------------------------------------------------------+
 | process maps binary + image heap, starts running                  |
 | no bytecode interpreter, no JIT warm-up                           |
 | no class loading, verification or linking at launch               |
 +-------------------------------------------------------------------+

Spring AOT fixes conditions and profiles at build time and emits bean definition code and reachability hints. The native-image builder then compiles the reachable code under a closed-world assumption into machine code plus an image heap snapshot, which the process maps directly at startup.

Who should use native images and when they pay off

That build-time fixing is the price. What you get back is a process that starts in a fraction of the time and runs with a much lower resident memory floor, because class loading, much of the initialization work and the bean graph analysis have already happened. Whether that matters depends on how often your service starts and how long it lives.

Workloads where native pays off

  • Scale-to-zero platforms. When an instance spins up in response to the first request, startup time is user-facing latency. Cutting it from seconds to well under one can decide whether scale-to-zero is acceptable at all.
  • Bursty autoscaling. If traffic spikes faster than JVM instances can start and warm up, you either over-provision or drop requests. Fast-starting replicas let the autoscaler react to the spike instead of anticipating it.
  • Short-lived jobs. CLI tools, batch steps and event-triggered functions often finish before the JIT has compiled anything useful. A JVM spends that run interpreting code. A native binary runs compiled code from the first instruction.
  • Dense packing. When you run many small services per node, the baseline memory of each JVM (metaspace, JIT code cache, compiler threads) adds up. A lower floor means more replicas per node.

Where the JVM still wins

A long-running, throughput-bound service amortizes its startup over days. The JIT compiles against observed runtime behaviour, so it can inline and specialize hot paths in ways ahead-of-time compilation can only approximate without profile data. For these services, peak throughput and tail latency under sustained load usually favour a warmed-up JVM, and native's build cost and constraints buy little.

Building a native executable and a native container image

Spring Boot gives you two build paths, and both run the same AOT processing step first. That step generates bean definitions and GraalVM hints that the native compiler later depends on.

Native executable

Install a GraalVM distribution with native-image available, then use the GraalVM Native Build Tools plugin that Spring Boot configures for you. With Maven, run the native:compile goal under the native profile. With Gradle, apply the org.graalvm.buildtools.native plugin and run nativeCompile. The output is a single binary for the operating system and CPU architecture of the build machine.

Container image

If you don't want GraalVM installed locally, use Cloud Native Buildpacks. Run spring-boot:build-image with the native profile in Maven, or bootBuildImage in Gradle with the native plugin applied. The buildpack downloads the toolchain into a builder container and produces an OCI image. You need a working Docker-compatible daemon for this.

What to expect

Native compilation runs a closed-world analysis of every reachable class. That makes it slow and memory-hungry. A build that takes seconds on the JVM can take minutes, and the build machine needs several gigabytes of RAM, more for large dependency graphs. Under-provisioned CI runners tend to fail with out-of-memory errors instead of just running slowly.

The output is also platform-specific. An amd64 binary won't run on arm64, so you need a separate build for each target architecture, ideally on a native runner for each one rather than under emulation.

In practice, develop and run tests on the JVM, where the feedback loop is fast. Build native images in CI, and run your test suite against the native binary there as well.

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

# Check which architecture this machine builds for.
# The binary or image produced here only runs on this architecture.
uname -m   # e.g. x86_64 (amd64) or aarch64 (arm64)

# --- Maven ---
# Native executable: needs a local GraalVM with native-image on PATH.
# AOT processing runs first, then native-image compiles the binary into target/.
./mvnw -Pnative native:compile

# Run the test suite as a native binary (slow, so this belongs in CI).
./mvnw -PnativeTest test

# Container image via buildpacks: no local GraalVM, but needs a Docker-compatible daemon.
./mvnw -Pnative spring-boot:build-image \
  -Dspring-boot.build-image.imageName=registry.example.com/demo-app:1.0.0

# --- Gradle (with the org.graalvm.buildtools.native plugin applied) ---
# Native executable is written to build/native/nativeCompile/.
./gradlew nativeCompile

# Run the tests as a native binary.
./gradlew nativeTest

# Container image. Applying the native plugin makes bootBuildImage produce a native image.
./gradlew bootBuildImage --imageName=registry.example.com/demo-app:1.0.0

# Day-to-day development stays on the JVM, where the feedback loop is fast:
#   ./mvnw test        or   ./gradlew test

Maven and Gradle commands for building a native executable, running native tests and producing a native OCI image with buildpacks; each build targets the architecture of the machine it runs on.

Measuring startup, memory and throughput fairly against the JVM

Numbers from that native build are only useful if you compare them against the JVM build fairly. Package both from the same commit, give them identical CPU and memory limits, and drive them with the same load script.

Startup and memory

Log lines such as "Started Application in X seconds" leave out process launch and anything that runs lazily on the first call. A better measure is the wall-clock time from process start until the first successful HTTP response from a real endpoint, not just a health check. For memory, compare resident set size (RSS) as reported by the OS or by container metrics. -Xmx limits only the Java heap. It does not cover metaspace, thread stacks, code cache or native buffers, and that missing memory is exactly where the two builds differ.

Throughput and latency

Warm the JVM build until JIT compilation settles, then measure throughput and p99 latency at the same request rate on both builds. Native code is compiled ahead of time from a closed-world view of the program. It cannot re-optimize based on the traffic it actually receives, so its peak throughput is often lower. Profile-guided optimization (PGO) narrows this gap. It requires an instrumented build, a representative training run and a second build, and it is not available in every GraalVM distribution.

Metric JVM (illustrative estimate) Native (illustrative estimate)
Time to first request 2–4 s 50–150 ms
RSS at idle 250–400 MB 60–120 MB
Peak throughput baseline 70–90% of baseline

Garbage collection also differs. Native images default to a simple serial collector, and G1 is available only in some distributions and platforms. On large heaps, measure pause times before you commit to a native build.

#!/usr/bin/env bash
# Measure time to first successful request and idle RSS for one build.
# Run it once per build, with the same limits and the same endpoint:
#   ./measure.sh java -jar target/app.jar
#   ./measure.sh ./target/app
set -euo pipefail

# Use a real endpoint that touches your code paths, not just a health check.
URL="${URL:-"

start_ns=$(date +%s%N)
"$@" > app.log 2>&1 &
pid=$!
# Stop the app when the script exits, even on failure.
trap 'kill "$pid" 2>/dev/null || true' EXIT

# Poll until the endpoint returns a 2xx response. -f makes curl fail on HTTP errors.
until curl -sf -o /dev/null "$URL"; do
  # Exit early if the process crashed during startup.
  kill -0 "$pid" 2>/dev/null || { echo "process exited; see app.log"; exit 1; }
  sleep 0.01
done

end_ns=$(date +%s%N)
echo "time_to_first_request_ms=$(( (end_ns - start_ns) / 1000000 ))"

# Let the process settle, then read resident set size from the OS.
# RSS includes heap, metaspace, thread stacks, code cache and native buffers.
sleep 5
rss_kb=$(ps -o rss= -p "$pid" | tr -d ' ')
echo "idle_rss_mb=$(( rss_kb / 1024 ))"

# Note: if "$@" is a wrapper script, $! is the wrapper's PID, not the app's.
# In that case, launch the java or native binary directly.

A bash script that starts either build and records the wall-clock time from process launch to the first successful HTTP response, then reads idle resident set size from the OS so the two builds are compared on total memory, not heap flags.

Fixing reflection, resource, proxy and serialization failures

Native images fail differently from JVM builds. The compiler includes only code it can prove is reachable, so anything resolved dynamically, such as Class.forName, classpath resources, JDK proxies or Java serialization, may be missing at runtime. The obvious symptoms are ClassNotFoundException, NoSuchMethodException, or a null from getResource. The harder case is a feature that silently does nothing. A Jackson-bound DTO serializes to {} because its getters were never registered, or a fallback code path that runs once a month throws only in production.

Spring AOT generates hints for the beans it understands. For everything else, implement RuntimeHintsRegistrar and register reflection, resource patterns, proxy interfaces or serialization types explicitly. Then attach the registrar to a configuration class with @ImportRuntimeHints. For types that only cross a JSON or HTTP boundary, @RegisterReflectionForBinding is shorter and registers the fields and accessors that binding needs.

Third-party libraries that use reflection internally need their own reachability metadata. Check whether the library ships it or whether it exists in the shared GraalVM metadata repository before you write it yourself.

The GraalVM tracing agent, attached to a normal JVM run, records dynamic access and writes it out as configuration. It only sees the paths that actually executed, so untested branches stay uncovered. It also records framework internals and test scaffolding. Review its output and trim it before committing.

Finally, run your test suite in AOT mode and as a native test executable in CI. Missing hints then show up as a red build in minutes, not as an incident you have to reproduce under a native binary.

package com.example.billing;

import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.TypeReference;
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;

@Configuration
@ImportRuntimeHints(BillingNativeConfig.BillingHints.class)
// DTOs that only cross the JSON boundary: register fields and accessors for binding
@RegisterReflectionForBinding({InvoiceDto.class, LineItemDto.class})
class BillingNativeConfig {

    static class BillingHints implements RuntimeHintsRegistrar {
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            // Loaded by name via Class.forName on the monthly fallback path
            hints.reflection().registerType(
                    TypeReference.of("com.example.billing.LegacyTaxCalculator"),
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                    MemberCategory.INVOKE_PUBLIC_METHODS);

            // Read with getResource(); without this pattern it returns null in the native image
            hints.resources().registerPattern("billing/tax-rates/*.csv");

            // JDK dynamic proxy created at runtime for an audit interface
            hints.proxies().registerJdkProxy(AuditedGateway.class);

            // Written to a Java-serialized cache entry
            hints.serialization().registerType(InvoiceSnapshot.class);
        }
    }
}

A RuntimeHintsRegistrar attached with @ImportRuntimeHints registers reflection, resource, proxy and serialization hints for dynamic access that Spring AOT cannot infer, while @RegisterReflectionForBinding covers DTOs used only for JSON binding.

Where the Spring programming model behaves differently and when to avoid native

Native mode works because Spring AOT turns runtime decisions into build-time facts, so the bean graph is fixed when the image is built.

Behavior JVM Native image
Profiles and @ConditionalOnProperty Evaluated at startup Evaluated during the AOT build; the result is fixed in the image
@Bean definitions Can vary per run Fixed; the set of beans cannot change at runtime
Classpath scanning Finds any type at runtime Only types seen at build time exist
Proxies and class generation Created on demand JDK proxies need hints, CGLIB proxies are pre-generated, and new bytecode at runtime is unsupported

Property values such as URLs and credentials still bind at runtime. Properties that decide whether a bean exists do not. If staging and production differ in which beans they create, rather than only in values, you need a separate image per configuration. Each image adds build time and another artifact to test.

Library support is uneven. Libraries that generate classes at runtime, load plugins dynamically, or depend heavily on reflection may need extensive hints or may not work at all.

Diagnostics are also weaker. Java agents do not attach, JMX support is limited, and heap and thread tooling is less mature than on HotSpot. That makes production incidents harder to investigate.

When to avoid native

Stay on the JVM in any of these cases:

  • Your services rely on runtime-selected profiles that change the bean graph.
  • You depend on agent-based APM or instrumentation.
  • Your libraries generate code dynamically.
  • The process is long-lived, so faster startup saves little.

Alternatives, cost impact and a decision checklist

Native is one of several ways to fix slow startup or high memory. The cheaper options touch only configuration, so try them first.

Option What it changes Build/CI cost Main trade-off
Tuned JVM (heap sizing, container-aware flags) Memory footprint None Startup stays slow
Class data sharing (CDS) Class loading at startup Small extra training step Partial gain; JIT warm-up remains
Scaling policy (minimum replicas, earlier scale-out) When instances start None Pays for idle capacity
Native image Startup and memory Slow, memory-hungry builds; per-architecture artifacts; hint upkeep Compatibility and tooling gaps

Native pays for itself only when lower idle memory or instant startup turns into fewer billed resources. That happens mainly with scale-to-zero or dense packing.

Illustrative estimate (assumed numbers, not a benchmark): monthly saving = memory saved per replica × replicas × cost per GB-hour × hours = 0.3 GB × 40 × $0.005 × 730 ≈ $44

extra CI cost = extra build minutes × builds per month × cost per minute = 8 × 300 × $0.01 = $24

At these assumed inputs, a few engineer-hours a month spent on reflection hints erase the remaining $20. The numbers change sharply if scale-to-zero removes whole replicas.

Go or stay checklist

  • Workload shape: Go native for bursty, short-lived or scale-to-zero services. Stay on the JVM for steady, long-running ones, where JIT peak throughput matters more than startup.
  • Library compatibility: Every dependency should ship hints or be tested under native. If not, stay.
  • Observability: Stay if you need agent-based tracing or profiling that native does not support.
  • Build tolerance: Go only if the team accepts slower builds, larger CI runners and separate builds for each architecture.

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.