Pavan Rangani

HomeBlogSpring Boot 4 AOT Compilation and Native Images: Production-Ready Guide

Spring Boot 4 AOT Compilation and Native Images: Production-Ready Guide

By Pavan Rangani · March 20, 2026 · Java & Spring

Spring Boot 4 AOT Compilation and Native Images: Production-Ready Guide

Spring Boot 4 AOT Compilation and Native Images

Spring Boot AOT native image support has reached production maturity with Spring Boot 4. Ahead-of-Time compilation transforms your Spring applications into standalone executables that start in milliseconds instead of seconds. This fundamental shift in how Java applications deploy brings Spring Boot closer to the instant-startup world of Go and Rust binaries, and it changes the economics of running JVM workloads in elastic, scale-to-zero environments.

In this comprehensive guide, we explore how AOT compilation works under the hood, walk through converting existing Spring Boot applications, and examine representative performance numbers reported by the Spring and GraalVM teams. Whether you are building serverless functions, CLI tools, or microservices that demand fast scaling, native images deliver transformative improvements — provided your workload fits the model, which is the part most teams get wrong on the first attempt.

How AOT Compilation Works in Spring Boot 4

Traditional Spring Boot applications perform extensive work at startup: classpath scanning, bean definition registration, dependency injection, and auto-configuration. AOT compilation shifts this work to build time. The AOT engine analyzes your application context, pre-computes bean definitions, and generates optimized Java source that GraalVM then compiles into a native binary. Because the dynamic discovery has already happened, the running binary never scans the classpath — it simply executes the pre-wired instructions.

Spring Boot 4 introduces a redesigned AOT pipeline that addresses the limitations of earlier versions. The new pipeline handles more patterns automatically, including conditional beans, profile-specific configurations, and complex injection scenarios that previously required manual hints. It is worth understanding what GraalVM’s closed-world assumption means here: the native compiler must see every class, method, and resource at build time. Anything resolved dynamically at runtime — reflection, dynamic proxies, resource loading by computed path — is invisible unless you declare it. That single constraint explains nearly every native-image quirk you will encounter.

Spring Boot AOT native image compilation pipeline
AOT compilation pipeline transforming Spring Boot applications into native binaries

Setting Up Native Image Compilation

Configure your Spring Boot 4 project for native image compilation by adding the GraalVM native build plugin. The setup is significantly simpler than previous versions, because the Spring Boot parent POM already aligns the plugin version and wires the AOT goals into the build lifecycle.

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.0</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.graalvm.buildtools</groupId>
            <artifactId>native-maven-plugin</artifactId>
            <configuration>
                <buildArgs>
                    <buildArg>--gc=G1</buildArg>
                    <buildArg>-march=native</buildArg>
                </buildArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

Note the two build args. The --gc=G1 flag selects the G1 garbage collector instead of the default Serial GC, which matters for services that hold larger heaps; the -march=native flag tunes the binary for the build machine’s CPU. The latter is a sharp edge: a binary built with -march=native may fault on older CPUs in your fleet, so for portable images you should pin a specific baseline such as -march=x86-64-v2 instead. This is exactly the kind of detail that passes locally and fails in production.

AOT Processing and Bean Registration

Spring Boot 4 AOT processing generates three types of artifacts: bean factory initialization code, reflection hints for GraalVM, and proxy class definitions. Understanding these helps you debug native image issues effectively, because when something works on the JVM but throws a ClassNotFoundException or missing-resource error in the native binary, the cause is almost always a missing hint.

// Custom AOT processor for complex beans
@Component
public class CustomBeanRegistrationAotProcessor
        implements BeanRegistrationAotProcessor {

    @Override
    public BeanRegistrationAotContribution processAheadOfTime(
            RegisteredBean registeredBean) {

        Class<?> beanType = registeredBean.getBeanClass();
        if (beanType.isAnnotationPresent(DynamicConfig.class)) {
            return (generationContext, beanRegistrationCode) -> {
                // Register reflection hints
                generationContext.getRuntimeHints()
                    .reflection()
                    .registerType(beanType,
                        MemberCategory.INVOKE_DECLARED_METHODS,
                        MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);

                // Register resource hints
                generationContext.getRuntimeHints()
                    .resources()
                    .registerPattern("config/*.yml");
            };
        }
        return null;
    }
}

