《Spring Boot 高级》9.1 AOT 处理与生成物

拆开 process-aot 构建期的 AOT 处理:从 ProcessAotMojo 到 SpringApplicationAotProcessor、ApplicationContextAotGenerator 的调用链,讲清 target/spring-aot 下生成的源码与 native-image 配置,以及 AotDetector 如何让启动跳过反射,并给出可核实的扩展点清单。

本节目标:把 process-aot 在构建期做的事拆成一条可核实的调用链,看清它生成了哪些源码与配置、SpringApplication 在 AOT 模式下为什么能少走反射,以及扩展点到底挂在哪里。
适用版本:Spring Boot 4.1.x(Java 21)

9.1 AOT 处理与生成物

实战卷解决的是「怎么把服务跑起来」,本节解决「构建期到底预演了什么」。AOT(Ahead-Of-Time)在 Spring Boot 里不是「编译成机器码」——那是 GraalVM native-image 的活——而是把运行期才做的装配决策,提前到构建期算一遍并固化成代码。理解这一点,后面两节讲反射与原生镜像权衡时才不会混淆「Spring 的 AOT」和「GraalVM 的 AOT」两件事。

本节围绕一个最小应用 LibraryApplication 展开:它扫描到 BookRepository、LoanService 两个组件。后面 9.2 讲这些 bean 在原生镜像里为什么还需要额外声明反射,9.3 讲值不值得上原生镜像。

诚实性提示:本节所有类名、方法名、目录常量均从本机 ~/.m2 下的 spring-boot-4.1.1.jar、spring-context-7.0.9.jar、spring-beans-7.0.9.jar 与 spring-boot-maven-plugin-4.1.1.jar 用 javap / unzip 核实。本机没有 GraalVM native-image,因此凡是「构建日志」与「生成的目录树」一律标注为示例输出,不是本机实测。

9.1.1 为什么需要 AOT:封闭世界假设

JVM 是一个开放世界:类可以在运行期被反射加载、方法可以在运行期被代理、资源可以按名字动态读取。GraalVM 原生镜像反过来,假设封闭世界——构建时能静态分析到的类才会进镜像,运行期再想 Class.forName("...") 一个没被分析到的类就会抛 ClassNotFoundException。

Spring 的装配恰恰是反射密集型的:@ComponentScan 靠反射读注解、@Configuration 靠反射调工厂方法、@Autowired 靠反射注入、BeanPostProcessor 靠反射生成代理。如果什么都不做直接丢给 native-image,绝大多数 bean 会因为「不可达」被裁掉。

AOT 处理的思路是:在构建期把容器真正跑一遍(但不产生副作用),把这次运行中发生的每一条 bean 定义、每一次注入、每一个候选构造器,翻译成等价的、纯代码的装配逻辑。运行期不再靠反射扫描,而是执行这些生成的方法。这就是 Spring 的 AOT 与 GraalVM 的 AOT 的分工。

9.1.2 process-aot:构建期的一次「预演」

Maven 侧的处理入口是 spring-boot-maven-plugin 的 process-aot goal。从本机 spring-boot-maven-plugin-4.1.1.jar 的 META-INF/maven/plugin.xml 可以核实到它的元数据:

项值(plugin.xml 核实)
goalprocess-aot
实现类org.springframework.boot.maven.ProcessAotMojo
绑定阶段prepare-package
since3.0.0
依赖解析compile+runtime
测试侧对应 goalprocess-test-aot → ProcessTestAotMojo,阶段 process-test-classes

也就是说,一次 mvn package 会在打包之前自动触发 AOT 处理;不需要额外命令。要单独触发可以显式执行:

./mvnw spring-boot:process-aot

输出目录是 Mojo 里写死的常量(ProcessAotMojo 字节码字符串核实):

${project.build.directory}/spring-aot/main/sources
${project.build.directory}/spring-aot/main/resources
${project.build.directory}/spring-aot/main/classes

测试侧的 ProcessTestAotMojo 对应 spring-aot/test/... 三个目录,并由 SpringBootTestAotProcessor 驱动。

4.1 的一处行为变更值得记住:-DskipTests 不再跳过测试的 AOT 处理,要连 AOT 一起跳过得用 -Dmaven.test.skip=true。这正是 ProcessTestAotMojo 字节码里判断的 maven.test.skip 属性。

