GraalVM Native Images with Spring Boot 4: From 8 Seconds to 37ms Startup
Spring Boot applications running as GraalVM native images start in milliseconds, use a fraction of the memory, and fit in tiny containers. The tradeoff is a longer build time. In 2026, with Spring Boot 4 and GraalVM 24, native images are production-ready for most Spring applications.
This guide covers everything: what Spring AOT does, how to build your first native image, how to fix the common issues, and how to add native builds to CI.
What Is a GraalVM Native Image?
A standard Spring Boot app runs on the JVM. The JVM starts, loads classes, JIT-compiles hot paths, and eventually reaches peak performance after a warm-up period.
A native image is different. GraalVM’s native-image tool runs ahead-of-time compilation at build time:
Source code → Maven/Gradle build → native-image compiler → platform binary
↑
(10–15 minutes, 4 CPU, 4GB RAM)
The result is a self-contained native binary (no JVM required) that:
- Starts in 30–100ms instead of 5–15 seconds
- Uses 4–10x less memory
- Has a smaller container image (often under 100MB)
The cost: AOT compilation is slow, and you lose some JVM features (runtime class loading, some reflection).
Real Numbers
From production Spring Boot services (Spring Boot 3.3, JDK 21 baseline):
| Metric | JVM Mode | Native Image | Improvement |
|---|---|---|---|
| Startup time | 8.2s | 0.045s (45ms) | 180x faster |
| Memory (idle) | 320 MB | 75 MB | 4.3x lower |
| Memory (under load) | 480 MB | 180 MB | 2.7x lower |
| Container image size | 320 MB | 85 MB | 3.8x smaller |
| Build time | 45s | 12 min | 16x slower |
When native images make sense:
- Serverless / AWS Lambda (cold start cost eliminated)
- Kubernetes with fast HPA scale-out (pods ready in under 1 second)
- CLI tools built with Spring Shell
- Microservices where memory cost per instance matters
When to stay on JVM:
- Services with heavy JIT benefit (CPU-intensive, long-running)
- Teams without the CI capacity for 10–15 min native builds
- Applications using libraries with poor native support
How Spring AOT Works
Spring’s Dependency Injection relies heavily on reflection, proxies, and runtime class scanning — all of which are incompatible with GraalVM’s closed-world assumption (the compiler must know at build time every class that will be loaded).
Spring Boot solves this with Spring AOT (Ahead-of-Time) processing:
Maven/Gradle build
│
▼
Spring AOT processor runs
│
├── Scans all @Configuration, @Component, @Bean definitions
├── Generates source code for bean instantiation (no reflection needed)
├── Generates proxy classes statically
├── Generates reflection hints for Jackson, JPA, etc.
└── Writes everything to generated-sources/
│
▼
native-image compiler
│
└── Compiles generated sources + your code → native binary
For most standard Spring Boot code, Spring AOT handles everything automatically. You only need to add hints manually when your code uses reflection dynamically.
Setting Up GraalVM Native Image Support
Prerequisites
- Install GraalVM 21+ (Community or Enterprise):
# Using SDKMAN (recommended)
sdk install java 21.0.2-graalce
sdk use java 21.0.2-graalce
# Verify
java -version
# openjdk 21.0.2 ... GraalVM CE 21.0.2+13.1
- Verify
native-imageis available:
native-image --version
# GraalVM Runtime Environment GraalVM CE 21.0.2+13.1
Maven setup
Add the Spring Boot native plugin to your pom.xml:
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Gradle setup
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.3.0"
id("org.graalvm.buildtools.native") version "0.10.1"
}
Building Your First Native Image
Option 1: Local build with native-image
# Maven
./mvnw -Pnative native:compile
# Gradle
./gradlew nativeCompile
The binary lands at target/your-app (Maven) or build/native/nativeCompile/your-app (Gradle).
# Run it — no JVM needed
./target/your-app
# Started YourApplication in 0.047 seconds (process running for 0.052)
Option 2: Docker with Buildpacks (no local GraalVM needed)
# Maven — builds a native container image without a local GraalVM install
./mvnw -Pnative spring-boot:build-image
# Gradle
./gradlew bootBuildImage
This runs the native compilation inside a Docker container using Paketo Buildpacks. Slower (downloads the builder image), but requires no local GraalVM setup.
Option 3: Multi-stage Dockerfile
# Build stage — needs GraalVM
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests
# Runtime stage — no JVM needed
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/your-app ./app
EXPOSE 8080
ENTRYPOINT ["./app"]
Final image size with debian:bookworm-slim: ~85 MB. With scratch (static binary): ~45 MB.
Handling Reflection and Dynamic Code
GraalVM’s closed-world assumption breaks code that uses reflection at runtime to load classes it doesn’t know about at build time. Spring AOT handles standard Spring patterns, but some cases require manual hints.
When you need manual hints
- Custom Jackson serializers/deserializers loaded by class name
- Libraries using
Class.forName()with dynamic strings - JNI, resource loading, serialization
Adding reflection hints
@Configuration
@ImportRuntimeHints(MyRuntimeHints.class)
public class MyConfig {
// ...
}
class MyRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Register a class for reflection
hints.reflection()
.registerType(MyDynamicClass.class, MemberCategory.INVOKE_DECLARED_METHODS);
// Register a resource file
hints.resources().registerPattern("my-config/*.json");
// Register a proxy interface
hints.proxies().registerJdkProxy(MyInterface.class);
}
}
Using @RegisterReflectionForBinding (simpler)
For classes used in serialization/deserialization:
@RegisterReflectionForBinding({OrderRequest.class, OrderResponse.class})
@RestController
public class OrderController {
// Jackson will now be able to serialize/deserialize these
}
Testing hints with the native test runner
# Runs tests in a native image — catches missing hints before production
./mvnw -PnativeTest test
Run this in CI to catch reflection problems early.
Common Issues and Fixes
Issue: ClassNotFoundException at runtime
Caused by: java.lang.ClassNotFoundException: com.example.SomeService
Cause: A class loaded by reflection at runtime wasn’t registered.
Fix: Add a RuntimeHintsRegistrar or @RegisterReflectionForBinding as shown above.
Quick workaround: Enable the GraalVM tracing agent to auto-generate hints:
# Run your app with tracing agent (JVM mode — exercises all code paths)
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/your-app.jar
# Run your integration tests to exercise all paths
./mvnw test
# Now build — the agent-generated hints are included
./mvnw -Pnative native:compile
Issue: UnsatisfiedLinkError (JNI)
Register JNI methods in your hints:
hints.jni().registerType(NativeClass.class, MemberCategory.INVOKE_DECLARED_METHODS);
Issue: Application starts but proxies fail
Spring’s CGLIB proxies are handled by AOT, but some third-party proxies aren’t. Register them:
hints.proxies().registerJdkProxy(YourInterface.class, SpringProxy.class, Advised.class);
Issue: Resources not found
Static resources and config files must be registered:
hints.resources().registerPattern("static/**");
hints.resources().registerPattern("application-*.properties");
CI/CD Integration
Native builds take 10–15 minutes. Run them on a separate CI job, not in the default fast-feedback loop.
GitHub Actions
name: Native Image Build
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- run: ./mvnw test
native-build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm-community'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build native image
run: ./mvnw -Pnative native:compile -DskipTests
timeout-minutes: 20
- name: Run native tests
run: ./mvnw -PnativeTest test
- name: Build Docker image
run: |
docker build -t myapp:${{ github.sha }} .
docker push myapp:${{ github.sha }}
Caching native build outputs
Native compilation is CPU and memory intensive. Cache the Maven/Gradle local repository to speed up subsequent builds:
- uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
Spring Boot 4 and Native Images
Spring Boot 4’s modularisation directly benefits native images:
- Smaller binary: Only the autoconfigure modules relevant to your app are compiled in. A Boot 4 web app with no JPA compiles significantly faster and produces a smaller binary than Boot 3.
- Better AOT: Spring Framework 7’s richer metadata makes AOT processing more accurate, reducing the number of manual hints needed.
- JSpecify null-safety: Compile-time null checks catch issues before the native compilation step.
Upgrade path: If you’re on Spring Boot 3.x with native images, the migration to Boot 4 is generally straightforward for native — the AOT improvements mean fewer manual hints are needed.
Performance Tuning Native Images
Optimize for peak throughput (not startup)
GraalVM PGO (Profile-Guided Optimization) improves throughput at the cost of a two-phase build:
# Phase 1: build with instrumentation
./mvnw -Pnative -Dspring-boot.native.build-args="--pgo-instrument" native:compile
./target/your-app # run with representative load to collect profile
# Phase 2: build with profile
./mvnw -Pnative -Dspring-boot.native.build-args="--pgo=default.iprof" native:compile
PGO can bring throughput within 10–20% of JVM peak performance.
G1 vs Serial GC
Native images default to Serial GC (good for startup and low memory). For high-throughput services, switch to G1:
# Maven — add to native-maven-plugin configuration
-Dspring-boot.native.build-args="--gc=G1"
Quick Reference
# Build native binary (Maven)
./mvnw -Pnative native:compile
# Build native container image (no local GraalVM)
./mvnw -Pnative spring-boot:build-image
# Run native tests (catch missing hints)
./mvnw -PnativeTest test
# Tracing agent (auto-generate hints)
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/your-app.jar
# Add reflection hint
@RegisterReflectionForBinding(MyClass.class)
# Register resource
hints.resources().registerPattern("config/*.json");
Summary
GraalVM native images with Spring Boot deliver 30–50ms startup times and 4x lower memory usage. Spring AOT handles the complex reflection and proxy work automatically for standard Spring patterns. Manual hints are needed for dynamic reflection and third-party libraries. Build time (10–15 minutes) is the main tradeoff — structure your CI to run native builds as a separate job after fast-feedback tests pass. Spring Boot 4’s modularisation makes native images smaller and faster to compile than Boot 3.