Moreover, Spring Boot 4 automatically generates hints for most standard patterns. You only need custom processors for dynamic bean registration, runtime proxies, or reflection-heavy libraries. For the common cases, the simpler RuntimeHintsRegistrar interface is enough, and you can register a serialization DTO for reflection with a single annotation, as shown below.

// Lightweight alternative: register hints declaratively
@Configuration
@RegisterReflectionForBinding({ OrderRequest.class, OrderResponse.class })
public class SerializationHintsConfig {
    // DTOs used by Jackson are now visible to the native compiler
}

// Or implement RuntimeHintsRegistrar for fine-grained control
public class AppRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.resources().registerPattern("templates/*.html");
        hints.reflection().registerType(LegacyAdapter.class,
            MemberCategory.INVOKE_PUBLIC_METHODS);
    }
}
Java code optimization for native compilation
Optimizing Java code patterns for GraalVM native image compatibility

Diagnosing Missing Hints with the Tracing Agent

Hand-writing hints for a large dependency graph is tedious and error-prone. Fortunately, GraalVM ships a tracing agent that watches a normal JVM run and records every reflective access, proxy, resource load, and serialization call it observes. You run your test suite or exercise the app’s real endpoints with the agent attached, and it emits JSON configuration files that Spring folds into the native build automatically. A common pattern in production teams is to wire this agent into an integration-test profile so the metadata regenerates whenever the code changes, rather than drifting out of date.

# Run the app or tests with the GraalVM tracing agent attached
java -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image \
     -jar target/my-app.jar

# Exercise every code path that uses reflection, then stop the app.
# The agent writes reflect-config.json, resource-config.json, etc.

# Maven equivalent: run the test suite under the agent
./mvnw -PnativeTest test

# Then build the native image with the collected metadata
./mvnw -Pnative native:compile -DskipTests

The crucial caveat is coverage: the agent only records paths it actually executes. If a reflective branch never runs during your traced session, its hints never get written, and the binary fails the first time a user triggers that path. Therefore, treat agent output as a starting point and pair it with realistic end-to-end tests rather than assuming the generated config is complete.

Performance Benchmarks: JVM vs Native

The following figures are representative of a typical Spring Boot 4 REST API with JPA, validation, and security, compiled in JVM and native modes and measured on comparable 2-vCPU cloud instances. Treat them as illustrative orders of magnitude rather than guarantees — your own numbers will depend heavily on dependency weight, heap configuration, and traffic shape.

Application: REST API with JPA + Security + Validation

┌────────────────────┬───────────────┬───────────────┐
│ Metric             │ JVM (Java 23) │ Native Image  │
├────────────────────┼───────────────┼───────────────┤
│ Startup time       │ 2.8s          │ 0.045s        │
│ Memory (RSS)       │ 280 MB        │ 68 MB         │
│ Docker image size  │ 340 MB        │ 85 MB         │
│ First request (ms) │ 12ms          │ 2ms           │
│ P99 latency (ms)   │ 8ms           │ 11ms          │
│ Peak throughput    │ 15,200 RPS    │ 12,800 RPS    │
│ Build time         │ 25s           │ 4m 30s        │
└────────────────────┴───────────────┴───────────────┘

The results show a clear trade-off. Native images deliver dramatically faster startup and far lower memory, but peak throughput is somewhat lower because GraalVM cannot apply the same aggressive profile-guided JIT optimizations that the HotSpot JVM discovers while a long-running process warms up. Therefore, native images shine for serverless and scale-to-zero workloads where startup time and memory dominate, and they look less attractive for steady high-throughput services. If you want to recover some of that throughput, GraalVM’s Profile-Guided Optimization lets you feed a representative load profile back into the build, narrowing the gap at the cost of an extra build step.

Optimizing Native Image Build Performance

// Spring Boot 4 native profile configuration
@Configuration
@Profile("native")
public class NativeOptimizationConfig {