9.1.3 处理入口:SpringApplicationAotProcessor 与 ContextAotProcessor

ProcessAotMojo 最终 fork 一个 JVM 去执行处理主类。这个主类在 spring-boot 模块里,叫 org.springframework.boot.SpringApplicationAotProcessor,它的继承链(javap 核实)是:

org.springframework.boot.SpringApplicationAotProcessor
  extends org.springframework.context.aot.ContextAotProcessor
  extends org.springframework.context.aot.AbstractAotProcessor<ClassName>

关键方法:

  • SpringApplicationAotProcessor(Class<?>, AbstractAotProcessor.Settings, String[]) —— 构造器接收主应用类、groupId/artifactId 等设置、以及传给应用的处理参数。
  • protected GenericApplicationContext prepareApplicationContext(Class<?>) —— 覆写父类抽象方法,把主类包成一个 GenericApplicationContext,并注册一个 AotProcessorHook 来拦截 close()。
  • ContextAotProcessor.performAotProcessing(GenericApplicationContext) —— 真正跑装配。
  • ApplicationContextAotGenerator.processAheadOfTime(GenericApplicationContext, GenerationContext) —— 遍历 bean 定义,产出代码。

AbstractAotProcessor.process() 是模板方法,它做了三件可核实的事:

  1. 处理期间设置系统属性 spring.aot.processing=true(常量 AbstractAotProcessor.AOT_PROCESSING,值经 javap -c -constants 核实为 "spring.aot.processing"),处理结束再清掉。这样被处理的容器知道「我现在是在构建期预演,别做真实副作用」。
  2. 通过 createFileSystemGeneratedFiles() 建立输出到磁盘的 GeneratedFiles。
  3. 最后 writeHints(RuntimeHints) 把收集到的 hint 写成 native-image 配置。

9.1.4 生成物:源码、native-image 配置、properties

AOT 处理产出三类东西,落在 target/spring-aot/main/ 下:

(1)生成的 Java 源码(sources/)。核心是两类类,命名遵循约定:

生成类生成器(jar 核实)作用
<主类>__ApplicationContextInitializerApplicationContextInitializationCodeGenerator取代运行期的扫描与配置类解析
<配置类>__BeanDefinitionsBeanRegistrationsAotContribution / BeanDefinitionsRegistrationGenerator每个配置类的 bean 定义与实例供应商

BeanRegistrationCodeFragments(spring-beans 核实)定义了生成代码的骨架,它的常量 BEAN_DEFINITION_VARIABLE、INSTANCE_SUPPLIER_VARIABLE 就是生成方法里那两个局部变量名;getTarget(RegisteredBean) 决定每个 bean 的注册代码写进哪个 __BeanDefinitions 类。BeanDefinitionMethodGenerator 则负责把一个 RegisteredBean 翻成一个 @Bean 工厂方法调用。

(2)native-image 配置(resources/)。RuntimeHintsWriter(org.springframework.aot.nativex 包)把收集到的 RuntimeHints 写成 GraalVM 能读的文件,落到:

META-INF/native-image/<groupId>/<artifactId>/reflect-config.json
META-INF/native-image/<groupId>/<artifactId>/resource-config.json
META-INF/native-image/<groupId>/<artifactId>/proxy-config.json
META-INF/native-image/<groupId>/<artifactId>/serialization-config.json

(3)native-image.properties。ContextAotProcessor 里可核实的字符串显示,它会往 META-INF/native-image/ 写一个 native-image.properties,内容由 getDefaultNativeImageArguments(String) 生成。字节码里能看到它至少包含 --no-fallback 与 -H:Class=<主类> 两项——前者要求「要么成功构建原生镜像,要么失败」,不允许回退到 JVM 解释执行;后者告诉 native-image 入口类是谁。

示例输出:构建后 target/spring-aot/main/ 的大致结构(路径常量已核实,具体文件列表随应用而定):

target/spring-aot/
└── main/
    ├── sources/
    │   └── com/example/library/
    │       ├── LibraryApplication__ApplicationContextInitializer.java
    │       ├── LibraryApplication__BeanDefinitions.java
    │       └── ...
    ├── resources/
    │   └── META-INF/native-image/com.example/library/
    │       ├── reflect-config.json
    │       ├── resource-config.json
    │       └── native-image.properties
    └── classes/            # 编译后的生成类,打进最终 jar

