Skip to content
Spring Boot sb production 4 min read

GraalVM Native Image

A GraalVM Native Image compiles your Spring Boot application ahead of time (AOT) into a standalone, self-contained executable — no JVM required at runtime. The payoff is dramatic: applications start in tens of milliseconds instead of seconds and use a fraction of the memory, which is ideal for serverless functions, Kubernetes autoscaling, and CLI tools. The trade-off is a slower, more rigid build and a closed-world model that forbids dynamic class loading. Spring Boot 3 has first-class native support built in.

How AOT changes the model

A normal JVM application starts a bytecode interpreter, JIT-compiles hot paths, and discovers beans, proxies, and reflection targets at runtime. GraalVM’s native-image tool instead performs static analysis at build time: it traces every reachable method and bakes the result into a native binary. Anything it cannot see statically — reflection, dynamic proxies, resources loaded by name — must be declared in advance as hints. Spring’s AOT engine generates most of these hints for you during the build.

Prerequisites

  • A GraalVM JDK (17+), or use the Buildpacks approach which downloads it for you.
  • spring-boot-starter (the standard web starter works) — no special native starter exists.
  • The Spring Boot AOT plugin goals, wired automatically by the parent POM.

Option 1: build a container image with Buildpacks

The easiest path needs only Docker — no local GraalVM install. The BP_NATIVE_IMAGE environment variable tells the Paketo buildpack to produce a native binary inside the image.

mvn -Pnative spring-boot:build-image -DskipTests

Configure the build in pom.xml:

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <configuration>
        <image>
          <env>
            <BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
          </env>
        </image>
      </configuration>
    </plugin>
  </plugins>
</build>

This yields an OCI image containing a tiny native executable, ready to run on Kubernetes.

Option 2: native-maven-plugin (local executable)

To produce a native binary directly (requires GraalVM installed locally), use the GraalVM native-maven-plugin. Spring Initializr adds it under a native profile:

<plugin>
  <groupId>org.graalvm.buildtools</groupId>
  <artifactId>native-maven-plugin</artifactId>
</plugin>
mvn -Pnative native:compile
./target/myapp        # run the native executable directly

Output (startup comparison):

JVM jar:        Started Application in 2.418 seconds   (~280 MB RSS)
Native image:   Started Application in 0.041 seconds   (~55 MB RSS)

Runtime hints for reflection

When Spring’s AOT engine can’t infer that a class is reflected on — common with libraries, custom JSON binding, or resources — you supply a RuntimeHints registrar.

import org.springframework.aot.hint.*;
import org.springframework.context.annotation.*;

@Configuration
@ImportRuntimeHints(MyApp.AppHints.class)
public class MyApp {

    static class AppHints implements RuntimeHintsRegistrar {
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            // Allow reflective access to a class GraalVM can't trace
            hints.reflection().registerType(LegacyDto.class,
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                    MemberCategory.INVOKE_PUBLIC_METHODS);

            // Bundle a resource into the image
            hints.resources().registerPattern("templates/*.html");
        }
    }
}

For reflecting over many types you can annotate a config class with @RegisterReflectionForBinding(LegacyDto.class) instead of hand-writing categories.

Tip: If a native image throws ClassNotFoundException or a serialization error that the JVM build never showed, a missing reflection or resource hint is almost always the cause.

Benefits and constraints

AspectJVM (jar)Native image
Startup timeSecondsMilliseconds
Memory footprintHigherMuch lower
Peak throughputHigher (JIT warms up)Slightly lower
Build timeSecondsMinutes
Reflection / proxiesWorks freelyNeeds hints
Dynamic class loadingSupportedNot supported
Conditional config at runtimeYesEvaluated at build time

Key constraints to remember:

  • Build time is long (minutes) and memory-hungry — keep it in CI, not your inner loop.
  • No runtime classpath scanning or dynamic class generation. Everything is fixed at build time.
  • Profiles and conditions are resolved at build time by default. Build a separate image per environment, or pass values that don’t change the bean graph.
  • Library support varies. Most Spring ecosystem libraries ship hints; verify third-party ones.

Warning: Test the native image, not just the JVM jar. Behaviour around reflection, resources, and conditional beans can differ, so run your integration suite against the actual native binary before shipping. GraalVM’s tracing agent can capture hints from a JVM test run if needed.

When to go native

Reach for native images when startup latency and memory density dominate — serverless, scale-to-zero, short-lived jobs, or packing many instances per node. For long-running services that prize peak throughput, a well-tuned JVM (see Performance Tuning) is often the better choice, since the JIT eventually outpaces AOT-compiled code.

Best Practices

  • Run native builds in CI; iterate locally on the JVM for speed.
  • Add RuntimeHints for any reflection, proxies, or named resources GraalVM can’t trace.
  • Run your full test suite against the native binary, not only the jar.
  • Build per-environment images since conditions resolve at build time.
  • Measure startup and RSS to confirm the win justifies the longer build.
Last updated June 13, 2026
Was this helpful?