    @Bean
    public HikariDataSource dataSource(DataSourceProperties props) {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(props.getUrl());
        ds.setUsername(props.getUsername());
        ds.setPassword(props.getPassword());
        // Reduce pool for native — lower memory footprint
        ds.setMaximumPoolSize(5);
        ds.setMinimumIdle(1);
        // Faster startup with eager initialization
        ds.setInitializationFailTimeout(0);
        return ds;
    }

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer nativeJsonCustomizer() {
        return builder -> builder
            .featuresToDisable(
                SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .modules(new JavaTimeModule());
    }
}

Beyond configuration, build time itself is a real cost. Native compilation is memory-hungry — the GraalVM builder frequently needs 8 GB or more of RAM, and an under-provisioned CI runner will fail with cryptic out-of-memory errors mid-build. A few practical levers help: keep the dependency surface lean so the compiler analyzes fewer classes, cache the GraalVM toolchain layer in CI, and reserve native builds for the branches you actually deploy rather than running them on every pull request. For long-running services where you want startup wins without the native build tax, consider Class Data Sharing or Project CRaC on the JVM as a middle ground.

When NOT to Use Spring Boot AOT Native Images

Native images are not universally better than JVM deployment. Avoid them when your application relies heavily on runtime reflection that cannot be pre-computed, such as dynamic plugin systems, bytecode-generating libraries, or frameworks that load classes by name at runtime. Older mocking and ORM tooling occasionally falls into this trap, and chasing down the required hints can cost more than the startup improvement is worth. Additionally, if your application runs continuously rather than scaling to zero, and peak throughput matters more than startup time, the JVM with a warmed-up JIT will simply outperform the native binary.

Key Takeaways

  • Start with a solid foundation and build incrementally based on your requirements
  • Test thoroughly in staging before deploying to production environments
  • Monitor performance metrics and iterate based on real-world data
  • Follow security best practices and keep dependencies up to date
  • Document architectural decisions for future team members

Furthermore, the longer build times slow down development iteration considerably. Use JVM mode during development and only build native images for CI/CD production pipelines. As a result, teams that deploy infrequently, or whose services never scale down, may not see enough benefit to justify the added build complexity and the new class of build-time failures. A useful rule of thumb: if you cannot point to a concrete cost or latency problem that fast startup solves, you probably do not need native images yet.

Production deployment with Spring Boot native images
Choosing between JVM and native deployment for production workloads

Containerizing Native Images

# Multi-stage build for Spring Boot native image
FROM ghcr.io/graalvm/native-image-community:23 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests

FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/target/my-app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

The resulting container image is well under 90 MB — roughly four times smaller than a typical JVM-based image. Consequently, container pulls are faster, auto-scaling responds quicker, and infrastructure costs drop for workloads with variable traffic. The distroless base image is doing real work here too: because it ships no shell and almost no userland, the attack surface shrinks alongside the size. One caveat worth flagging is that a native binary built against glibc will not run on an Alpine/musl base, so match your build and runtime libc — another mismatch that compiles cleanly and then crashes on first boot.

Key Takeaways

Spring Boot 4 AOT compilation and native images are production-ready for the right workloads. Choose native for serverless functions, CLI tools, and microservices that scale out and in frequently. Stick with JVM for long-running services where peak throughput dominates. The substantial startup and memory improvements make Spring Boot AOT native images compelling for cloud-native deployments. Start with a non-critical service to build team experience, wire the tracing agent into your tests, and validate behavior in staging before migrating core applications.

For more Java performance topics, check out our guide on Java virtual threads in Spring Boot and Spring Boot microservices best practices. Additionally, the official Spring Boot native image documentation and GraalVM native image reference provide detailed configuration guides.

In conclusion, Spring Boot AOT native compilation is an essential tool in the modern Java toolbox, not a default for every service. By applying the patterns covered here — declaring hints, tracing reflective paths, tuning the build, and matching your runtime base image — you can ship binaries that start in milliseconds and run lean. Start with the fundamentals, measure against your real traffic, and adopt native images where their trade-offs genuinely fit.

← Back to all articles