Ahead-of-Time Compilation: How Spring Generates Code for Native Images
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:
- Implementing
RuntimeHintsRegistrar— a callback interface invoked during AOT processing @Reflectiveand@RegisterReflectionForBinding— annotations that declare reflection requirements- 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:
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:
GeneratedClasses— A namespace for creating new Java classesGeneratedFiles— For writing resource files, bytecode, or sourceRuntimeHints— For registering reflection/resource/proxy hints
InstanceSupplierCodeGenerator is the workhorse that produces the actual bean instantiation code:
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:
Tip: To inspect the generated AOT code for your application, run
./gradlew processAot(or the Maven equivalent) and look inbuild/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:
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:
-
Part 1 (Build System): The vendored JavaPoet library in spring-core is the code generation engine. The
RuntimeHintsAgentPluginvalidates hint completeness in CI. -
Part 2 (IoC Container): The bean creation pipeline that uses
createBeanInstance()+ reflection is replaced by generatedInstanceSuppliercode that calls constructors directly. -
Part 3 (refresh() + Configuration):
ConfigurationClassPostProcessor's runtime classpath scanning is replaced by pre-computed bean definitions in generated code.BeanRegistrarprovides an inherently AOT-friendly alternative. -
Part 4 (AOP Proxies): CGLIB proxy classes are generated at build time and included in the native image, rather than generated dynamically at runtime.
-
Part 5 (Web Dispatch): WebFlux's functional
RouterFunctionmodel 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 aRuntimeHintsRegistraris registered for that type. The@ImportRuntimeHintsannotation 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.