Contents

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.

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.