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.
<!-- 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"]
]
# 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();
}
}
// ❌ 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.