GraalVM Native Image with Spring Boot: Production Guide
Traditional Spring Boot applications start in 2-5 seconds and consume 200-500MB of memory. With GraalVM native image, those same applications start in under 100 milliseconds and use 50-80MB. This matters enormously for serverless functions, Kubernetes pods that need fast scaling, and microservices where memory costs add up across hundreds of instances. Consequently, the technology has moved from experimental to genuinely production-ready with Spring Boot 3.x. This guide covers building, optimizing, and deploying Spring Boot native images in production, along with the trade-offs that often get glossed over in introductory tutorials.
How GraalVM Native Image Works
The native-image tool performs ahead-of-time (AOT) compilation, converting your Java application into a standalone binary. Unlike the JVM which interprets bytecode at runtime, AOT compilation analyzes your code during the build phase, determines exactly which classes and methods are reachable, and compiles everything to native machine code. The result is a binary that starts instantly because there is no JVM to bootstrap, no classpath to scan, and no bytecodes to interpret.
However, this comes with constraints. The compiler operates under a “closed-world assumption”: everything reachable must be known at build time. Because of this, reflection, dynamic proxies, serialization, and JNI must be declared explicitly through configuration files. Spring Boot 3.x and Spring Framework 6.x include built-in AOT processing that handles most of this automatically, but custom libraries may need manual configuration. In practice, this is where most of the engineering effort goes when adopting native images.
Setting Up Spring Boot for Native Compilation
Spring Boot 3.x includes native support out of the box. You need GraalVM installed (or use the buildpack approach for containerized builds) and the native-maven-plugin or native Gradle plugin configured. Notably, the buildpack route is what most teams adopt for CI/CD because it removes the need to provision GraalVM on every build agent.
<!-- pom.xml - Native profile -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
<arg>-H:+ReportExceptionStackTraces</arg>
<arg>--initialize-at-build-time=org.slf4j</arg>
<arg>-march=native</arg>
</buildArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
# Build native image directly (requires GraalVM)
./mvnw -Pnative native:compile
# Build using Cloud Native Buildpacks (no GraalVM needed locally)
./mvnw spring-boot:build-image -Pnative
# The buildpack approach is recommended for CI/CD
# It produces a Docker image with the native binary inside
Understanding Build-Time vs Run-Time Initialization
One concept trips up nearly every team new to native images: the distinction between build-time and run-time initialization. By default, GraalVM initializes most classes at build time, meaning their static initializers run during compilation and the resulting state is baked into the binary. This is fast, but it is also dangerous when a static initializer captures something that should be evaluated fresh on each launch — a timestamp, a random seed, or an open file handle.
For example, a logging framework that opens a file in a static block, or a class that caches the current time, will misbehave if initialized at build time. Therefore, you frequently need to push specific classes to run-time initialization using --initialize-at-run-time. Conversely, you can force libraries with expensive but deterministic setup to build time to shave milliseconds off startup. Getting this balance right is one of the more subtle skills in native image tuning, and the build log will often warn you when a class was initialized at build time unexpectedly.
# Common initialization adjustments in buildArgs
--initialize-at-build-time=org.slf4j,ch.qos.logback
--initialize-at-run-time=io.netty.util.internal.logging.Log4JLogger
--initialize-at-run-time=com.example.RandomSeedHolder
# When in doubt, let the build log guide you:
# "Detected a started Thread in the image heap" or
# "Classes that should be initialized at run time" are clear signals
GraalVM Native Image: Handling Reflection and Dynamic Features
The biggest challenge with native images is reflection. Java libraries extensively use reflection for dependency injection, serialization, and ORM mapping. Spring’s AOT engine handles most framework-level reflection, but third-party libraries may need manual hints. Importantly, you rarely write raw JSON config files anymore — Spring’s programmatic RuntimeHints API is the idiomatic approach in modern codebases.
// Register reflection hints for custom classes
@RegisterReflectionForBinding({
PaymentRequest.class,
PaymentResponse.class,
WebhookPayload.class
})
@SpringBootApplication
public class PaymentServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentServiceApplication.class, args);
}
}
// For more complex scenarios, use RuntimeHintsRegistrar
@ImportRuntimeHints(CustomHints.class)
@Configuration
public class NativeConfig {
static class CustomHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader cl) {
// Register reflection for Jackson deserialization
hints.reflection()
.registerType(PaymentEvent.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS);
// Register resource files
hints.resources()
.registerPattern("templates/*.json")
.registerPattern("validation-rules.yaml");
// Register JNI access
hints.jni().registerType(NativeLibrary.class,
MemberCategory.INVOKE_DECLARED_METHODS);
}
}
}
When a dependency does not ship its own reachability metadata, the GraalVM Reachability Metadata Repository often already contains community-contributed hints, and Spring pulls these in automatically. As a result, popular libraries like Hibernate, Jackson, and the PostgreSQL driver generally work without hand-written config.
Performance Benchmarks: JVM vs Native
The numbers below are representative figures published in GraalVM and Spring benchmarks for a typical Spring Boot REST API with JPA and PostgreSQL. Your mileage will vary with hardware and workload, but the shape of the trade-off is consistent across reported benchmarks.
Metric | JVM (OpenJDK 21) | Native (GraalVM)
------------------------|-------------------|------------------
Startup time | 2.8 seconds | 0.065 seconds
Memory at startup | 280 MB | 62 MB
Memory under load | 450 MB | 120 MB
Build time | 15 seconds | 4.5 minutes
Peak throughput (req/s) | 12,500 | 9,800
p99 latency | 8 ms | 12 ms
Binary size | 18 MB (JAR) | 85 MB (binary)
The trade-off is clear: native images win dramatically on startup and memory but sacrifice peak throughput and build time. The JVM’s JIT compiler optimizes hot paths at runtime, achieving higher peak performance for long-running services. For serverless functions and short-lived containers, native is the clear winner. For high-throughput services that run continuously, the JVM may still come out ahead. That said, GraalVM’s Profile-Guided Optimization (PGO) can close much of the throughput gap — you run an instrumented build, exercise it with representative traffic, then feed the collected profile back into a second build.
# Step 1: build an instrumented binary
./mvnw -Pnative native:compile -DbuildArgs=--pgo-instrument
# Step 2: run it against representative load to produce default.iprof
./target/payment-service # then hit it with your load test
# Step 3: rebuild using the collected profile
./mvnw -Pnative native:compile -DbuildArgs=--pgo=default.iprof
# Reported gains: often 10-15% throughput recovery vs non-PGO native
Docker Multi-Stage Builds for Native Images
# Multi-stage build for minimal container
FROM ghcr.io/graalvm/native-image-community:21 AS build
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests
# Use distroless for minimal attack surface
FROM gcr.io/distroless/base-debian12
COPY --from=build /app/target/payment-service /app/payment-service
EXPOSE 8080
ENTRYPOINT ["/app/payment-service"]
# Final image: ~90MB vs ~350MB for JVM equivalent
# Startup: 65ms vs 2.8 seconds
Because the native binary is dynamically linked against glibc by default, the base image matters. Distroless and Debian-slim work well; Alpine, which uses musl, requires a static or mostly-static build (--static --libc=musl) to avoid runtime linkage errors. Therefore, validate your base image early rather than discovering an incompatibility after the binary is built.
Troubleshooting Common Native Image Issues
The most frequent problems and their solutions are below. The recurring theme is that anything dynamic the static analysis could not “see” must be declared explicitly.
// Problem 1: ClassNotFoundException at runtime
// Solution: Add @RegisterReflectionForBinding or reflect-config.json
// Problem 2: Missing resources
// Solution: Add to resource-config.json
// {"resources":{"includes":[{"pattern":"templates/.*"}]}}
// Problem 3: Native test failures
// Run the tracing agent to auto-detect:
// java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image
// -jar target/app.jar
// Problem 4: Build runs out of memory
// Solution: Increase build memory
// MAVEN_OPTS="-Xmx8g" ./mvnw -Pnative native:compile
// Problem 5: Serialization issues with Jackson
// Solution: Register serialization hints
hints.serialization().registerType(PaymentEvent.class);
The tracing agent (Problem 3) deserves emphasis: it observes a running JVM build of your app, records every reflective and resource access along the exercised paths, and writes config files automatically. However, it only captures code paths your tests actually hit — so incomplete test coverage produces incomplete hints, and you may still see failures on untested branches in production. For this reason, treat the agent as a starting point, not a guarantee.
The CI/CD and Observability Tax
Adopting native images is not free operationally, and this is the part most introductory guides skip. Build times jump from seconds to minutes, and native builds are memory-hungry — a non-trivial service can need 8GB or more of RAM on the build agent, which means upsizing CI runners. Furthermore, because compilation is so much slower, many teams keep a dual-track pipeline: fast JVM builds for pull-request feedback and inner-loop development, with native builds reserved for the release branch.
Observability shifts too. The rich JVM tooling you rely on — full JFR support, live heap dumps, attach-based profilers, and many bytecode-instrumentation agents — is reduced or unavailable in a native binary. Newer GraalVM releases have added partial JFR and monitoring support, but you should validate that your APM agent and debugging workflow function in native mode before committing a service to it. In short, budget for tooling gaps alongside the build-time cost.
When to Use Native vs JVM
For further reading, refer to the Spring Boot official documentation and the Oracle Java documentation for comprehensive reference material.
Key Takeaways
- Start with a solid foundation and build incrementally based on your requirements
- Test thoroughly in staging before deploying to production environments
- Monitor performance metrics and iterate based on real-world data
- Follow security best practices and keep dependencies up to date
- Document architectural decisions for future team members
Use native compilation for: serverless functions (AWS Lambda, Google Cloud Functions), CLI tools, Kubernetes pods that scale frequently, and applications where memory cost matters (running 100+ instances). Stick with the JVM for: high-throughput services, applications heavily using reflection or dynamic class loading, and cases where build time and rich observability matter more than startup time. Many organizations run a hybrid approach: native for edge services and serverless, JVM for core backend services where peak throughput and deep profiling tooling are decisive.
In conclusion, Graalvm Native Image Spring is an essential topic for modern software development. By applying the patterns and practices covered in this guide — from initialization tuning and reflection hints to PGO and a realistic appraisal of the operational tax — you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.