9.1.5 生成的装配代码与运行期装配的差异

理解了生成器,再看生成代码就有感觉了。运行期装配靠 ConfigurationClassPostProcessor 反射解析 @Configuration,AOT 装配则把每个 @Bean 方法翻成一个静态的注册调用。形态大致如下:

示例输出(生成代码的具体形态由生成器与 JavaPoet 决定,此处为按 BeanDefinitionMethodGenerator 语义整理的示意,非本机实测):

// target/spring-aot/main/sources/com/example/library/
//     LibraryApplication__BeanDefinitions.java(示意)
final class LibraryApplication__BeanDefinitions {

    // 每个 @Bean 方法对应一个 BeanInstanceSupplier
    // 每个 bean 定义对应一次 registerBeanDefinition 调用
    static BeanDefinitionRegistrar registerBeanDefinitions() {
        // 由 BeanRegistrationsAotContribution 生成的注册逻辑
        return null; // 示意,实际返回填充好的 registrar
    }
}

与运行期装配的差别可以列成一张表:

维度运行期装配(JVM 默认)AOT 装配(生成物)
触发者ConfigurationClassPostProcessorAotApplicationContextInitializer
扫描反射读 classpath 注解构建期扫描结果写死
@Bean 调用反射调用配置类方法生成的直接方法调用
注入AutowiredAnnotationBeanPostProcessor 反射注入生成的 BeanInstanceSupplier 装配
条件注解运行期 Condition 求值构建期求值、结果固化

最后一行是理解 AOT 的关键:@Conditional 在构建期就被求值了。这意味着「运行期环境变了、条件本该翻转」的场景在 AOT 下不会发生——条件结果已经写进生成代码。对 profile 相关条件尤其要注意:构建时激活的 profile 决定了生成物,运行期改 profile 不改变已固化的条件分支。这也是为什么有些自动配置在原生镜像里「没生效」,根因不在 hint,而在条件求值时机。

9.1.6 SpringApplication 为什么在 AOT 模式跳过反射

SpringApplication.run() 里有一个分叉点:如果检测到「应该用生成物」,就不再走常规的 refresh() 扫描路径,而是让 AotApplicationContextInitializer 直接把 __BeanDefinitions 注册进来。

判断逻辑在 org.springframework.aot.AotDetector(spring-core 核实):

public abstract class AotDetector {
    public static final String AOT_ENABLED = "spring.aot.enabled";
    public static boolean useGeneratedArtifacts();
}

useGeneratedArtifacts() 的字节码(javap -c 核实)是「inNativeImage 或 spring.aot.enabled 为真」。其中 inNativeImage 在静态初始化时通过 NativeDetector.inNativeImage(Context.RUN, Context.BUILD) 求得。也就是说:

  • 在真正的原生镜像里运行,NativeDetector.inNativeImage() 为真,自动走生成物;
  • 在 JVM 上想验证 AOT 生成物,可以加 -Dspring.aot.enabled=true 强制走生成路径。

如果 AOT 模式开着却找不到生成物,启动会抛 AotInitializerNotFoundException(org.springframework.boot 包,jar 核实),并且有一个专门的 AotInitializerNotFoundFailureAnalyzer 给出人话提示。这解释了「为什么本地跑得好好的 jar,加了 -Dspring.aot.enabled=true 反而起不来」——因为它去找一个并不存在的 __ApplicationContextInitializer。

AotApplicationContextInitializer 的两个静态方法(javap 核实)说明了挂载方式:

static <C extends ConfigurableApplicationContext> AotApplicationContextInitializer<C>
        forInitializerClasses(String... initializerClassNames);
static ApplicationContextInitializer<C> instantiateInitializer(String, ClassLoader);

运行期它按类名实例化生成类,不再依赖 @ComponentScan 去反射读注解。

9.1.7 扩展点:两个 *AotProcessor 接口

这里要澄清一个常见误解:Spring 并没有一个叫 @AotProcessor 的注解。AOT 的扩展点是两个接口,靠 META-INF/spring/aot.factories 这个服务文件注册(不是 spring.factories)。从 spring-boot-4.1.1.jar 的 META-INF/spring/aot.factories 可以直接看到三类键:

org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.boot.ApplicationProperties$ApplicationPropertiesRuntimeHints,\
...

