Spring Boot 3.x native image support is production-ready, and the performance numbers are hard to ignore. A service that took 8 seconds to start and consumed 512MB of heap at idle now starts in 150ms and runs comfortably in 64MB. For containers, serverless functions, and CLI tools, that’s a meaningful difference.
This article covers the full picture: how GraalVM native image works, what Spring Boot 3’s AOT engine does for you, how to configure the build, and how to handle the cases where you’ll still need to write hints manually.
How GraalVM Native Image Works
The JVM compiles Java bytecode to machine code at runtime via JIT. GraalVM Native Image flips this: it performs Ahead-of-Time (AOT) compilation at build time, producing a self-contained native executable with no JVM dependency.
The tradeoff is fundamentally about when work happens:
| Characteristic | JVM Mode | Native Image |
|---|---|---|
| Startup time | Seconds (JIT warm-up) | Milliseconds |
| Memory footprint | High (JIT metadata, heap) | Low (fixed binary) |
| Peak throughput | Excellent (JIT-optimized) | Good (no JIT) |
| Build time | Fast | Slow (minutes) |
| Reflection support | Full | Requires hints |
Native image uses static analysis (called “points-to analysis”) to determine which code paths are reachable at build time. Everything not reachable gets dropped. This is what makes the binary small and memory-light—and it’s also why reflection, dynamic proxies, and runtime classpath scanning require explicit configuration.
Spring Boot 3.x AOT Engine
Spring Boot 3 introduced a built-in AOT processing step that runs during the build. Before GraalVM compiles your code, Spring:
- Starts the
ApplicationContextin a restricted mode - Processes all
@Beandefinitions,@Conditionallogic, and auto-configuration - Generates Java source files representing the pre-computed context
- Generates
reflect-config.json,proxy-config.json, andresource-config.jsonhint files - Packages all of this for the native image compiler
In practice, this means the majority of Spring’s reflection and proxy usage—which is pervasive—is handled automatically. You write normal Spring code and the AOT engine does the heavy lifting.
Setting Up the Native Build
Prerequisites
You need either:
- GraalVM JDK 21+ with native-image installed:
gu install native-image - Docker with
docker buildx(for container-based native builds without a local GraalVM install)
Check your GraalVM setup:
java -version
# Should show: OpenJDK ... GraalVM CE ...
native-image --version
# Should show: native-image 21.x.x ...
Maven Configuration
Add the Spring Boot native Maven plugin:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
</parent>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<executions>
<execution>
<id>add-reachability-metadata</id>
<goals>
<goal>add-reachability-metadata</goal>
</goals>
</execution>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
Gradle Configuration
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.2.3"
id("org.graalvm.buildtools.native") version "0.10.1"
}
graalvmNative {
binaries {
named("main") {
imageName.set("my-service")
buildArgs.add("--no-fallback")
buildArgs.add("-O2")
}
}
}
Building the Native Executable
# Maven: build native executable
./mvnw -Pnative native:compile
# Gradle: build native executable
./gradlew nativeCompile
# The executable will be at target/my-app (Maven) or build/native/nativeCompile/my-app (Gradle)
./target/my-app
Expect the build to take 3–10 minutes depending on your application size and hardware.
Container Builds with Buildpacks
If you don’t have GraalVM installed locally, or need to build for a different platform, Spring Boot’s Buildpacks integration handles everything inside Docker:
# Maven: build native container image (requires Docker)
./mvnw -Pnative spring-boot:build-image
# Gradle
./gradlew bootBuildImage
This pulls a GraalVM-enabled builder image, runs AOT processing and native compilation inside the container, and produces a minimal runtime image. No local GraalVM install needed.
For CI pipelines, this is often the cleanest option:
# .github/workflows/native-build.yml
name: Build Native Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'graalvm'
- name: Build native image
run: ./mvnw -Pnative native:compile -DskipTests
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: native-executable
path: target/my-app
Multi-Stage Dockerfile
For a production-optimized container image:
# Build stage — uses GraalVM
FROM ghcr.io/graalvm/native-image:21 AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q
COPY src/ src/
RUN ./mvnw -Pnative native:compile -DskipTests
# Runtime stage — minimal base image
FROM debian:bookworm-slim
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
WORKDIR /app
COPY --from=builder /app/target/my-app ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["./my-app"]
The resulting image is typically 80–150MB vs 400–600MB for a JVM-based image, and it starts in under a second.
Handling Reflection and Dynamic Proxies
This is where most native image issues live. GraalVM’s static analysis cannot see through dynamic reflection calls, so anything that uses reflection at runtime needs an explicit hint.
What Spring’s AOT Engine Handles Automatically
The good news: Spring handles most of this for you. The AOT engine generates hints for:
- All
@Component,@Service,@Repository,@Controllerbeans @Configurationclasses and@Beanmethods- JPA entities (when using Spring Data JPA)
- Spring Security configurations
- All auto-configuration classes
- Jackson serialization for types used in
@RequestBody/@ResponseBody
Writing Manual Hints
For cases the AOT engine can’t see—third-party libraries, dynamic class loading, custom serialization—you register hints manually using RuntimeHintsRegistrar:
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.stereotype.Component;
@Component
@ImportRuntimeHints(MyRuntimeHints.class)
public class MyService {
// service implementation
}
class MyRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Register a class for reflection (all declared methods and fields)
hints.reflection()
.registerType(MyDynamicallyLoadedClass.class,
MemberCategory.INVOKE_DECLARED_METHODS,
MemberCategory.DECLARED_FIELDS);
// Register a specific method
hints.reflection()
.registerMethod(
ReflectionUtils.findMethod(MyClass.class, "myMethod", String.class),
ExecutableMode.INVOKE);
// Register resources
hints.resources()
.registerPattern("templates/*.html")
.registerPattern("static/icons/*.svg");
// Register serialization for a type
hints.serialization()
.registerType(MySerializableClass.class);
// Register a JDK proxy interface
hints.proxies()
.registerJdkProxy(MyInterface.class);
}
}
Using @RegisterReflectionForBinding
For Jackson serialization on types that Spring can’t detect automatically:
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
@RestController
@RequestMapping("/api/events")
@RegisterReflectionForBinding({
OrderEvent.class,
PaymentEvent.class,
ShipmentEvent.class
})
public class EventController {
// ...
}
JSON Hint Files (Fallback)
For third-party libraries without native hints, you can place hint files in src/main/resources/META-INF/native-image/:
// reflect-config.json
[
{
"name": "com.example.ThirdPartyClass",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
}
]
// proxy-config.json
[
["com.example.MyInterface", "org.springframework.aop.SpringProxy"]
]
Testing Native Image Compatibility
Don’t wait until the native build to find hint gaps. The AOT test support lets you run your existing tests against the AOT-generated code (not the actual native binary, but close enough to catch most issues):
# Maven: run tests in AOT mode
./mvnw test -PnativeTest
# Gradle
./gradlew nativeTest
This runs your full test suite against the AOT-processed application context, catching reflection misses before the 10-minute native compile.
For the actual native binary, add a smoke test to your CI:
./target/my-app &
sleep 2
curl -f http://localhost:8080/actuator/health || exit 1
kill %1
Performance Benchmarks
Numbers from a representative Spring Boot 3.2 REST service with Spring Data JPA, Spring Security, and ~20 endpoints:
| Metric | JVM (JIT) | Native Image |
|---|---|---|
| Startup time | 4.2s | 0.18s |
| First request latency | 120ms (JIT cold) | 8ms |
| Steady-state throughput | ~4,200 req/s | ~3,800 req/s |
| Idle memory (RSS) | 310MB | 68MB |
| Peak memory under load | 580MB | 195MB |
| Binary/image size | 280MB (fat jar) | 95MB (native exe) |
The throughput gap (about 10%) exists because JIT-compiled code can be more aggressively optimized than AOT code. For I/O-bound services—which describes most web applications—this gap is rarely significant in practice, because the bottleneck is usually network or database, not CPU.
Where native image wins clearly: containers with memory limits, scale-to-zero serverless workloads (AWS Lambda, Google Cloud Run), and CLI tools that need to respond instantly.
Common Issues and Fixes
Missing Reflection Hints — ClassNotFoundException at Runtime
You’ll see errors like:
java.lang.ClassNotFoundException: com.example.SomeClass
or:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class com.example.MyDto
Fix: add the missing type to your RuntimeHintsRegistrar or use @RegisterReflectionForBinding.
Initializing a Class Too Early
Error: Classes that should be initialized at run time got initialized during image building
Some libraries initialize static state that native image tries to bake in at build time—but that state depends on runtime conditions (e.g., system properties, environment variables).
Fix:
// In your native-maven-plugin configuration, or programmatically:
graalvmNative {
binaries {
named("main") {
buildArgs.add("--initialize-at-run-time=com.example.ProblematicClass")
}
}
}
Accessing Sun Internal APIs
WARNING: Using incubator modules: ...
Error: com.oracle.svm.core.jdk.UnsupportedFeatureError
Some libraries use sun.* or JDK internal APIs. Check if the library has a native-image-compatible version, or file a bug upstream.
Dynamic @ConfigurationProperties
If you use @ConfigurationProperties on classes discovered dynamically (e.g., loaded from an external JAR), you need to register them explicitly:
hints.reflection()
.registerType(MyConfigProps.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.DECLARED_FIELDS,
MemberCategory.INVOKE_DECLARED_METHODS);
GraalVM Reachability Metadata Repository
Before writing custom hints, check the GraalVM Reachability Metadata Repository. It contains community-contributed hint files for hundreds of popular libraries (Hibernate, Jackson, Netty, Micrometer, and many more).
The native-maven-plugin’s add-reachability-metadata goal pulls these automatically during the build. Most Spring Boot starter dependencies already have this covered.
When to Use Native Image
Native image is the right choice when:
- Startup time matters: serverless, scale-to-zero, or CLI tools
- Memory is constrained: dense container deployments, Kubernetes with tight resource limits
- Distribution simplicity: shipping a single binary without a JRE dependency
Stick with the JVM when:
- Peak throughput is critical: high-frequency trading, compute-heavy batch processing
- Extensive dynamic class loading: plugin systems, scripting engines
- Build time is a bottleneck: native builds add minutes to every CI run
- Debugging and profiling: JVM tooling (async-profiler, JFR, heap dumps) doesn’t apply to native
For most microservices and APIs, native image is worth evaluating—especially if you’re already on Spring Boot 3.x. The AOT engine handles the hard parts, and the operational benefits at scale are real.
Putting It Together
A minimal native-ready Spring Boot service:
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository repository;
public OrderController(OrderRepository repository) {
this.repository = repository;
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
return repository.findById(id)
.map(OrderResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
Order order = repository.save(Order.from(request));
return OrderResponse.from(order);
}
}
Build and run:
# Build native
./mvnw -Pnative native:compile -DskipTests
# Run — starts in ~150ms
./target/order-service
# Output:
# Started OrderServiceApplication in 0.157 seconds (process running for 0.172)
No JVM, no warm-up, ready to serve immediately.
Spring Boot 3’s AOT engine has made native image practical for standard Spring applications. Start with ./mvnw -Pnative native:compile on your existing app—if you’re using standard Spring starters, it will probably just work.
Related Articles
- Java Virtual Threads with Spring Boot — Another powerful approach to Spring Boot performance: virtual threads for high-concurrency I/O.
- Java 25 Performance Breakthrough: 30% CPU Reduction — Java 25 includes AOT profiling improvements that complement GraalVM’s native image approach.
- Spring Boot Microservices Architecture Patterns — Native images are especially impactful in microservices deployments where startup time matters.
- Spring Cloud Functions: Serverless Java Development — GraalVM native images are the natural companion to serverless Spring Cloud Functions on AWS Lambda.