Read OSS

Ahead-of-Time Compilation: How Spring Generates Code for Native Images

Advanced

Prerequisites

  • Article 2: BeanFactory and BeanDefinition concepts
  • Article 3: ConfigurationClassPostProcessor and refresh() sequence
  • Basic understanding of GraalVM native image constraints

Ahead-of-Time Compilation: How Spring Generates Code for Native Images

Throughout this series, we've seen Spring rely heavily on runtime dynamism: reflection-based bean creation in Part 2, annotation scanning in Part 3, dynamic proxy generation in Part 4. Every one of these techniques is a problem for GraalVM native images, which require a closed-world assumption — everything that will be used at runtime must be known at build time. Spring Framework's AOT engine bridges this gap by generating Java source code that replaces dynamic behaviors with static alternatives. This article traces the complete pipeline.

The Problem: Why Native Images Need AOT

GraalVM's native image compiler performs aggressive dead code elimination and pre-initializes classes at build time. To do this, it needs to know upfront:

  • Which classes will be reflected upon
  • Which resources will be loaded from the classpath
  • Which dynamic proxies will be created
  • Which serialization formats will be used

The traditional Spring startup sequence from Part 3 violates all of these: ConfigurationClassPostProcessor scans the classpath at runtime, AbstractAutowireCapableBeanFactory creates beans via reflection, and DefaultAopProxyFactory generates CGLIB proxies dynamically.

AotDetector is the switch between the two worlds:

spring-core/src/main/java/org/springframework/aot/AotDetector.java#L31-L54

public static boolean useGeneratedArtifacts() {
    return (inNativeImage || SpringProperties.getFlag(AOT_ENABLED));
}
flowchart TD
    Start["Application Starts"]
    
    Start --> Check{"AotDetector<br/>.useGeneratedArtifacts()?"}
    
    Check -->|"false (JVM mode)"| Traditional["Traditional startup:<br/>classpath scanning,<br/>reflection-based bean creation,<br/>dynamic proxy generation"]
    
    Check -->|"true (native or AOT mode)"| AOT["AOT startup:<br/>load generated code,<br/>pre-computed bean definitions,<br/>pre-built proxy classes"]
    
    Traditional --> Runtime["Runtime: Full Spring flexibility"]
    AOT --> Native["Runtime: Faster startup,<br/>lower memory, no reflection"]

The detection is simple: either we're inside a GraalVM native image (detected via NativeDetector), or the spring.aot.enabled system property is set. When AOT mode is active, the generated code completely replaces the dynamic configuration processing pipeline.

RuntimeHints: Declaring What the Native Image Needs

Not all reflection and resource access can be eliminated. Some frameworks and libraries legitimately need them at runtime. RuntimeHints is the registry where Spring collects these requirements:

spring-core/src/main/java/org/springframework/aot/hint/RuntimeHints.java#L34-L80

classDiagram
    class RuntimeHints {
        +reflection() ReflectionHints
        +resources() ResourceHints
        +proxies() ProxyHints
        +jni() ReflectionHints
    }
    
    class ReflectionHints {
        +registerType(Class, MemberCategory...)
        +registerConstructor(Constructor, ExecutableMode)
        +registerMethod(Method, ExecutableMode)
    }
    
    class ResourceHints {
        +registerPattern(String)
        +registerResourceBundle(String)
    }
    
    class ProxyHints {
        +registerJdkProxy(Class... interfaces)
    }
    
    RuntimeHints --> ReflectionHints
    RuntimeHints --> ResourceHints
    RuntimeHints --> ProxyHints

Components register hints in several ways:

  1. Implementing RuntimeHintsRegistrar — a callback interface invoked during AOT processing
  2. @Reflective and @RegisterReflectionForBinding — annotations that declare reflection requirements
  3. Automatically via BeanRegistrationAotProcessor — processors that analyze bean definitions and register hints

The build system includes a validation mechanism. The RuntimeHintsAgentPlugin in buildSrc configures a Java agent that runs during tests to verify hint completeness:

buildSrc/src/main/java/org/springframework/build/hint/RuntimeHintsAgentPlugin.java#L42-L60

Tests tagged with RuntimeHintsTests run with the agent, which intercepts reflection calls and validates that corresponding hints have been registered. If a reflection call isn't covered by a hint, the test fails. This turns native image compatibility from a "hope it works" scenario into a testable, CI-enforced guarantee.