org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\
org.springframework.boot.context.properties.ConfigurationPropertiesBeanFactoryInitializationAotProcessor,\
...

org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\
org.springframework.boot.context.properties.ConfigurationPropertiesBeanRegistrationAotProcessor

两个处理接口的签名(javap 核实):

// 针对「单个 bean 定义」:可改注册代码,或把 bean 排除出 AOT 处理
public interface BeanRegistrationAotProcessor {
    String IGNORE_REGISTRATION_ATTRIBUTE = "...";
    BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean);
    default boolean isBeanExcludedFromAotProcessing();
}

// 针对「整个 bean 工厂」:在所有定义就绪后、实例化前介入
public interface BeanFactoryInitializationAotProcessor {
    BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory);
}

Spring Boot 自己就用这两个接口:ConfigurationPropertiesBeanRegistrationAotProcessor 为 @ConfigurationProperties 类补上绑定所需的反射 hint;ConfigurationPropertiesBeanFactoryInitializationAotProcessor 则把 ConfigurationPropertiesReflectionHintsContribution 加进最终 hint。

想自定义生成代码(例如给某个 bean 换一套注册片段),实现 BeanRegistrationCodeFragments 并包一层 BeanRegistrationCodeFragmentsDecorator 是标准做法;BeanRegistrationCodeFragments 的 getTarget、generateNewBeanDefinitionCode、generateInstanceSupplierCode 等方法(javap 核实)就是切入点。

9.1.8 构建日志里能看到什么

示例输出(本机无 GraalVM,以下为按 Mojo 行为整理的示意,非本机实测):

[INFO] --- spring-boot:4.1.1:process-aot (default) @ library ---
[INFO] Using auto-detected main class: com.example.library.LibraryApplication
[INFO] Generating AOT source files in /path/target/spring-aot/main/sources
[INFO] Generating AOT resource files in /path/target/spring-aot/main/resources
[INFO] AOT processing completed in 2.3s

要看得更细,可以把 Maven 的日志级别调高(-X)观察 ProcessAotMojo fork 出的子进程命令,那里能看到传给处理主类的 spring-boot.aot.main-class 等参数——这个属性名也是从 ProcessAotMojo 字节码里核实到的。

9.1.9 知道之后能做什么

排障定位。 原生镜像里 bean 没注册、ClassNotFoundException、注入拿到 null,第一站都是 target/spring-aot/main/sources/ 里那个 __BeanDefinitions 类:它写明了每个 bean 的定义从哪来。如果某个 bean 根本没出现在生成代码里,说明它在构建期没被容器「看到」。

强制在 JVM 上验证生成物。 用 -Dspring.aot.enabled=true 在普通 JVM 上跑生成的初始化器,能在不装 GraalVM 的前提下暴露一批 AOT 相关问题(比如某个 hint 缺失导致的反射失败)。这是把「原生镜像构建慢」这个反馈环缩短的关键手段。

写扩展点。 需要为自定义 bean 补生成逻辑,就实现 BeanRegistrationAotProcessor 并注册进 META-INF/spring/aot.factories;需要改整个工厂级别的生成物,用 BeanFactoryInitializationAotProcessor。两者都只在构建期被调用,运行期零成本。

小结

  • Spring 的 AOT 是「构建期把装配预演一遍并固化成代码」,与 GraalVM 的 native-image 编译是两件事,前者是后者的前置。
  • process-aot 绑定在 prepare-package 阶段,实现类 ProcessAotMojo,产物落在 target/spring-aot/main/{sources,resources,classes}。
  • 入口链是 SpringApplicationAotProcessor → ContextAotProcessor → AbstractAotProcessor,核心生成器是 ApplicationContextAotGenerator。
  • AotDetector.useGeneratedArtifacts() 决定是否跳过反射扫描;-Dspring.aot.enabled=true 可在 JVM 上强制验证生成物。
  • 扩展点是 BeanRegistrationAotProcessor 与 BeanFactoryInitializationAotProcessor 两个接口(不存在 @AotProcessor 注解),经 META-INF/spring/aot.factories 注册。

下一节进入 hint 层:为什么光有生成代码还不够,反射、资源与动态代理在原生镜像里各自需要怎样声明。

阅读导航:上一节:8.3 方法安全与上下文传播 · 下一节:9.2 反射与资源配置 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计