一个偶发的空指针
服务启动时偶发 NPE,堆栈指向缓存预热器里取到的 RedisTemplate 是 null。但 RedisTemplate 明明配好了——问题是它被用在了配置类的 @PostConstruct 里,而那时它依赖的连接工厂还没就绪。重启三四次才复现一次,最磨人。这种"时灵时不灵"的启动问题,往往就是 Bean 初始化顺序在作怪。
Bean 初始化的真实顺序
Spring 里"顺序"至少有四层,容易混淆:
- @DependsOn:强制某些 Bean 先实例化;
- @PostConstruct:当前 Bean 属性注入完成后执行;
- InitializingBean.afterPropertiesSet:同理,但接口契约更明确;
- Ordered / PriorityOrdered:影响同一批次 Bean 的排序。
很多人以为 @DependsOn("A") 之后,A 就"完全就绪"了。错。@DependsOn 只保证 A 先被实例化(构造 + 属性注入),不保证 A 自己的 @PostConstruct 已经跑完。如果 A 在 @PostConstruct 里才建连接,那 B 在 @PostConstruct 里用 A,A 的连接可能还没建好。
@DependsOn 的坑
我那次就是这样:RedisTemplate 依赖的 LettuceConnectionFactory,在它的 @PostConstruct 里才去建立到 Redis 的连接。我的预热器用 @DependsOn 声明依赖它,但预热器自己的 @PostConstruct 跑的时候,连接工厂的 @PostConstruct 还没轮到——顺序只是"实例化了",不是"初始化完了"。于是 template 拿到了,但底层连接是 null,get 一下就 NPE。
正确的写法
@Configuration
@DependsOn("redisConnectionFactory")
public class CacheWarmup {
@Autowired
private RedisTemplate<String, String> template;
@PostConstruct
public void init() {
// 此时连接工厂已实例化;
// 若还需其初始化完成,应把预热移到
// SmartInitializingSingleton.afterSingletonsInstantiated
template.opsForValue().set("warmup", "1");
}
}
更稳妥的是实现 SmartInitializingSingleton,在所有单例都初始化完之后统一做预热,避免互相依赖顺序的泥潭:
@Component
public class CacheWarmup implements SmartInitializingSingleton {
@Autowired RedisTemplate<String,String> template;
@Override
public void afterSingletonsInstantiated() {
// 这里所有单例 Bean 都已完全初始化
template.opsForValue().set("warmup", "1");
}
}
为什么 SmartInitializingSingleton 更稳
它是在容器完成所有单例实例化、包括所有 @PostConstruct 和 afterPropertiesSet 之后才回调的。所以不管你依赖的 Bean 内部初始化多绕,到这一步都保证就绪。需要"跨 Bean 协调的启动逻辑"时,这是比 @DependsOn 更安全的落点。
还有个类似的钩子是 ApplicationRunner / CommandLineRunner,它们在容器完全刷新后才跑,适合"启动后做点事"而非"启动中初始化"。预热属于初始化,用 SmartInitializingSingleton;发个启动通知之类用 Runner。
排查技巧
这类偶发问题怎么定位?我加了一个 -Dspring.main.lazy-initialization=false(默认就是 eager),再在可疑 Bean 的 @PostConstruct 里打日志,观察启动顺序。复现后看日志时间戳,谁先谁后一目了然。另外 Debug 时看 AbstractApplicationContext 的 finishRefresh 流程,能清楚看到 SmartInitializingSingleton 在所有 Bean 之后。
循环依赖的连锁反应
初始化顺序问题常和循环依赖纠缠。两个 Bean 互相 @Autowired,Spring 靠三级缓存解决,但如果你在 @PostConstruct 里调用对方的方法,可能拿到"半成品"。我们遇到过 A 的 @PostConstruct 里调 B,而 B 还没初始化完,拿到 null 字段。这类问题用 SmartInitializingSingleton 也救不了,因为循环还在。根治是拆掉循环依赖,把公共逻辑抽到第三个 Bean。顺序坑很多时候是设计坏味道的信号。
配置类的特殊顺序
@Configuration 类之间也有顺序。@AutoConfigureAfter / @AutoConfigureBefore 控制自动配置类的先后;自己写的 @Configuration 如果依赖别的配置类先就绪,可以用 @DependsOn 在配置类上。但我们更推荐用 @Conditional 系列表达"依赖关系",让 Spring 自己决定,而不是硬塞顺序。硬顺序越多,越脆,一次加依赖就可能打破假设。
一个通用排查清单
遇到启动期 NPE 或空 Bean,按这个顺序查:先确认 Bean 有没有被注册(看启动日志的 Bean 定义数);再确认是不是 @Lazy 导致没初始化;然后看 @DependsOn 只管实例化不管初始化完成;最后考虑用 SmartInitializingSingleton 挪到所有单例就绪后。九成的问题落在这四步里。别一上来就怀疑 Spring 有 bug,绝大部分是自己的初始化假设错了。
和 @Lazy 的配合
@Lazy 会让 Bean 延迟到首次使用时才初始化,这会改变你以为的"启动顺序"。我们曾给一个重 Bean 加 @Lazy 想加速启动,结果它在第一次请求时才初始化,那次请求 RT 暴涨 2 秒,还因为初始化时依赖的另一个 Bean 此时已部分回收而出错。@Lazy 是把启动成本挪到运行期,不是消除,用之前想清楚"第一次谁触发、能否承受"。预热类逻辑尤其别依赖 @Lazy 的 Bean。
一个速查口诀
记一个口诀帮团队避坑:实例看 @DependsOn,就绪看 SmartInitializingSingleton,单 Bean 内收尾看 @PostConstruct,跨 Bean 协调看后者。新人按这个对口,十有八九能对。我们把这段写进了团队的 Spring 开发规范,配了两个反例(一个用 @DependsOn 踩空、一个用 SmartInitializingSingleton 救回)当教材。规范 + 反例比口头说教管用,新服务上线后再没出过启动期 NPE。
下篇预告
这篇先把《Spring Bean 初始化顺序那些事》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。