Are you struggling with slow cold starts for your Spring Boot app in Lambda or Kubernetes environments? The problem of JVM startup taking several seconds can be dramatically improved with GraalVM Native Image. It has been officially supported since Spring Boot 3.0, making now the perfect time to adopt it.
What is GraalVM Native Image
GraalVM Native Image is a technology that AOT (Ahead-of-Time) compiles Java code to produce a native binary. Since JVM startup is no longer required, it pairs exceptionally well with FaaS and short-lived containers.
Prior to Spring Boot 3.0, an experimental project called “Spring Native” existed, but its configuration was complex. From 3.0 onward it has been incorporated as a standard feature and is considerably easier to use. For migrating from Spring Boot 2.x, see the Spring Boot 2 to 3 Migration Guide as well.
Performance Comparison: JVM Mode vs. Native Mode
The following measurements are from a simple Spring Boot app (roughly equivalent to Hello World). The numbers will vary as dependencies increase, but the trend remains the same.
| Metric | JVM Mode | Native Mode |
|---|---|---|
| Startup time | 3–5 seconds | 0.1–0.3 seconds |
| Memory usage | 200–400 MB | 50–100 MB |
That said, JVM is often advantageous for throughput and latency. The JVM, where JIT compilation accumulates optimizations over time, remains powerful for long-running, high-traffic API servers. Native builds are suited for scenarios where fast startup and low memory consumption are required.
Environment Setup
GraalVM JDK 21 is required. SDKMAN makes installation straightforward.
# Check available versions (version numbers are examples at time of writing)
sdk list java | grep graal
# Oracle GraalVM (JDK 17+, free under GFTC license)
sdk install java 21.0.2-graal
sdk use java 21.0.2-graal
# For GraalVM CE (Community Edition), use the graalce suffix
# sdk install java 21.0.2-graalce
native-image --version
21.0.2-graal is Oracle GraalVM and 21.0.2-graalce is the Community Edition. Check the latest version with sdk list java | grep graal before selecting. Spring Boot 3.1 or later is recommended.
Adding Build Configuration
For Maven
If you are using spring-boot-starter-parent, simply add native-maven-plugin. No version needs to be specified as it is managed by the BOM.
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
For Gradle
plugins {
id 'org.springframework.boot' version '3.2.0'
// io.spring.dependency-management is optional when using the Spring Boot Gradle plugin
id 'org.graalvm.buildtools.native'
}
The Spring Boot BOM manages the plugin version (0.10.x series), so no version specification is needed.
Building the Native Image
# Maven
./mvnw -Pnative native:compile
# Gradle
./gradlew nativeCompile
The build can take 10–20 minutes. On success, a binary is generated at target/<artifactId> (Maven) or build/native/nativeCompile/ (Gradle). Set up a dedicated build step for CI environments.
AOT Processing and Reflection Issues
In a native build, the entire Bean graph is statically analyzed at build time. The problem is dynamic Reflection. Libraries such as Jackson and JPA operate via reflection, so without configuration, errors may occur at runtime even if the build succeeds. Spring Boot itself auto-generates AOT hints for most cases, but issues mainly arise with third-party libraries and custom code.
Adding Reflection Hints
Specify via annotation (simplest approach)
@Configuration
@RegisterReflectionForBinding(MyDto.class)
public class AppConfig {
}
Write reflect-config.json manually
[
{
"name": "com.example.MyDto",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true
}
]
Place this under src/main/resources/META-INF/native-image/. Programmatic registration via the RuntimeHintsRegistrar interface is also possible and is useful when you want to add hints conditionally.
Auto-Generating Configuration with native-image-agent
Writing hints by hand can be tedious. With native-image-agent, you can record reflection usage while running in JVM mode.
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/myapp.jar
Start the app, exercise all representative endpoints, then terminate normally with Ctrl+C (SIGINT). A forced kill (SIGKILL) will not write the configuration files. After shutdown, files such as reflect-config.json will be generated. Note that only code paths actually executed are recorded. If you want to run multiple times and merge configurations, the config-merge-dir option is convenient.
Verifying Behavior with Testcontainers
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
For Gradle, add testImplementation 'org.springframework.boot:spring-boot-testcontainers'. Using the @ServiceConnection annotation, containers such as databases are automatically started during test execution. Run tests in native mode with the following command:
./mvnw -PnativeTest test
Native tests take several times longer than JVM tests, so splitting them into a dedicated stage in CI/CD is the practical approach.
Common Build Errors and Solutions
ClassNotFoundException / Missing reflection configuration
A class-not-found error at runtime indicates missing Reflection hint configuration. Regenerate with native-image-agent or add the target class with @RegisterReflectionForBinding.
Out of memory during build
JAVA_TOOL_OPTIONS=-Xmx8g ./mvnw -Pnative native:compile
Passing -J-Xmx8g directly to the mvnw command only controls the Maven JVM heap itself and does not affect the heap of the subprocess that the native-image compiler launches. The correct approaches are to use the JAVA_TOOL_OPTIONS environment variable, or to add <buildArg>-J-Xmx8g</buildArg> to <buildArgs> in native-maven-plugin. Large projects may require around 8 GB.
Library does not support native builds
Some libraries do not support native builds. Check compatibility in the GraalVM Reachability Metadata Repository and consider switching to an alternative library or excluding it.
Deploying to Lambda and Kubernetes
Use a multi-stage Dockerfile to separate build and runtime.
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests
FROM ubuntu:22.04
COPY --from=builder /app/target/myapp /myapp
ENTRYPOINT ["/myapp"]
ubuntu:22.04 is used for the runtime because the default native binary is dynamically linked against glibc. With the --static option for a statically linked build, gcr.io/distroless/static can also be used, but additional configuration is required. See the Spring Boot Docker Containerization Guide and Kubernetes Deployment Guide for details.
Summary
Native builds are suited for scenarios where startup speed and memory efficiency matter — Lambda, short-lived containers, CLI tools, and so on. Conversely, JVM mode is often more advantageous for long-running, high-throughput API servers where JIT compilation is effective. Build times increase and debugging becomes somewhat harder, so assess your use case carefully before adopting. Checking library compatibility in advance will save you trouble later.
- Handle Reflection errors with
@RegisterReflectionForBindingornative-image-agent - Resolve build out-of-memory issues with
JAVA_TOOL_OPTIONS=-Xmx8g(note:-J-Xmxonly controls Maven’s own heap) - For throughput-focused scenarios, consider combining with Virtual Threads (Java 21)
Start by trying it on a new project or a small service first.