事情的起因是这样的。我们有个支付回调服务,跑在K8s上,高峰期HPA扩容的时候新Pod要等好几秒才能接流量。那几秒里用户那边的表现就是------点完支付,页面转圈,然后大概率收到一个502。 这种问题也不是第一天遇到了。之前靠预热+readiness probe的延迟配置勉强糊弄着,但每次大促前心里都没底。 上个月组里有人提了一嘴:Spring Boot 4的原生镜像不是挺火的吗,启动52毫秒,要不试试? 我当时的反应是:52毫秒?这数字也太好看了吧。 然后我就跳进了这个坑。
数字确实好看
我在本地一个小型服务上做了测试------就是一个很普通的Spring Boot Web服务,连了MySQL和Redis,大概20个Controller。用传统JAR方式启动,时间稳定在3.1秒左右。 构建原生镜像之后,启动时间52毫秒。 从3.1秒到52毫秒,快了差不多60倍。内存占用从420MB掉到了78MB。
我当时第一反应是:这玩意儿是不是把启动过程给跳过了?后来加了断点一步步看,确实是真的------所有静态初始化在构建期就跑完了。 原理简单说一下,核心就两点:一是Substrate VM这个极简运行时,没有JIT解释器,GC用的是精简版Serial,体积只有几MB,传统JVM运行时动辄几十MB的开销直接省掉了;二是对象状态在构建期就被"冻"进了可执行文件。 一句话:JVM是把启动成本摊到每次运行上,原生镜像是把启动成本提前到构建期一次性付清。
所以如果你面对的是这种场景------Serverless函数、边缘节点、CLI工具、或者像我这种突发流量型的回调服务------原生镜像确实是银弹。K8s HPA扩出来的新Pod几十毫秒就能接流量,高峰期502的问题直接消失。 看到这里你可能会想:那还等什么,直接上啊。 别急。
反射这个坑,比想象中大得多
这是整个升级过程中最耗时间的部分,没有之一。 原生镜像的核心机制叫"封闭世界假设"(Closed-World Assumption)。简单说就是:编译器从main()出发做可达性分析,能找到的类留下来,找不到的全部砍掉。一个Spring Boot应用的产物可能只有原来JAR的十分之一。 这跟Java生态里泛滥的动态特性是天然冲突的。
我碰到的第一个问题出在Jackson。我们有个DTO用了@JsonTypeInfo做多态反序列化,运行时根据JSON里的type字段决定实例化哪个子类。这在传统JVM下跑得飞起,但AOT编译的时候,编译器根本不知道这些子类会被用到------因为它们是运行时动态加载的。 结果就是:构建成功,启动也不报错,一接收请求直接抛InvalidDefinitionException。
排查这个过程我花了将近半天。因为报错信息太模糊了,只告诉你"无法构造某个类型",根本不会提示你是AOT元数据缺失。后来用GraalVM的Tracing Agent跑了一遍集成测试,让它自动生成reflect-config.json,才算找到问题根源。
json
// src/main/resources/META-INF/native-image/reflect-config.json
// 这部分是Tracing Agent自动生成的,但需要你自己跑一遍覆盖关键路径
{
"name": "com.example.payment.dto.CallbackResult",
"allDeclaredConstructors": true,
"allPublicMethods": true
}
关键是,你得知道哪些类需要加进去。Tracing Agent只能收集它"看到"的,如果你的代码里有通过字符串拼类名再Class.forName这种操作,Agent也抓不到。 我后来养成了一个习惯:每次加新功能,先跑一遍Agent,然后git diff看reflect-config.json有没有变化。如果新代码用到了反射但没被收集到,就得手动补。 这个问题在我们项目里大概涉及7-8个地方。改完一轮,心里踏实了点。然后又冒出来一个新的。
@Async代理消失了
我们有个异步通知服务,用的@Async注解。Spring默认用CGLIB动态代理来实现这个功能。传统JVM下,Spring在启动时动态生成代理类,没有任何问题。 但AOT编译时,如果这些代理类没有被提前生成,运行时就找不到------直接ClassNotFoundException。 Spring Boot 4的AOT引擎理论上会自动处理Spring自身的代理。但实际上,它只覆盖了标准的场景。如果你的@Async方法用了自定义的TaskExecutor、或者方法签名涉及泛型擦除后的复杂返回类型,AOT有可能漏掉。 我的解决办法是用@NativeHint显式声明:
less
@NativeHint(
trigger = AsyncNotificationService.class,
types = @TypeHint(
types = AsyncNotificationService.class,
access = {TypeAccess.DECLARED_CONSTRUCTORS, TypeAccess.DECLARED_METHODS}
)
)
@Configuration
public class AotHintsConfig {
// 纯粹为了给AOT编译器提供元数据
// 写这些感觉很蠢,但没别的办法
}
后来我在Spring Boot的GitHub Issues里翻了一圈,发现不少人碰到过类似问题。有个maintainer的回复挺实在的:AOT引擎在持续改进,但目前的覆盖度确实还不够,复杂场景还是得手动补hints。
线程钉住:虚拟线程的隐藏代价
这个问题跟原生镜像没直接关系,但既然升了Spring Boot 4,很多人会顺手开虚拟线程(配置spring.threads.virtual.enabled=true),所以也值得说一下。 虚拟线程有个天敌:synchronized代码块。如果你在虚拟线程里用synchronized做I/O操作(比如调HTTP接口、读写数据库),这个虚拟线程就会被"钉"在底层平台线程上,完全失去轻量级的优势。 我们项目里有个遗留工具类,里面用了synchronized做HTTP调用。升级后功能正常,但压测的时候发现并发性能反而比Java 21的非虚拟线程模式还差。排查了半天才发现是线程钉住。 改法不难------把synchronized换成ReentrantLock:
csharp
// 改之前:虚拟线程会被钉住
synchronized(lock) {
httpClient.call(); // 阻塞I/O
}
// 改之后:虚拟线程正常工作
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
httpClient.call();
} finally {
lock.unlock();
}
Java 24之后对线程钉住的检测和改善比Java 21好不少,能打印pinning的warning日志帮你定位。如果能上Java 24+,建议直接上。Spring Boot 4.0.3在JDK 24/25上跑都没问题,虽然官方声明的正式支持范围只到Java 25。
构建时间,这个代价很少有人认真算
反射和代理的问题,虽然烦,但至少改完就完了。真正影响日常开发体验的,是构建时间。 原生镜像编译一次10到20分钟起步,编译过程要吃6-12GB堆内存。这个信息我看文章的时候扫过一眼,但没什么感觉------直到我们CI直接炸了。
第一次在CI上跑构建,机器是4核8G的。跑了12分钟,OOM,整个pipeline挂掉。当时我还以为是什么内存泄漏,反复查了两遍,最后才定位到是native-image的内存需求。把CI机器加到16G才跑通。 这带来一个很现实的问题:改一行代码,等20分钟才能验证。在开发阶段这是不可接受的。所以我们现在的做法是------本地开发用传统JAR,只在CI/CD的最终构建环节做原生镜像编译。代价是本地和线上的行为不完全一致,偶尔会有"本地好好的,线上挂了"的情况,但比起每次等20分钟,这个trade-off值得做。
顺带提两个小坑:Spring Boot 4把Jackson升到了3.0,自定义JsonSerializer大概率要改代码,我们有两个编译不过,花了半天;还有个老旧的Excel处理库在原生镜像里直接启动失败,内部用了大量反射+动态类加载,最后换成Apache POI才解决。如果你的项目有这种"古董"依赖,建议提前跑一遍./mvnw spring-boot:process-aot看看有没有报错。
到底值不值
折腾了两周,目前的状态是:支付回调服务已经切到原生镜像上线了,高峰期扩容确实丝滑了很多,502基本消失了。但其他几个服务我暂时没动。 我的判断是这样的:如果你的服务是突发流量型、跑在K8s上依赖HPA、或者做Serverless,原生镜像确实能解决真问题。但如果你的服务是长期运行、流量稳定的,JIT预热之后的性能大概率比AOT好------原生镜像在这种场景下收益很有限,维护成本却是实打实的。
还有一点值得注意:如果项目大量用了反射、动态代理、运行时类加载,改造成本会非常高,而且这些hints需要随着业务变化持续维护。对于大多数中等规模Spring Boot服务的团队,虚拟线程才是4.x最值得立即用起来的改进,原生镜像可以等AOT引擎再成熟一些再说。
升级路径上建议走渐进路线:先升到3.5把所有deprecated API修掉,再跳到4.0。别直接从3.3/3.4蹦过去,风险太大。 下一步打算在预发环境跑一周压力测试,看看稳定性数据再决定是否推广到其他服务。先这样。