Spring Native and AOT: What Ahead-of-Time Processing Changes About Everything Above
Spring Native and AOT: What Ahead-of-Time Processing Changes About Everything Above
Every chapter before this one described a framework that builds itself at runtime. The container scans the classpath (CH2). It evaluates conditions to decide which beans exist (CH5). It generates proxy subclasses with CGLIB bytecode (CH8). It reflects on constructor parameters to wire dependencies (CH3). All of this happens after the JVM starts, during ApplicationContext.refresh() (CH1).
GraalVM Native Image removes the ability to do any of that.
Native Image compiles Java bytecode into a standalone executable ahead of time. The resulting binary contains no JIT compiler, no classloader, and no ability to generate new classes. The closed-world assumption governs everything: every class that will ever exist must be known at build time. Every reflective access must be declared. Every resource loaded at runtime must be cataloged.
This chapter covers what Spring AOT does to make the framework work under those constraints, and what breaks from the mechanisms described in every earlier chapter.
The Closed-World Assumption
Traditional JVM applications operate in an open world. Any class can be loaded at any time. Reflection can access any field or method. New classes can be generated from bytecode. The JVM class-verifies and JIT-compiles as it goes.
Native Image operates in a closed world. The native-image compiler performs a static analysis called points-to analysis. It traces all reachable code paths from the entry point. Only reachable classes, methods, and fields are included in the binary. Everything else is discarded.
Three operations become impossible without explicit configuration:
-
Reflection.
Class.forName("com.example.TenantConfig")fails unless the class is registered for reflection. Field access, constructor invocation, method invocation: all require registration. -
Dynamic class generation. CGLIB’s
Enhancer.create()generates a new class at runtime. The native image has no bytecode generator, no class verifier, no way to define a class that did not exist at build time. -
Classpath resource loading.
ClassLoader.getResourceAsStream("templates/invoice.html")fails unless the resource is included in the image.
This is not a limitation that can be worked around with clever code. It is a fundamental constraint of static compilation. Spring must adapt to it.
What Spring AOT Replaces
Spring AOT is a build-time processing step that generates Java source code to replace what the container traditionally does at runtime. The Maven or Gradle plugin invokes AOT processing during the build, before native-image runs.
Here is what AOT replaces from earlier chapters:
| Runtime Mechanism (JVM) | AOT Replacement (Native) |
|---|---|
CH1: refresh() discovers and creates beans | AOT generates __BeanDefinitions classes with explicit registrations |
CH2: @ComponentScan scans packages | AOT enumerates beans at build time, no scanning needed |
CH3: AutowiredAnnotationBeanPostProcessor reflectively injects | AOT generates direct constructor/setter calls |
CH5: @ConditionalOnClass evaluated at startup | Conditions evaluated at build time, resolved statically |
CH8: CGLIB Enhancer generates proxy subclasses | Proxy classes pre-generated as source files, compiled normally |
The AOT engine starts a full ApplicationContext at build time. It calls refresh(). It evaluates every condition. It resolves every bean. Then, instead of running the application, it serializes the entire container state into generated Java code.
When the native image starts, it executes the generated code directly. No scanning. No reflection. No condition evaluation. No proxy generation.
The SaaS Backend in Native: 0.1 Seconds
The multi-tenant SaaS backend from previous chapters starts in approximately 3 seconds on a standard JVM. It scans 200+ components, evaluates 150+ auto-configuration classes, generates dozens of CGLIB proxies, and autowires hundreds of injection points.
In native image, the same application starts in 0.1 seconds.
Started TenantApplication in 0.098 seconds (process running for 0.112)
The startup difference is not optimization. It is elimination. The native image does not scan, evaluate, generate, or reflect. It executes pre-computed initialization code that was generated at build time.
Memory consumption drops similarly. The JVM backend uses 400MB of heap at idle (class metadata, JIT compiler state, reflection caches). The native image uses 60MB.
For the SaaS backend, where each tenant deployment is an isolated instance, this means 6x more instances per host.
How AOT Processing Works
AOT processing runs during mvn spring-boot:aot-generate or the equivalent Gradle task. The process has four phases:
Phase 1: Context Refresh at Build Time
Spring creates a real GenericApplicationContext and calls refresh(). This is the same refresh() from CH1. Bean definitions are registered. Conditions are evaluated. Dependencies are resolved. The difference: this context will never serve a request. It exists only to be introspected.
[AOT] Starting ApplicationContext for AOT processing
[AOT] Loaded 247 bean definitions
[AOT] Evaluated 153 conditions (98 matched, 55 skipped)
Phase 2: Bean Definition Code Generation
For each bean definition in the context, Spring generates a Java class that explicitly registers that bean. The generated class replaces what ConfigurationClassPostProcessor and component scanning did at runtime.
A bean like:
@Service
public class TenantService {
private final TenantRepository repository;
public TenantService(TenantRepository repository) {
this.repository = repository;
}
}
Produces a generated class:
public class TenantService__BeanDefinitions {
public static BeanDefinition getTenantServiceBeanDefinition() {
RootBeanDefinition definition = new RootBeanDefinition(TenantService.class);
definition.setInstanceSupplier(
InstanceSupplier.of(TenantService__BeanDefinitions::createTenantService));
return definition;
}
private static TenantService createTenantService(
RegisteredBean registeredBean) {
return new TenantService(
registeredBean.getBeanFactory()
.getBean(TenantRepository.class));
}
}
No reflection. No constructor discovery. The generated code calls new TenantService(repository) directly.
Phase 3: Proxy Pre-Generation
Every proxy that CGLIB would generate at runtime is instead generated as a Java source file at build time. The proxy class is compiled by javac along with the rest of the application. At runtime, it is a normal class, indistinguishable from hand-written code.
Phase 4: RuntimeHints Collection
AOT processors register hints for any reflection, resources, or serialization that the application still needs at runtime. These hints are written to reflect-config.json, resource-config.json, and related GraalVM configuration files.
What Breaks from Earlier Chapters
CH1: BeanFactoryPostProcessors Using Reflection
Any BeanFactoryPostProcessor that uses java.lang.reflect at runtime will fail in native image.
// BROKEN: reflection-based BPP that inspects annotations at runtime
@Component
public class TenantAwareBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String name) {
// This reflection call has no hint registration
for (Field field : bean.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(TenantScoped.class)) {
field.setAccessible(true);
// inject tenant context
}
}
return bean;
}
}
At runtime in native image, getDeclaredFields() returns an empty array. No exception. No warning. The field injection silently does nothing. The tenant context is never set. Requests process without tenant isolation.
// CORRECT: register RuntimeHints for the reflective access
@Component
public class TenantAwareBeanPostProcessor
implements BeanPostProcessor, BeanRegistrationAotProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String name) {
for (Field field : bean.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(TenantScoped.class)) {
field.setAccessible(true);
// inject tenant context
}
}
return bean;
}
@Override
public BeanRegistrationAotContribution processAheadOfTime(
RegisteredBean registeredBean) {
// Generate hints for every bean this processor will touch
return (context, code) -> {
RuntimeHints hints = context.getRuntimeHints();
hints.reflection().registerType(
registeredBean.getBeanClass(),
MemberCategory.DECLARED_FIELDS);
};
}
}
The BeanRegistrationAotProcessor interface lets the post-processor participate in AOT processing. It runs at build time and registers the reflection hints that the runtime code requires.
CH5: Conditions Evaluated Once, at Build Time
In JVM mode, @ConditionalOnBean evaluates during refresh(). If a bean is absent at startup but registered by a lazy mechanism, the condition might re-evaluate. In native, conditions are evaluated exactly once, at AOT build time. The result is baked into the generated code.
// BROKEN: condition depends on runtime environment variable
@Configuration
@ConditionalOnProperty(name = "feature.advanced-analytics", havingValue = "true")
public class AdvancedAnalyticsConfig {
@Bean
public AnalyticsEngine analyticsEngine() {
return new AnalyticsEngine();
}
}
If feature.advanced-analytics is false during AOT processing, the bean is excluded from the generated code. Setting it to true at runtime in the native image has no effect. The bean definition does not exist.
// CORRECT: use build profiles for environment-specific native images
// Build with: mvn -Panalytics spring-boot:aot-generate native:compile
@Configuration
@Profile("analytics")
public class AdvancedAnalyticsConfig {
@Bean
public AnalyticsEngine analyticsEngine() {
return new AnalyticsEngine();
}
}
Build separate native images for different configurations. @Profile is resolved at build time. One image includes analytics, one does not. There is no runtime toggle.
CH8: JDK Dynamic Proxies Without Configuration
JDK dynamic proxies (CH8) use java.lang.reflect.Proxy.newProxyInstance(). This creates a new class at runtime. In native image, every proxy interface combination must be declared at build time.
Spring AOT handles this automatically for proxies it creates. But if your code creates JDK proxies manually, or if a library does, the proxy interfaces must be registered:
// BROKEN: manual JDK proxy without native hint
public NotificationGateway createProxy(NotificationGateway target) {
return (NotificationGateway) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
new Class<?>[] { NotificationGateway.class },
new TenantAwareInvocationHandler(target)
);
}
This throws com.oracle.svm.core.jdk.UnsupportedFeatureError at runtime.
// CORRECT: register proxy hint via RuntimeHintsRegistrar
@ImportRuntimeHints(NotificationGatewayHints.class)
@Configuration
public class GatewayConfig {
@Bean
public NotificationGateway notificationGateway(
NotificationGateway target) {
return createProxy(target);
}
}
public class NotificationGatewayHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.proxies().registerJdkProxy(NotificationGateway.class);
}
}
The Mental Model Shift
JVM Spring is a framework that builds itself. It discovers what it needs, constructs what it needs, and adapts to its environment. The cost is startup time and memory.
Native Spring is a framework that was built. The build step captured every decision. The runtime executes those decisions. The cost is build-time complexity and the loss of runtime dynamism.
Every mechanism described in chapters 1 through 25 still works. But the “when” changes. Discovery happens at build time. Conditions resolve at build time. Proxies generate at build time. Reflection requires explicit declaration.
The next sections examine AOT bean definition generation and RuntimeHints in detail.