The AOT Processing Pipeline: From Bean Definitions to Generated Code

The AOT pipeline operates at build time, processing the same bean definitions that would normally be used at runtime. The central interface is BeanRegistrationAotProcessor:

spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationAotProcessor.java#L50-L90

sequenceDiagram
    participant Build as Build Tool
    participant AOT as AOT Engine
    participant BRAP as BeanRegistrationAotProcessor
    participant Contrib as BeanRegistrationAotContribution
    participant GenCtx as GenerationContext
    participant ISCG as InstanceSupplierCodeGenerator
    participant JP as JavaPoet (vendored)

    Build->>AOT: Process application context
    AOT->>AOT: Simulate refresh() up to step 5
    Note right of AOT: Discovers all bean definitions<br/>without instantiating singletons
    
    loop For each RegisteredBean
        AOT->>BRAP: processAheadOfTime(registeredBean)
        BRAP-->>AOT: BeanRegistrationAotContribution
    end
    
    loop For each Contribution
        AOT->>Contrib: applyTo(generationContext, code)
        Contrib->>GenCtx: getGeneratedClasses()
        Contrib->>ISCG: generateCode(generatedMethods)
        ISCG->>JP: Build Java source with CodeBlock
        JP-->>ISCG: MethodSpec
        ISCG-->>Contrib: MethodReference
    end
    
    AOT->>GenCtx: Write all generated .java files
    Build->>Build: Compile generated sources

The process works in three phases:

Phase 1 — Discovery: The AOT engine simulates the application startup, running ConfigurationClassPostProcessor and other BeanFactoryPostProcessor beans to discover all bean definitions — just like steps 1–5 of refresh() from Part 3.

Phase 2 — Contribution Generation: For each discovered bean, the engine invokes BeanRegistrationAotProcessor.processAheadOfTime(). The default processor examines the bean definition's constructor, factory method, and property values, producing a BeanRegistrationAotContribution that knows how to generate the registration code.

Phase 3 — Code Emission: Each contribution uses GenerationContext — the central interface for AOT code generation:

spring-core/src/main/java/org/springframework/aot/generate/GenerationContext.java#L43-L57

GenerationContext provides three things:

  1. GeneratedClasses — A namespace for creating new Java classes
  2. GeneratedFiles — For writing resource files, bytecode, or source
  3. RuntimeHints — For registering reflection/resource/proxy hints

InstanceSupplierCodeGenerator is the workhorse that produces the actual bean instantiation code:

spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java#L17-L59

