Spring Boot 3 Native Images with GraalVM: Build Config, Reflection Hints, and Runtime Trade-offs
Spring Boot 3.x AOT processing plus GraalVM native images cuts startup and memory for cloud-native services, but requires explicit reflection hints and accepts JVM runtime limits.
08 Sept 2026, 19:05 UTC

Problem: Cold starts and memory density in cloud-native Spring services
When a Spring Boot service runs on AWS Lambda, Google Cloud Run, or a scale-to-zero Kubernetes deployment, the JVM pays a warm-up tax on every cold start. Class loading, JIT compilation, and Spring's runtime reflection scanning add latency and consume memory that stays allocated even when traffic is idle. Teams want the Spring programming model without paying the JVM startup penalty on every instance.
Thesis
Spring Boot 3.x ships first-class GraalVM native image support through ahead-of-time (AOT) processing. By moving configuration and reflection analysis to build time, you can produce a standalone executable that starts in tens of milliseconds with a roughly 50-80% smaller memory footprint than a JIT-warmed JVM. The trade is a stricter build contract: third-party reflection must be declared explicitly, and several JVM runtime features are unavailable.
Build configuration: AOT processing and the native compiler
In a Maven project, AOT processing is driven by the spring-boot-maven-plugin, not a separate spring-aot artifact. Add the process-aot goal and the GraalVM native-maven-plugin to your pom.xml:
<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>
</plugin>
For Gradle, apply the org.springframework.boot.aot plugin and run ./gradlew nativeCompile. This assumes you are already on Spring Boot 3.x with the Jakarta EE namespace and a baseline JDK 17; Spring Boot 2.x applications must upgrade first. The native compilation itself must run on Linux x86_64 or ARM64 with a GraalVM distribution that matches your JDK version; Spring Boot 3.2+ targets GraalVM for JDK 21. A typical build takes 2-5 minutes, which is longer than a JAR build but cacheable in CI with layer caching. Because AOT output is environment-sensitive, pin the exact JDK and GraalVM versions in your pipeline so repeated builds produce identical native configuration.
Reflection handling: what AOT resolves and what you must declare
The AOT engine analyzes @Configuration classes, component scans, and auto-configuration at build time. It emits reflect-config.json and proxy-config.json that GraalVM consumes, so most Spring-managed beans require no manual work. Spring Boot 3.2+ also bundles native hints for common libraries such as Jackson and Protobuf through the spring-boot-native-image-hints dependency.
Problems appear when your code or a dependency accesses a type by reflection that the AOT scanner cannot see. The build fails with a message naming the missing class. Add a hint on a @Configuration class using the @NativeHint annotation provided by Spring's AOT tooling:
@Configuration
@NativeHint(types = CustomSerializer.class)
public class AppConfig { }
Alternatively, drop a META-INF/native-image/reflect-config.json file into your resources. Reflection-heavy frameworks such as Hibernate ORM, MapStruct, and Lombok generally work but may need additional hints; check each dependency's native-image support matrix. When debugging, pass -H:+ReportExceptionStackTraces to the native compiler to get fuller stack traces from failed reflection lookups.
Runtime trade-offs and observability
Native images start in tens of milliseconds and use roughly half the resident memory of a warmed-up JVM under comparable load. That density is attractive for serverless and scale-to-zero workloads, but it comes with constraints:
- No dynamic class loading: JSPs and runtime scripting engines are unsupported.
- No
java.lang.instrumentagents: most traditional APM Java agents rely on JVM TI and will not attach. - No JMX/MBeanServer by default: export Micrometer metrics through Prometheus, PushGateway, or OTLP instead.
- Serialization libraries must ship native configurations; rely on bundled hints or add your own.
Container images should use a minimal base such as scratch or a distroless image with glibc compatibility to realize the size benefit. A dynamically linked executable will not run on bare scratch unless you supply the required libraries or build a static musl binary with -H:+StaticExecutableWithDynamicLibC on supported platforms.
Worked example: compiling and verifying a minimal REST service
Start with a Spring Boot 3.3 project that includes spring-boot-starter-web and Actuator. After adding the plugins above, run the native build from the project root:
# Linux/macOS with GraalVM for JDK 21 on PATH
./mvnw -Pnative native:compile
This produces an executable under target/ and writes AOT metadata to target/spring-aot/. Inspect the generated reflect-config.json to confirm that expected application types are present. If a third-party class is missing, add an @NativeHint and rebuild.
To verify behavioral parity, run the standard suite in JVM mode and again against the native executable:
./mvnw test
./mvnw -Pnative test
For faster iteration, the AOT plugin also emits an application.aot.jar that mirrors native behavior closely; run it in JVM mode to catch reflection regressions before a full native compile.
Package the executable in a Dockerfile that uses a minimal base:
FROM scratch
COPY target/demo /demo
ENTRYPOINT ["/demo"]
Build and run the container, then check health and resource usage:
docker build -t demo-native .
docker run --rm -p 8080:8080 demo-native
# In another terminal
curl -s http://localhost:8080/actuator/health
docker stats demo-native
Generate load with a tool such as hey and compare docker stats output for the native container against an equivalent eclipse-temurin:21-jre JAR container under the same load to validate the memory reduction for your specific workload:
hey -n 10000 -c 50 http://localhost:8080/actuator/health
Actionable next steps
If cold-start latency and instance density are your bottleneck, enable AOT processing in an existing Spring Boot 3.x service and attempt a native compile. Treat every reflection failure as a build-time contract to satisfy with @NativeHint or a resource descriptor. Replace JMX-based monitoring with Micrometer exporters before you deploy, and lock your GraalVM and JDK versions in CI so the AOT output remains reproducible.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.