本节目标:理解原生镜像下反射、资源加载与动态代理为什么会失败,掌握用
RuntimeHints/RuntimeHintsRegistrar/@RegisterReflectionForBinding精确声明这些能力的方法,并知道出问题后用什么命令排查。
适用版本:Spring Boot 4.1.x(Java 21)
9.2 反射与资源配置
上一节讲的 AOT 生成代码解决了「bean 怎么装配」,但没解决「代码里主动写的反射怎么办」。业务代码里常见 objectMapper.readValue(json, Foo.class)、Class.forName(className)、new URL(...).openStream()、Proxy.newProxyInstance(...),这些在 JVM 上畅通无阻的用法,在原生镜像里全都可能失效——因为封闭世界假设下,构建器无法保证这些「按名字动态访问」的目标会被保留。
Spring 的解法是 hints:一套显式声明「运行期会用到什么」的 API,AOT 处理时把它们写进 GraalVM 的配置文件,native-image 读到后就不再裁掉这些类。
诚实性提示:本节的类名、接口签名、注解属性均从本机
spring-core-7.0.9.jar、spring-context-7.0.9.jar、spring-beans-7.0.9.jar与spring-boot-4.1.1.jar用javap/unzip核实。本机没有 GraalVMnative-image,涉及构建器命令、运行期异常与配置文件的示例一律标注为示例输出。
9.2.1 三类会失败的东西:反射、资源、动态代理
先说清「为什么会失败」,才知道要声明什么。
反射。 Class.forName、getDeclaredMethod、getDeclaredConstructor().newInstance() 都依赖「类和方法在运行期可被查到」。原生镜像构建器做可达性分析时,如果一个类只被字符串引用、没有任何静态引用,它会被判定为不可达而裁掉。即使类被保留了,它的私有成员、构造器、方法也可能因未被调用而被裁剪。
资源。 getResourceAsStream("messages.properties")、new File(...)、Files.readAllBytes(...) 需要对应文件被「打进」镜像。构建器默认只打包被显式分析到的资源,动态按名字读的资源要显式声明。
动态代理。 Proxy.newProxyInstance(...) 生成的代理类在运行期由 JDK 动态生成,原生镜像里没有「运行期生成类」这回事,必须把代理所实现的接口组合在构建期登记,让 native-image 预先造好代理类。
Spring 的 AOP 默认用 CGLIB 子类代理(生成的是真实类,进镜像没问题),但 @Configuration(proxyBeanMethods = false) 之外仍有 JDK 动态代理的场景;MyBatis、Jackson 之类的库则大量依赖反射。这就是 hints 要覆盖的面。
9.2.2 RuntimeHints:五个子 hint
org.springframework.aot.hint.RuntimeHints(spring-core 核实)是 hint 的聚合根,它暴露五个子 hint:
public class RuntimeHints {
public RuntimeHints();
public ReflectionHints reflection();
public ResourceHints resources();
public SerializationHints serialization();
public ProxyHints proxies();
public ReflectionHints jni();
}
对应关系如下表:
| 子 hint | 覆盖的能力 | 典型场景 |
|---|---|---|
reflection() | 类、字段、方法、构造器的反射访问 | Jackson 反序列化、Class.forName |
resources() | 按名字或模式加载资源 | i18n、模板、ResourceBundle |
serialization() | Java 原生序列化 | ObjectOutputStream |
proxies() | JDK 动态代理 | Proxy.newProxyInstance |
jni() | JNI 访问 | 本地库回调 |
每个子 hint 又有自己的 builder。以反射为例,ReflectionHints 上常用的注册方法有 registerType(Class, MemberCategory...)、registerType(TypeReference, MemberCategory...)、registerTypeIfPresent(...) 等;TypeHint.Builder(ReflectionHints 的注册单元)还能链式声明字段、方法、构造器与调用模式。成员类别枚举 MemberCategory 与调用模式枚举 ExecutableMode 都在 org.springframework.aot.hint 包(jar 核实)。
几个容易混的成员类别:
| 枚举值 | 含义 |
|---|---|
INTROSPECT_PUBLIC_METHODS | 允许反射查看到 public 方法 |
INVOKE_PUBLIC_METHODS | 允许反射调用 public 方法 |
INTROSPECT_DECLARED_CONSTRUCTORS | 允许看到声明的构造器 |
INVOKE_DECLARED_CONSTRUCTORS | 允许调用声明的构造器(newInstance 需要) |
INTROSPECT_DECLARED_FIELDS / INVOKE_DECLARED_FIELDS | 字段的读/写权限 |
ExecutableMode.INTROSPECT 只要求方法可被查到,ExecutableMode.INVOKE 还要求可被调用——两者差别直接影响镜像大小,能少声明就少声明。
资源侧则有两个注册单元:ResourcePatternHint(按 Ant 模式匹配一组资源)与 ResourceBundleHint(ResourceBundle 专用),对应 ResourceHints 与 ResourcePatternHints 两类容器;ResourceHints 上有 registerPattern(...)、registerResourceBundle(...)。代理侧是 ProxyHints.registerJdkProxy(Class...)(内部单元 JdkProxyHint),参数就是被代理的接口列表——9.2.1 说的「代理接口组合要在构建期登记」正是指这里。
9.2.3 RuntimeHintsRegistrar:把 hint 交出去的 SPI
写 hint 的地方是 RuntimeHintsRegistrar(spring-core 核实):
@FunctionalInterface
public interface RuntimeHintsRegistrar {
void registerHints(RuntimeHints hints, ClassLoader classLoader);
}
它由 AOT 处理期通过 META-INF/spring/aot.factories 的 org.springframework.aot.hint.RuntimeHintsRegistrar 键加载。上一节我们已经看到 Spring Boot 自己的 aot.factories 里就注册了一串 registrar,例如 LogbackRuntimeHints、PropertySourceRuntimeHints、ConfigDataLocationRuntimeHints。
两种挂载方式:
(1)全局注册——在自定义库里放 META-INF/spring/aot.factories:
org.springframework.aot.hint.RuntimeHintsRegistrar=\
com.example.library.LibraryRuntimeHints
(2)按配置类注册——用 @ImportRuntimeHints(org.springframework.context.annotation 包,javap 核实,属性是 Class<? extends RuntimeHintsRegistrar>[]):
@Configuration
@ImportRuntimeHints(LibraryRuntimeHints.class)
public class LibraryConfiguration {
}
一个具体 registrar 长这样:
package com.example.library;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
public class LibraryRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection()
.registerType(Book.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS)
.registerType(Loan.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
hints.resources().registerPattern("messages/*.properties");
}
}
registerPattern 支持 Ant 风格通配,registerType 则按成员类别精确放开。RuntimeHintsRegistrar 只在构建期被调用,运行期不占成本。
9.2.4 注解式声明:@RegisterReflectionForBinding 与 @Reflective
对「某个 DTO 要被 Jackson 反序列化」这种高频场景,写 registrar 太重。Spring 提供了注解(均在 org.springframework.aot.hint.annotation 包,jar 核实):
| 注解 | 属性(javap 核实) | 用途 |
|---|---|---|
@RegisterReflectionForBinding | value()、classes()、classNames() | 为一个或多个绑定类型登记反射 hint |
@Reflective | 配合 ReflectiveProcessor | 自定义「怎么登记」的处理器 |
@RegisterReflectionForBinding 的处理类是 RegisterReflectionForBindingProcessor,它继承 RegisterReflectionReflectiveProcessor(jar 核实)。后者借助 BindingReflectionHintsRegistrar(spring-core 核实)按「数据绑定」的语义登记:不只是构造器,还包括 setter、@JsonCreator 方法等绑定入口。所以:
@Configuration
@RegisterReflectionForBinding({Book.class, Loan.class})
public class LibraryConfiguration {
}
比手写 registrar 更贴合 Jackson 的真实需求。@Reflective 则用于更一般的场景:标在自定义注解上,指定一个 ReflectiveProcessor 实现,ReflectiveRuntimeHintsRegistrar(jar 核实)会在 AOT 期扫描带该注解的类并调用处理器。
9.2.5 配置文件:从 reflect-config.json 到 reachability-metadata.json
hints 最终会被 AOT 写成 GraalVM 的配置文件。老格式是每类能力一个文件,放在 META-INF/native-image/<groupId>/<artifactId>/:
| 老格式文件 | 对应子 hint |
|---|---|
reflect-config.json | reflection() |
resource-config.json | resources() |
proxy-config.json | proxies() |
serialization-config.json | serialization() |
本机 spring-boot-4.1.1.jar 里就有一个现成的老格式示例:META-INF/native-image/log4j-generated/org.springframework.boot/spring-boot-log4j/reflect-config.json,内容形如:
[{"name":"org.springframework.boot.logging.log4j2.ColorConverter",
"methods":[{"name":"newInstance","parameterTypes":["org.apache.logging.log4j.core.config.Configuration","java.lang.String[]"]}],
"fields":[]}]
上面这条是从本机 jar 里直接读出的真实内容(
unzip -p核实),不是示例。
GraalVM 较新的版本(24 起)引入了统一格式 reachability-metadata.json,把反射、资源、代理、序列化收进一个文件。Spring 生成端正在跟进:org.springframework.aot.nativex 包里的 NativeConfigurationWriter、RuntimeHintsWriter、FileNativeConfigurationWriter、ReflectionHintsAttributes、ResourceHintsAttributes 就是这套写出逻辑的实现。具体生成为哪种格式取决于所用 GraalVM 版本,以构建时构建器实际读取的为准,不要凭记忆断定。
构建参数放在 native-image.properties。本机 spring-core-7.0.9.jar 里就带了一份真实文件(unzip -p 核实):
Args = --initialize-at-build-time=org.springframework.aot.nativex.feature.ThrowawayClassLoader \
--features=org.springframework.aot.nativex.feature.PreComputeFieldFeature
--initialize-at-build-time=<类> 表示允许该类在构建期完成静态初始化(省运行期开销,但如果静态初始化有副作用要小心);--features= 挂载 GraalVM 的 feature 扩展点。上一节提到的 ContextAotProcessor.getDefaultNativeImageArguments 生成的 native-image.properties 也会带 --no-fallback、-H:Class=<主类> 之类的参数。
9.2.6 一个从失败到修复的例子
假设 LoanService 里有一段按名字加载实现类的代码:
public LoanCalculator calculatorFor(String type) throws Exception {
Class<?> clazz = Class.forName("com.example.library." + type + "Calculator");
return (LoanCalculator) clazz.getDeclaredConstructor().newInstance();
}
JVM 上正常,原生镜像里运行到这一行会抛:
示例输出(本机无 GraalVM,异常信息为示意):
java.lang.ClassNotFoundException: com.example.library.SimpleCalculator
排查步骤:
- 在
target/spring-aot/main/sources/的生成代码里搜SimpleCalculator——搜不到,说明它没进装配。 - 在
target/spring-aot/main/resources/META-INF/native-image/.../reflect-config.json里搜SimpleCalculator——同样搜不到,说明没有 hint 覆盖它。 - 补一个 registrar:
public class LoanRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(
TypeReference.of("com.example.library.SimpleCalculator"),
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
}
}
- 用
@ImportRuntimeHints(LoanRuntimeHints.class)或aot.factories挂上,重新构建。
修复后 reflect-config.json 里会出现该类的条目。注意这里用了 TypeReference.of(String) 而非 Class:当目标类可能不在当前 classpath 时,用字符串形式的 TypeReference 才不会在构建期就抛 ClassNotFoundException——TypeReference 与实现类 SimpleTypeReference 都在 org.springframework.aot.hint 包(jar 核实)。
9.2.7 排查手段
原生镜像构建/运行出问题时,按下面顺序排查(命令为示例输出,本机无 GraalVM 未实测):
| 手段 | 看什么 |
|---|---|
构建加 -H:+ReportExceptionStackTraces | 让 native-image 在失败时打印完整栈,而不是吞掉异常 |
--trace-class-initialization=<类> | 追踪某类在构建期/运行期何时初始化,定位 --initialize-at-build-time 该不该加 |
--initialize-at-run-time=<类> | 把「构建期初始化会出问题」的类推迟到运行期初始化 |
运行加 -Dspring.aot.enabled=true | 在 JVM 上强制走生成物,提前暴露 hint 缺失导致的反射失败 |
构建加 --exact-reachability-metadata | 严格校验声明的 hint 是否都真正可达,揪出多余/遗漏 |
最常用的组合是「先在 JVM 上跑一遍 AOT 生成物」:不装 GraalVM 也能发现一多半问题,因为生成物本身的正确性与 GraalVM 无关,只有「封闭世界裁剪」这一步才需要构建器。
9.2.8 知道之后能做什么
给自定义库配 hint。 如果你写的是一个会被别人引入的库,且内部用了反射,一定要带 META-INF/spring/aot.factories 注册自己的 RuntimeHintsRegistrar——否则每个使用方都得手动补配置。这是库作者的责任边界。
优先注解、其次 registrar。 数据绑定类用 @RegisterReflectionForBinding,语义最准;其余场景用 registrar。能用 ExecutableMode.INTROSPECT 就不要用 INVOKE,能用具体类名就不要用通配模式——每一条多声明的 hint 都会增大镜像并拖慢构建。
把「运行期反射」当设计信号。 如果一段代码反复需要 hint 才能工作,说明它用了构建器难以静态分析的动态特性,值得考虑改成更静态的写法(例如用 @ConfigurationProperties 绑定替代手写反射)。
小结
- 原生镜像失败的三类根因是反射、资源、动态代理——封闭世界裁剪掉了「只被字符串引用」的目标。
- hints 的聚合根是
RuntimeHints,五个子 hint 是reflection()/resources()/serialization()/proxies()/jni()。 - 声明的三条途径:
RuntimeHintsRegistrar(经META-INF/spring/aot.factories或@ImportRuntimeHints挂载)、@RegisterReflectionForBinding、@Reflective。 - 写出格式分老的四文件(
reflect-config.json等)与新版统一的reachability-metadata.json,取哪个取决于 GraalVM 版本。 - 排查首选「JVM 上开
spring.aot.enabled验证生成物」,再上--trace-class-initialization之类的构建器选项。
下一节把视角拉到收益与代价:原生镜像到底能省多少启动时间和内存,又要付出什么,以及什么时候不该上。
阅读导航:上一节:9.1 AOT 处理与生成物 · 下一节:9.3 原生镜像的构建与权衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。