It uses the vendored JavaPoet library (under org.springframework.javapoet, as we saw in Part 1's shadow/repack section) to emit Java source code. For a bean defined as:

@Bean
public MyService myService(MyRepository repo) {
    return new MyService(repo);
}

The generator produces something like:

public static MyService getMyServiceInstance(RegisteredBean registeredBean) {
    return new MyService(registeredBean.getBeanFactory().getBean(MyRepository.class));
}

No reflection. No Method.invoke(). Just a direct constructor call that GraalVM can statically analyze.

BeanRegistrationsAotContribution handles the bulk registration — it groups individual bean registrations into generated classes, splitting at 5,000 registrations per file and 1,000 per method to avoid JVM limits:

spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotContribution.java#L51-L58

Tip: To inspect the generated AOT code for your application, run ./gradlew processAot (or the Maven equivalent) and look in build/generated/aotSources. The generated classes are plain Java — you can read them, set breakpoints in them, and use them to debug AOT issues.

ConfigurationClassPostProcessor's AOT Integration

ConfigurationClassPostProcessor doesn't just process configurations at runtime — it also contributes AOT code. Its imports reveal the dual nature:

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java#L44-L75

The imports include GeneratedClass, GeneratedMethod, GenerationContext, MethodReference, RuntimeHints, ReflectionHints — the full AOT toolkit. This class implements both BeanRegistrationAotProcessor and BeanFactoryInitializationAotProcessor, meaning it contributes code at both the per-bean and factory-wide levels.

For @Configuration classes with @Bean methods, the post-processor generates code that directly invokes the factory methods — replacing the CGLIB enhancement and reflective method invocation that traditionally powers @Configuration class processing.

BeanRegistrar and JSpecify: Designing for Compile-Time Safety

The BeanRegistrar API we introduced in Part 3 was designed with AOT as a first-class concern:

spring-beans/src/main/java/org/springframework/beans/factory/BeanRegistrar.java#L22-L41

flowchart LR
    subgraph "Traditional @Bean"
        T1["@Bean method"] --> T2["Reflection to discover"]
        T2 --> T3["Reflection to invoke"]
        T3 --> T4["AOT: Generate code<br/>to replace reflection"]
    end
    
    subgraph "BeanRegistrar"
        B1["register(registry, env)"] --> B2["Direct API calls"]
        B2 --> B3["supplier lambda"]
        B3 --> B4["AOT: Already<br/>reflection-free"]
    end

Since BeanRegistrar uses explicit API calls and supplier lambdas rather than annotated methods, the AOT engine can process it with minimal transformation. The supplier(context -> new Bar(context.bean(Foo.class))) lambda is already a direct constructor call — no reflection to replace.

Alongside BeanRegistrar, Spring 7.x is migrating its nullness annotations from org.springframework.lang.@Nullable to JSpecify's org.jspecify.annotations.@Nullable. The Nullness utility class supports this:

spring-core/src/main/java/org/springframework/core/Nullness.java#L43-L60

This migration isn't just about better IDE support. JSpecify annotations are designed to work with static analysis tools and compilers, enabling compile-time detection of null-safety violations. For AOT, this means the code generator can make stronger assumptions about nullability without runtime checks. The ArchUnit rules we saw in Part 1 enforce this migration by forbidding the old @Nullable and @NonNull imports from org.springframework.lang.

flowchart TD
    subgraph "Spring's Compile-Time Safety Strategy"
        JSpecify["JSpecify @Nullable / @NullMarked"]
        BR["BeanRegistrar (no reflection)"]
        AOT["AOT Code Generation"]
        ArchUnit["ArchUnit enforcement"]
        
        JSpecify --> |"Enables"| StaticAnalysis["Compile-time null checks"]
        BR --> |"Enables"| NoReflect["Reflection-free bean creation"]
        AOT --> |"Produces"| Generated["Generated Java source"]
        ArchUnit --> |"Prevents"| OldAPI["Use of deprecated annotations"]
        
        StaticAnalysis --> Native["GraalVM Native Image"]
        NoReflect --> Native
        Generated --> Native
    end

The Big Picture: From Dynamic to Static

Looking back across this entire series, the AOT story connects every major component:

  1. Part 1 (Build System): The vendored JavaPoet library in spring-core is the code generation engine. The RuntimeHintsAgentPlugin validates hint completeness in CI.

  2. Part 2 (IoC Container): The bean creation pipeline that uses createBeanInstance() + reflection is replaced by generated InstanceSupplier code that calls constructors directly.

  3. Part 3 (refresh() + Configuration): ConfigurationClassPostProcessor's runtime classpath scanning is replaced by pre-computed bean definitions in generated code. BeanRegistrar provides an inherently AOT-friendly alternative.

  4. Part 4 (AOP Proxies): CGLIB proxy classes are generated at build time and included in the native image, rather than generated dynamically at runtime.

  5. Part 5 (Web Dispatch): WebFlux's functional RouterFunction model is naturally AOT-friendly since routes are defined in code rather than discovered via annotation scanning.

The AOT engine is not just an optimization — it's a fundamental architectural shift that allows Spring's rich, dynamic programming model to run in environments that forbid dynamism. The generated code is the bridge between developer experience (annotations, scanning, proxies) and runtime efficiency (static dispatch, no reflection, instant startup).

Tip: When debugging AOT issues, the most common problem is missing RuntimeHints. If a bean needs reflection at runtime (for example, a JPA entity that's introspected by Hibernate), ensure a RuntimeHintsRegistrar is registered for that type. The @ImportRuntimeHints annotation makes this declarative.

Series Conclusion

Over these six articles, we've navigated the entire Spring Framework — from its 22-module Gradle monorepo and build conventions, through the IoC container's interface pyramid and bean creation pipeline, into the bootstrap sequence and configuration processing, across the AOP proxy infrastructure, through both web dispatch architectures, and finally into the AOT compilation engine. Each layer builds on the ones below it, and each architectural decision has a rationale that becomes clear only when you understand the whole picture.

Spring Framework's enduring success comes from this internal consistency: the same patterns (Template Method, Strategy, interface segregation) appear at every level, making the codebase navigable even at 1.5 million lines. The source code, thoroughly documented with Javadoc, is ultimately the best reference — and now you know where to look.