Contents
- How GraalVM Native Image Works
- Project Setup & Prerequisites
- Spring AOT Processing
- Providing Native Hints
- JSON Reflection & Resource Config
- Building the Native Image
- Native Integration Tests
- Troubleshooting Common Issues
GraalVM Native Image performs Ahead-of-Time (AOT) compilation at build time using a closed-world assumption: it traces all code paths reachable from the entry point and compiles only those paths into a self-contained native binary. Everything not reachable at build time — including dynamically loaded classes, reflection, JNI, serialization, and dynamic proxies — must be declared explicitly.
- Startup time: typically 50–200 ms (vs 3–10 s for JVM)
- Memory (RSS): 50–80% lower than JVM at steady state
- Trade-off: longer build time (1–5 min), no JIT optimisation at runtime, some dynamic features require hints
Spring Boot 3 requires GraalVM 22.3+ or the community Liberica NIK / Mandrel distributions. GraalVM JDK 21 LTS is recommended for production.
Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.
<!-- pom.xml — spring-boot-maven-plugin with native profile -->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<!-- Buildpacks native image (no local GraalVM needed) -->
<builder>paketobuildpacks/builder-jammy-tiny:latest</builder>
<env>
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
</plugin>
<!-- Native Maven Plugin — for local native-image builds -->
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
# Install GraalVM via SDKMAN (recommended)
sdk install java 21.0.3-graalce
sdk use java 21.0.3-graalce
# Verify
java -version # should show GraalVM
native-image --version
When you build a native image, Spring runs its AOT engine before native-image compilation. It pre-computes bean definitions, generates source code for bean factories, and registers hints for reflection and proxies — eliminating most manual configuration.
# Trigger AOT source generation (runs automatically during native build)
./mvnw spring-boot:process-aot
# Generated files appear in:
# target/spring-aot/main/sources/ — generated Java source files
# target/spring-aot/main/resources/ — generated hint JSON files
// AOT generates classes like this for each bean:
// target/spring-aot/main/sources/com/example/MyApplication__BeanDefinitions.java
public class MyApplication__BeanDefinitions {
@Bean
public static BeanDefinition getOrderServiceBeanDefinition() {
RootBeanDefinition beanDefinition = new RootBeanDefinition(OrderService.class);
beanDefinition.setInstanceSupplier(OrderService::new);
return beanDefinition;
}
}
// This replaces runtime reflection — Spring no longer needs to introspect
// bean classes at startup, which is what makes native startup so fast.
The AOT engine handles the vast majority of Spring's own infrastructure automatically — @Component, @Bean, @Autowired, JPA entities, Spring Data repositories, and Spring MVC controllers all work without manual hints.
When AOT cannot infer hints automatically (e.g., classes loaded reflectively by a third-party library, dynamic proxies, or serialized types), you provide them via annotations or a RuntimeHintsRegistrar.
// 1. Annotation-based hints (simplest)
@RegisterReflectionForBinding(OrderDto.class) // Jackson deserialization
@RegisterReflectionForBinding({ProductDto.class, UserDto.class})
@SpringBootApplication
public class MyApplication { }
// 2. RuntimeHintsRegistrar — programmatic hints
import org.springframework.aot.hint.*;
public class MyHintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Reflection — make a class and its methods accessible
hints.reflection()
.registerType(LegacyXmlParser.class,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS);
// Resources — include files in the native image
hints.resources()
.registerPattern("templates/*.html")
.registerPattern("META-INF/services/*");
// Proxies — JDK dynamic proxy for an interface
hints.proxies()
.registerJdkProxy(MyService.class);
// Serialization
hints.serialization()
.registerType(MySerializableClass.class);
}
}
// Register the hints registrar
@ImportRuntimeHints(MyHintsRegistrar.class)
@SpringBootApplication
public class MyApplication { }
// 3. @Reflective — annotate a class to register it for reflection
@Reflective // registers all public constructors, methods, and fields
public class PluginConfig { }
For third-party libraries where you can't add annotations, create JSON hint files in src/main/resources/META-INF/native-image/<groupId>/<artifactId>/:
// reflect-config.json
[
{
"name": "com.thirdparty.SomeClass",
"allDeclaredConstructors": true,
"allPublicMethods": true,
"allDeclaredFields": true
},
{
"name": "com.thirdparty.AnotherClass",
"methods": [
{ "name": "parse", "parameterTypes": ["java.lang.String"] }
]
}
]
// resource-config.json
{
"resources": {
"includes": [
{ "pattern": "\\Qdb/migration/V1__init.sql\\E" },
{ "pattern": ".*\\.xml$" }
]
}
}
// proxy-config.json
[
["com.example.MyRepository", "org.springframework.data.repository.Repository"]
]
The commands below build and run the application. Make sure Docker is running locally before executing the image build steps.
# Option 1: Local native-image binary (fastest iteration)
./mvnw -Pnative native:compile
# The executable is at: target/<artifactId>
./target/myapp # starts in ~100ms!
# Option 2: OCI image via Buildpacks (no local GraalVM needed — uses Docker)
./mvnw spring-boot:build-image -Pnative
# Option 3: Dockerfile with multi-stage build
# Stage 1: AOT + native-image compilation
# Stage 2: minimal distroless/scratch image with the binary
# Dockerfile — multi-stage native build
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative -DskipTests native:compile
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/myapp ./myapp
EXPOSE 8080
ENTRYPOINT ["./myapp"]
Native image compilation is memory-intensive. Allow at least 4–8 GB RAM for the build step in CI. Use -J-Xmx6g in <buildArgs> if the build runs out of memory.
Run your integration tests as a native binary to catch hint gaps before deploying.
# Compile tests to native and run them
./mvnw -PnativeTest test
# This:
# 1. Runs AOT on test sources
# 2. Compiles a test-native binary
# 3. Executes the binary — all @SpringBootTest tests run natively
// Tests look identical — the difference is they run in native image
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NativeCompatibilityIT {
@Autowired TestRestTemplate restTemplate;
@Test
void healthEndpoint_returns200() {
ResponseEntity<String> response = restTemplate.getForEntity("/actuator/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createOrder_persistsToDatabase() {
// Tests that JPA, Jackson, and your service layer all work natively
ResponseEntity<OrderDto> response = restTemplate.postForEntity(
"/orders", new CreateOrderRequest("item-1"), OrderDto.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().id()).isNotNull();
}
}
The output or file structure below illustrates the expected result.
// ❌ ClassNotFoundException at runtime in native image
// → A class is loaded reflectively but not registered
// Fix: add @RegisterReflectionForBinding or a RuntimeHintsRegistrar
// ❌ java.lang.reflect.InaccessibleObjectException
// → Missing reflection hint for a field or method
// Fix: add MemberCategory.INVOKE_DECLARED_METHODS / DECLARED_FIELDS to hints
// ❌ Missing resource: could not find ...properties / ...xml
// Fix: add resource pattern to RuntimeHints or resource-config.json
// ❌ JDK proxy cannot be created for interface X
// Fix: hints.proxies().registerJdkProxy(X.class)
// ❌ Build fails: "com.example.Foo is not accessible"
// → Third-party library uses internal JDK APIs blocked by modules
// Fix: add --add-opens JVM args in native-maven-plugin <buildArgs>
# Use the native-image agent to auto-generate hints by running the app normally
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/myapp.jar
# Exercise all code paths while the agent is running, then Ctrl+C
# The agent writes reflect-config.json, resource-config.json, proxy-config.json
# Review and commit these — they are your starting point for hints
The native-image-agent is the fastest way to discover missing hints for third-party libraries. Run your full test suite with the agent attached, then review the generated JSON files for anything not covered by Spring's AOT engine.