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
ClassNotFoundExceptionor a serialization error that the JVM build never showed, a missing reflection or resource hint is almost always the cause.
Benefits and constraints
| Aspect | JVM (jar) | Native image |
|---|---|---|
| Startup time | Seconds | Milliseconds |
| Memory footprint | Higher | Much lower |
| Peak throughput | Higher (JIT warms up) | Slightly lower |
| Build time | Seconds | Minutes |
| Reflection / proxies | Works freely | Needs hints |
| Dynamic class loading | Supported | Not supported |
| Conditional config at runtime | Yes | Evaluated 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
RuntimeHintsfor 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.