背景:百度说@SpyBean/AOP/循环依赖冲突的场景,在Spring Boot 3.5可以用@MockitoSpyBean解决,但实际是问题并没有完全解决,而是变得更隐蔽更难查
Spring Boot 2.7 时代同样的场景(@SpyBean + AOP + 循环依赖)会在启动期直接抛
BeanCurrentlyInCreationException,快速失败;Spring Boot 3.5.6 同样的代码却"启动成功",有时没有任何问题,正常运行,但有时,在运行期 、在特定调用路径上会抛NPE------启动期的错误被吞掉运行时才抛,从而使问题变得更隐蔽、更难查。版本:Spring Boot 3.5.6 / Spring Framework 6.2.11 / @MockitoSpyBean(Spring Boot 3.4+ 的新注解,替代 Spring Boot 2.7 的 @SpyBean)。
1 术语约定
| 记号 | 含义 |
|---|---|
order / pay |
beanName(小写) |
raw |
bean 的原始实例(原对象,未经任何包装) |
OrderServiceImpl / PayServiceImpl |
bean 对应的类 |
order@1 / pay@1 |
第一轮构造出的实例 |
order@2 / pay@2 |
第二轮重建出的实例 |
earlyBeanReference(x) |
x 未创建完成时被提前暴露给其他 bean 的引用(getEarlyBeanReference 钩子链的产出) |
override(x) |
spy(WRAP 策略)创建的包装对象(快照副本代理),x是被override的对象 |
aop(x) |
AOP 代理,x是被aop的对象 |
| 双切 bean | 同时被 @MockitoSpyBean 注解和被 AOP 切中的 bean |
| 包装器 | 在 getEarlyBeanReference / postProcessAfterInitialization 两条钩子链上做"包装 + 记账"的后置处理器(本文指 WrapEarlyBeanPostProcessor 与 AbstractAutoProxyCreator) |
2 先说结论
Spring Boot 3 中,存在循环依赖时,@MockitoSpyBean 在某些情况下仍会与 AOP 冲突,且与 Spring Boot 2.7 的表现不同:Spring Boot 2.7 是 fast-fail (有问题直接终止启动);Spring Boot 3.5.6 把问题留到运行期才爆,潜在危害更大。
异常处理机制差异(不管什么原因抛的异常,两版对"第一轮抛异常"的处理方式不同):
- Spring Boot 2.7:第一次遍历抛出异常,启动直接终止
- Spring Boot 3.5.6:第一次遍历抛出的如果是
BeanCurrentlyInCreationException,会被 try-catch 吞掉,然后遍历下一个 bean;如果是其他异常,同样直接抛出、启动终止
双切 bean 场景的差异(双切 = 同时被 spy 注解和 AOP 切中):
-
Spring Boot 2.7(@SpyBean):被提前暴露的 bean(简单环中即环起点,见下文"提前暴露规律")是双切 bean 时必定抛
BeanCurrentlyInCreationException,启动直接终止 -
Spring Boot 3.5.6(@MockitoSpyBean):被提前暴露的 bean是双切 bean 时同样必定抛
BeanCurrentlyInCreationException,但第一次异常被吞掉,从beanNames[]的下一个 beanName 开始下一处理轮(注意:按beanNames[]顺序遍历,不是按环上顺序)。假设这个bean恰好也是环上的bean,则此轮就是本文所述的同一个环第二轮,在第二轮中如果本轮被提前暴露的 bean(简单环中即第二轮环起点;复连通环中一条递归链可暴露多个 bean,判据对每个逐一适用,见"提前暴露规律")是双切 bean,那么:- 启动可以成功(异常被吞);
- 对这些双切 bean 的插桩(stub)必定失败;
- 这些双切 bean 的 override 快照副本是提前暴露时刻(字段填充中途)拷贝的,尚未注入的依赖字段都是 null------测试时只要访问了这些没注入的字段,就必定抛 NPE(已注入的字段不受影响,这也正是症状"只有特定调用路径炸"的原因)。
如果第二轮被提前暴露的 bean 都不是双切 bean → 第二轮正常重建,一切正常(详见 5.3)。
3 循环依赖与earlyBeanReference 的提前暴露
3.1 循环依赖示例
beanNames[] 是记录所有 beanName 的数组。假设有 a、b、c 三个 bean,beanNames[] = [a, c, b]。a 依赖 b,b 依赖 c。a 先启动,则 a 是环上第一个 bean,c 是环上最后一个 bean,getBean 顺序为 a -> b -> c -> earlyBeanReference(a):
getBean(a):三级缓存都没有a→ 创建a的原始实例 → 放入三级缓存(singletonFactories,即 EarlyBeanReference 工厂)→ 属性填充时发现依赖b→getBean(b)getBean(b):三级缓存都没有b→ 创建b原始实例 → 放入三级缓存 → 属性填充发现依赖c→getBean(c)getBean(c):三级缓存都没有c→ 创建c原始实例 → 放入三级缓存 → 属性填充发现依赖a→getBean(a)- 再次
getBean(a):一级缓存没有,但三级缓存有a的工厂 → 调用工厂(如有 AOP 则提前生成代理)→ 拿到a的 earlyBeanReference(early reference)→ 升级放入二级缓存 →c注入这个earlyBeanReference c完成初始化:initializeBean(@PostConstruct 等)→ 放入一级缓存,从二、三级缓存删除 →c是完全初始化完成的b注入c:此时getBean(c)一级缓存里已是完全体c,注入的是最终实例,不是earlyBeanReferenceb完成初始化 → 一级缓存a注入b:同理,拿到的是完全体b→a完成初始化 → 一级缓存
第一次遍历即为 a -> b -> c -> earlyBeanReference(a)。
3.2 提前暴露 earlyBeanReference 的规律(对任意环都成立的通用结论)
Spring 不识别"环"这个单位,它只沿依赖递归 getBean,环是被递归回访"顺手"解开的。因此不是"一个环一个环地创建",而是按 getBean 递归链为单位创建。通用规律:
一条递归链上,所有"别人需要它时它自己还没做完"的 bean 都会被提前暴露;被提前暴露的 bean 可以不止一个。
两种典型形态:
- 简单环 (每个 bean 只属于一个环,本例
a -> b -> c -> earlyBeanReference(a)即是):只有第一个开始创建的 bean(递归链起点)会被提前暴露------链上其他 bean 都是"完全做完才被注入"(走一级缓存)。此时"环起点 = 被提前暴露的 bean",两者可以互换使用。 - 共享 bean 的复连通环 :多个环共享 bean 时,一条递归链可以同时解开多个环,每个环的起点都可能被提前暴露 。例如
a -> b、b -> c、c -> a(环 1)加上b -> d、d -> b(环 2,b被两个环共享):
text
getBean(a) → getBean(b) → getBean(c) → 需要 a(a 正在创建中)→ earlyBeanReference(a) ← 暴露 a(环 1 起点)
c 完成 → 回到 b 继续 populate → getBean(d) → 需要 b(b 还在创建中,它在等 c/d)→ earlyBeanReference(b) ← 暴露 b(环 2 起点)
d 完成 → b 完成 → a 完成
一次递归解开了两个环,a 和 b 都被提前暴露------不是先创建完环 1 再创建环 2,而是 b 横跨两个环,创建到一半既喂了 c 又喂了 d。
本文 5.2 主线场景(order/pay 两 bean 依赖环)是简单环**,因此后文判据在简单环下可表述为"环起点是否双切";推广到任意环,判据一律表述为"被提前暴露的 bean 是否双切"(简单环中它恰好就是环起点)。
4 与 Spring Boot 2.7 的关键差异总览
| 维度 | Spring Boot 2.7(Spring 5.3) | Spring Boot 3.5.6(Spring 6.2.11) |
|---|---|---|
| 注解 | @SpyBean / @MockBean | @MockitoSpyBean / @MockitoBean(spring-boot-test 内新体系,走 BeanOverride 机制,但处理流程和冲突原因与 @SpyBean 相同) |
| Spy 处理器 | MockitoPostProcessor$SpyPostProcessor | spring-test 的 MockitoBeanExecutionListener → WrapEarlyBeanPostProcessor(WRAP 策略,@MockitoSpyBean 专属;@MockitoBean 走 REPLACE 策略的 ReplaceBeanPostProcessor,与 spy 无关,见下"差异的边界") |
| 冲突表现 | preInstantiateSingletons 无 catch,异常直接抛出,refresh 失败,一轮构造 | preInstantiateSingleton 新增 catch (BeanCurrentlyInCreationException)(本为并发 bootstrap 设计),异常被吞,只打一条 INFO 日志,两轮构造后"启动成功" |
| 最终症状 | 启动期 BeanCurrentlyInCreationException,快速失败 |
启动"成功";运行期走特定路径爆 NPE(环上 bean 字段里装的是坏掉的提前暴露 override------对方 bean 在提前暴露时刻、字段还没填充时拷贝的快照副本) |
两版底层机制的关键点在 spy 的生成:2.7(Mockito 2.x)默认 subclass MockMaker(SubclassByteBuddyMockMaker),ByteBuddy 运行时生成被代理类的子类实例并把 spiedInstance 的字段值逐个拷贝进去("继承 + 字段快照拷贝");3.5.6(Mockito 5.x)默认 inline MockMaker(InlineByteBuddyMockMaker),通过 Instrumentation API 重新转换原类字节码做方法插桩,spy 时 new 一个同类型新实例(Objenesis 实例化 + 字段拷贝),不再生成子类。两版 spy 生成的共同点:Mockito.spy(instance) 都不复用传入实例,而是另建新实例并逐字段拷贝快照------提前暴露时刻拷到的就是"字段还没填充"的半成品快照,这正是 3.5.6 运行期 NPE 的根源(坏快照副本里未注入字段为 null);2.7 也存在这个问题,但是 2.7 启动时就因为包装器之间的记账冲突(见 6.1)导致启动失败提前终止了,根本等不到运行时抛 NPE
WRAP / REPLACE 两种策略只对应旧版两种注解,不是 spy 的两种策略:
- WRAP:
@MockitoSpyBean专属(对应旧版 @SpyBean)------原 bean 走完整创建流程,钩子里现场包装; - REPLACE:
@MockitoBean专属(对应旧版 @MockBean)------原 bean 的定义被替换、真实实例永不被创建,也就不进钩子链、不与 AOP/循环依赖冲突。
5 原理与实际启动流程
5.1 包装器记账冲突的根源(与 Spring Boot 2.7 相同的部分)
两个互不知晓的包装器在同样的两个钩子 (getEarlyBeanReference / postProcessAfterInitialization)上做"包装 + 记账",冲突由此产生。钩子链是流水线:上一个钩子的返回值是下一个钩子的入参。
getEarlyBeanReference 阶段(钩子链起始参数是 raw bean):
WrapEarlyBeanPostProcessor(@MockitoSpyBean WRAP 策略,HIGHEST_PRECEDENCE 排最前):收到 bean 本身 → 记账(map.put(beanName, raw))→Mockito.spy用settings.spiedInstance(instance)创建override(raw)------拷贝这个 bean 当时的字段值 (注意:拷贝发生在提前暴露时刻、属性填充完成之前,所以 override 拷到的是"半成品"------尚未注入的依赖字段都是 null;至于更早注入的字段(如构造器注入),则已被拷入、非 null)AbstractAutoProxyCreator(所有 AOP 的汇聚点):收到override(raw)→ 记账(map.put(beanName, override(raw)))→ 包一层 AOP → 流出aop(override(raw))
postProcessAfterInitialization 阶段(钩子链起始参数同样是 raw bean):
WrapEarlyBeanPostProcessor:收到 raw bean → 对账(map.get(beanName) == raw bean→ 对账成功,认为 bean 没有被改变)→ 什么也不做,直接把 raw bean 丢给下个钩子AbstractAutoProxyCreator:收到 raw bean → 对账(map.get(beanName) != raw bean→ 对账失败,认为 bean 被人改变了)→ 重新包一层 AOP → 返回aop(raw)
doCreateBean 收尾自检:
earlyBeanReference(bean)= 循环依赖中其他 bean 拿到的引用 = getEarlyBeanReference 阶段钩子链产出的最终对象aop(override(raw))exposedObject(bean)= 容器里最终的引用 = postProcessAfterInitialization 阶段钩子链产出的最终对象aop(raw)≠ bean(裸 raw)earlySingletonReference != null(本 bean 被提前暴露过,有人拿到过提前暴露产物)且收尾链交出的不是裸 bean(exposedObject != bean)→ 才进入检查分支 → 认为可能有其他 bean 拿到的引用不是最终的 → 查dependentBeanMap,看是否有依赖当前 bean 的其他 bean:- 没有 → 忽略(没人依赖这个 bean,即使不一样也没问题)
- 有 → 认为有人持有了与最终对象不一致的 earlyBeanReference → 如果没有开启 allowRawInjectionDespiteWrapping → 抛
BeanCurrentlyInCreationException
注意:自检不直接比较 earlyBeanReference 与 exposedObject 是否相等。放行条件只有 exposedObject == bean(收尾链原样放行裸 bean)这一个:满足时直接采纳 earlyBeanReference 作为最终对象(哪怕它与收尾链产物本就不同也不检查);不满足且有人依赖时才抛异常。为什么要先看 earlySingletonReference != null、再查 dependentBeanMap(而不只看前者) :提前暴露产物可能"生成了、但没人持有"------如果创建过程中 bean 因异常被销毁,销毁会连带清掉 dependentBeanMap 里的依赖账目(依赖它的 bean 也会一起销毁重建),而提前暴露产物早已交出去、不会随之回收,于是出现"earlySingletonReference != null 但账本为空"的局面;这正是本文阶段 4.4 静默放行的成因。所以 earlySingletonReference != null 只能说明"暴露过",不能说明"现在还有人持有";还要再查 dependentBeanMap,真的有 bean 依赖它才判定冲突、抛异常,没有则说明无人持有提前暴露产物,直接放行。
下面按实际启动时间线走一遍 Spring Boot 3.5.6 完整流程。场景假设:有一个测试类 TestC,注入了 order 和 pay 两个 bean,都用了 @MockitoSpyBean 注解;order 和 pay 组成依赖环(两 bean 简单环),且都是双切 bean。假设 order 先创建,则环流程为 order -> pay -> earlyBeanReference(order)。
5.2 实际启动流程:遍历到 order → 收尾自检失败报错 → 清账 → catch 吞错 → 遍历到 pay
先看源码骨架(DefaultListableBeanFactory,Spring 6.2.11;preInstantiateSingletons / preInstantiateSingleton / instantiateSingleton / getBean / doGetBean / createBean / doCreateBean 合并、省略与本事故无关的 background-init 分支,缩进表示调用层级)------后面所有剧情都发生在这个方法里:
java
// refresh() 的 finishBeanFactoryInitialization 阶段调进来
public void preInstantiateSingletons() throws BeansException {
List<String> beanNames = new ArrayList<>(this.beanDefinitionNames); // 拷贝全部 bean 名
for (String beanName : beanNames) { // 按顺序逐个遍历
RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
if (!mbd.isAbstract() && mbd.isSingleton() && !mbd.isLazyInit()) { // 非抽象 && 单例 && 非懒加载
try {
getBean(beanName); // new → 三级缓存工厂 → 填充字段 → 钩子链 → 收尾冲突自检
}
catch (BeanCurrentlyInCreationException ex) {
// Boot 3.4 起引入并发 bootstrap,才新增了这个 catch(以前单线程,直接向上抛)
// 注意:这里只捕获 BeanCurrentlyInCreationException 这一种类型:
// 吞掉它 → for 循环不中断,继续遍历下一个 bean(整场事故的舞台就在这里)
// 其他任何异常照样向上抛出并中止启动(即 Boot 2.7 的行为,不变)
logger.info("Bean '" + beanName + "' ... skipping it in mainline thread");
}
}
}
}
阶段 1:遍历到 order(整环以 order 为起点初始化)→ 冲突检测命中 → 报错
1.1 preInstantiateSingletons 循环到 order → doCreateBean(order):
createBeanInstance:new 出order@1(半成品,字段全空)addSingletonFactory:三级缓存放入 getEarlyBeanReference 工厂(备着)
1.2 populateBean:order@1 需要 pay → 递归 getBean(pay) → doCreateBean(pay):
- new 出
pay@1→ populate 反向要order→ 三级缓存命中order的 earlyBeanReference工厂 → 触发 order@1的getEarlyBeanReference 钩子链 (首个钩子收到的参数是 raw bean,即order@1):WrapEarlyBeanPostProcessor:收到order@1→ 记账map[order] = order@1→ 交出override(order@1)AnnotationAwareAspectJAutoProxyCreator:收到override(order@1)→ 记账earlyProxyReferences[order] = override(order@1)→ 交出aop(override(order@1))
pay@1到手的正是aop(override(order@1)),填充自己的 orderService 字段- ★ 注意:
pay@1虽然也是双切 bean,但它没有被提前暴露 (无人请求它的 earlyBeanReference),所以只走 postProcessAfterInitialization 钩子链、不走 getEarlyBeanReference 钩子链------spy 在收尾阶段才包装它(此刻字段已填充,快照是好的),所以不会像 order@1 那样出现"提前暴露时包一次、收尾时又包一次、两次包出来的对象对不上"的问题,因此pay@1自己是正常的。真正潜在的问题是它的字段里装的是提前暴露的aop(override(order@1)))而override(order@1)里面拷贝的字段都是空的,后续调用会抛NPE - ★ 在
pay@1获取order的过程中会用到两个 Map:injectionMetadataCache:记录当前 bean 需要注入哪些 bean(即当前 bean 依赖哪些 bean)。 在pay@1获取order时先查injectionMetadataCache[pay],命中则直接返回;不命中则构建并记一笔injectionMetadataCache[pay] += orderdependentBeanMap:记录哪些 bean 依赖当前 bean。当injectionMetadataCache没有命中时他才会再记一笔dependentBeanMap[order] += pay,表示pay会依赖order(后续处理order冲突时就会检查这个)
1.3 pay@1 收尾 postProcessAfterInitialization(pay@1 未被提前暴露,首钩子收到裸 pay@1 → spy 正常包装,拷贝的是已填充完的实例)→ 产出 aop(override(pay@1)) 入一级缓存 → 回溯后把 aop(override(pay@1)) 注入 order@1 → order@1 构造完成,走完 initializeBean → order@1的postProcessAfterInitialization 钩子链 (首个钩子收到的参数是 raw bean,即 order@1 本身,而不是 getEarlyBeanReference 阶段产生的 aop(override(order@1))):
WrapEarlyBeanPostProcessor:收到order@1→ 对账map[order] = order@1,与 getEarlyBeanReference 阶段收到的参数相同 → 对账成功,直接放行order@1(不再包装)AnnotationAwareAspectJAutoProxyCreator:收到order@1→ 对账earlyProxyReferences[order] = override(order@1) != order@1,与 getEarlyBeanReference 阶段收到的参数不同 → 对账失败 → 误判"尚未包装" → 交出重包的aop(order@1)(不含 override)
1.4 doCreateBean 收尾自检 (AbstractAutowireCapableBeanFactory ~633 行):
earlyBeanReference(order@1)= 其他 bean 拿到的引用 = getEarlyBeanReference 阶段钩子链产出的最终对象aop(override(order@1))exposedObject(order@1)= 容器里最终的引用 = postProcessAfterInitialization 阶段钩子链产出的最终对象aop(order@1)≠ bean(裸 order@1)earlySingletonReference != null(order 被提前暴露过,pay 拿到过提前暴露产物)且收尾链交出的不是裸 bean(exposedObject != bean)→ 进入检查分支 → 查dependentBeanMap,发现有 bean 依赖order(查到pay)→ 冲突 → 抛BeanCurrentlyInCreationException
阶段 2:冲突 → destroySingleton 连锁清账
2.1 清理 dependentBeanMap。doCreateBean(order) 的 finally 里 destroySingleton("order") → destroyBean(DefaultSingletonBeanRegistry:779):
dependentBeanMap[order] = {pay@1},表示pay@1依赖order(在getBean(pay)时顺带登记的)- 先
dependentBeanMap.remove("order"),移除order登记的账目 - 依赖
order的 bean 也要一起销毁:pay@1持有order@1的引用,pay@1依赖order@1,所以pay@1也要销毁(不然pay@1就是有问题的对象)。接着destroySingleton(pay):移除dependentBeanMap[pay],销毁pay@1;返回的依赖集合 {order@1} 已销毁过,递归直接结束 - 结果:环上所有 bean 在
dependentBeanMap中的条目全部移除,后续dependentBeanMap.get("order"/"pay")都返回空;此外order@1/pay@1的实例也会从一/二/三级缓存移除(removeSingleton) - ★ bean 销毁后清除它在
dependentBeanMap中的账目,是理所当然的操作
2.2 但另一半账本 injectionMetadataCache 没清、也永远不会清。它只在 BeanDefinition 重注册(resetBeanDefinition)时才清,而重建轮 definition 没变:
injectionMetadataCache[pay]里pay -> order的注入元数据仍在 → 到了后面的阶段 4(第二轮),pay@2获取对order的注入元数据时直接缓存命中,也就不会再记一笔dependentBeanMap[order] += pay。这就是 Spring Boot 3.5.6 第二轮 doCreateBean 收尾自检时不再报错的原因(此处埋下的因,在阶段 4.2 才兑现)
阶段 3:异常被 catch 吞掉(Spring Boot 2.7 与 3.5.6 的分水岭)
3.1 DefaultListableBeanFactory#preInstantiateSingleton(~1185-1193 行):
java
private CompletableFuture<?> preInstantiateSingleton(String beanName, RootBeanDefinition mbd) {
try {
instantiateSingleton(beanName);
}
catch (BeanCurrentlyInCreationException ex) {
logger.info("Bean '" + beanName + "' marked for pre-instantiation (not lazy-init) " +
"but currently initialized by other thread - skipping it in mainline thread");
}
return null;
}
3.2 这个 catch 本为并发 bootstrap(后台启动的 bean 与主线竞争)设计,而 doCreateBean 收尾自检抛的恰好也是这个异常类型,被一起吞了。
3.3 效果:本轮 beanName 对应的 bean 被 destroy 了,但主线循环不中断,会继续遍历 beanNames[] 的下一个 bean。因为是循环依赖,本轮被 destroy 的 bean 还是会在下次遍历到环上某个 bean 时被循环创建出来,所以不用担心它不会被创建。
3.4 同时打出那条误导性的 INFO 日志("other thread")------实际全程单线程,真实原因是 spy 与 AOP 冲突。
阶段 4:遍历到 pay(整环以 pay 为起点重新初始化)→ 同样的冲突检测,这次静默放行
4.1 getBean(pay) → doCreateBean(pay),new 出 pay@2 → pay@2 populate 需要 order → 触发 order 重建 → doCreateBean(order),new 出 order@2:
order@2populate 需要pay时,pay@2还在创建中(它正在等 order)→pay@2被提前暴露 (无人请求order@2的 earlyBeanReference,但order@2请求了pay@2的 earlyBeanReference)→ 走pay@2的 getEarlyBeanReference 钩子链:WrapEarlyBeanPostProcessor收到pay@2→ 记账map[pay] = pay@2→ 交出override(pay@2);AnnotationAwareAspectJAutoProxyCreator收到override(pay@2)→ 记账earlyProxyReferences[pay] = override(pay@2)→ 交出aop(override(pay@2))order@2到手的正是aop(override(pay@2))------这个 override 是pay@2的快照副本(拷贝时刻在提前暴露时刻,pay@2的 order 字段还没填充,所以快照副本里该字段为 null)order@2完成注入 → 收尾自检时无人请求过order@2的 earlyBeanReference (三级缓存里 order 的提前暴露工厂已随第一轮销毁清掉)→ order@2 的 earlyBeanReference 为 null → 提前暴露比对跳过 →order@2干净完成、放入一级缓存
4.2 回到 pay@2 的 populate:需要的 order@2→ ★ 直接命中 injectionMetadataCache[pay] → getBean(order)->一级缓存里拿到完全体的order@2:
registerDependentBeans被if (!this.cached)守卫短路 →dependentBeanMap维持为空(dependentBeanMap[order/pay]在第一轮已被清掉,这一轮也不会补登)
4.3 pay@2 收尾 postProcessAfterInitialization:
WrapEarlyBeanPostProcessor:对账map[pay] = pay@2,与 getEarlyBeanReference 阶段收到的参数相同 → 对账成功,直接放行pay@2(不再包装)AnnotationAwareAspectJAutoProxyCreator:对账earlyProxyReferences[pay] = override(pay@2) != pay@2→ 对账失败 → 误判"尚未包装" → 交出重包的aop(pay@2)(不含 override)→ 身份不匹配再次发生
4.4 doCreateBean 收尾自检------同一个位置,这次不抛了:
earlySingletonReference != null(确实被提前暴露过)、exposedObject != bean(postProcessAfterInitialization确实又包装了一次),但hasDependentBean(pay) == false------它查的dependentBeanMap在阶段 1 结束时被清空、阶段 4 又因cached短路不会被补上。dependentBeanMap[pay]为空表示没有 bean 依赖pay,所以即使 earlyBeanReference 和最终对象 exposedObject 不一致也没关系------它认为没有任何 bean 使用这个 earlyBeanReference。于是跳过检测,直接把aop(pay@2)放进一级缓存,后续其他 bean 创建时注入的就是aop(pay@2)- 但实际上
order@2持有的是aop(override(pay@2)),所以此时这个检测是错误的,这个放行是致命的
阶段 5:终局------启动"成功" + 运行期 NPE
5.1 所有 bean"就位",Started Application,全程无 ERROR 日志。
5.2 容器最终单例(getBean 拿到的):
- 其他 bean(如本例的 TestC)持有的是最终的
aop(pay@2),而order@2持有的是aop(override(pay@2)) - 所以 TestC 中测试时针对 pay 插桩是没有用的
- TestC 中的 order 为
aop(override(order@2)),这本身没问题;但当 TestC 调用 order,order 内部再调用 pay 时就会抛 NPE------因为往 order 注入 pay 的 earlyBeanReference 时,pay 的 earlyBeanReference 钩子链中 WrapEarlyBeanPostProcessor 创建了一个新的 pay 对象并拷贝原对象的字段,此时原 pay 对象还在属性填充中途:拷贝发生在填充完成之前,尚未注入的字段(本例的 order 字段)拷到的是 null;构造器注入等更早完成的字段则已被正常拷入。所以 order 内部调 pay、pay 再访问自己那部分未注入字段时,就会抛 NPE;访问已注入字段则正常
5.3 只有调用链走到坏副本(override)的未注入字段时才会抛 NPE,其他路径一切正常:
- 方法链路:
OrderService.orderA()内部调payService.payA(),PayService.payA()又反过来调this.orderService.orderB()------即orderA() → payA() → orderB(),链路跨回了 order 自己 - NPE 举例(从外到内逐层代理):
- TestC 调
this.orderService.orderA(),实际是aop(override(order@2)).orderA()(order 的 AOP 代理) - order 的 AOP 代理转发进
override(order@2).orderA()(无插桩则调底层对象)→order@2.orderA() order@2内部调this.payService,它装的是阶段 4.1 注入给 order@2 的 pay earlyBeanReference:aop(override(pay@2))(pay 的 AOP 代理)- pay 的 AOP 代理转发进
override(pay@2).payA()(pay 的 Mockito spy 包装) - pay 的 spy 包装包的是 pay@2 提前暴露时刻拷贝出的快照副本(不是 pay@2 本身)------拷贝时 pay@2 还没注入 orderService,所以 pay 的快照副本里
this.orderService = null - pay 的快照副本执行
payA(),走到this.orderService.orderB(),此时副本的this.orderService为 null → 抛 NPE
- TestC 调
- 其他路径一切正常:比如
orderA() → payB(),调到 pay 后不再回头调 order,就不会走到 pay 的快照副本里this.orderService这个空字段------只有像orderA() → payA() → orderB()这样最终用到 pay 快照副本空字段的路径才会炸,所以症状比较隐蔽 - 注意爆出时机可能比"测试方法执行期"更早:实测中,测试实例注入阶段(
BeanOverrideTestExecutionListener.injectFields往测试类字段注入 spy 时触发的调用链)就可能提前踩到坏副本的空字段而抛 NPE------凡是任何代码走到坏副本未注入字段的路径都会炸,不限时机
5.3 不冲突的场景:并非所有情况都会出问题
前面阶段 1-5 是出问题 的完整链路,但并不是所有"spy + AOP + 循环依赖"的场景都会走到坏结局。关键点是第二轮中被提前暴露的 bean 是不是双切 bean。如果不是,则第二轮正常重建,一切正常。
(本文 5.2 主线是简单环,"被提前暴露的 bean"即第二轮环起点;共享 bean 的复连通环中一条递归链可暴露多个 bean,判据对每个被提前暴露的 bean 逐一适用,见"提前暴露规律"。)
场景 A:环上只有一个双切 bean(如只有 order 双切,pay 仅被 AOP 切中)
第一轮与阶段 1-3 完全相同(order 双切 → 抛异常 → 清账 → 吞错),分水岭在第二轮:
A.1 遍历到 pay(非双切)为环起点开始第二轮 → doCreateBean(pay),new 出 pay@2 → populate 反向要 order → 触发 order@2 重建。
A.2 order@2 重建中 populate 要 pay → 请求 pay@2 的 earlyBeanReference → 走 pay@2 的 getEarlyBeanReference 链------★ 链上没有 spy 钩子 (pay 不被 @MockitoSpyBean 处理),只有 AnnotationAwareAspectJAutoProxyCreator:收到 pay@2 本身 → 记账 earlyProxyReferences[pay] = pay@2 → 交出 aop(pay@2)(注意:包的是 pay@2 本身,不是 override(pay@2))。
A.3 order@2 注入 aop(pay@2) 后,无人请求 order@2 的 earlyBeanReference → order@2 干净完成、放入一级缓存(同阶段 4.1)。
A.4 pay@2 收尾 postProcessAfterInitialization:AnnotationAwareAspectJAutoProxyCreator 对账 earlyProxyReferences[pay] = pay@2 == 收到的 pay@2 → 对账成功 → 不重包 → 直接放行 pay@2。
A.5 doCreateBean 收尾自检:收尾链原样放行了裸 pay@2(exposedObject == bean,未被重包)→ 直接走"采纳提前暴露产物"分支,aop(pay@2) 成为最终单例 → 检查直接通过(甚至不需要查 dependentBeanMap)。
A.6 而唯一的双切 bean order:第二轮中 order@2 没有被提前暴露 (提前暴露的只有 pay),它的 spy 包装发生在收尾 postProcessAfterInitialization 阶段------此刻 order@2 字段已填充完毕,override(order@2) 拷贝的是完整的实例 (不是阶段 5 那种坏快照------拷贝发生在填充中途,未注入字段拷到的是 null),spy 正常生效,容器最终单例 = aop(override(order@2)),所有 bean 拿到的引用一致。
结果:启动成功,spy 生效,运行期无 NPE,一切正常。
场景 B:环上有多个双切 bean,但第二轮环起点不是双切 bean
以三 bean 环 a -> b -> c -> earlyBeanReference(a) 为例,a、c 双切,b 非双切:
- 若
beanNames[] = [a, c, b]:第一轮遍历到a(双切,环起点)→ 抛异常 → 清账 → 吞错(同阶段 1-3);第二轮从c开始------c也是双切,作为第二轮环起点被提前暴露 → 同阶段 4-5,仍会出问题 - 若
beanNames[] = [a, b, c]:第一轮同样在a处抛异常;第二轮从b(非双切)开始 →b -> c -> a -> earlyBeanReference(b),被提前暴露的只有b(非双切)→ 同场景 A.2-A.5,对账成功、无坏副本,第二轮正常重建,一切正常
即:双切 bean 在第二轮中没有被提前暴露,提前暴露链上交出的就是原 bean 本身(AOP 包装),收尾对账必然成功;它只在收尾阶段被 spy 包装(拷贝的是字段已填充的完整实例,快照是好的)。
判据总结
| 场景 | 第一轮被提前暴露的 bean | 第二轮被提前暴露的 bean | 结果 |
|---|---|---|---|
| 1 | 双切 | 双切 | 启动"成功" + 插桩失败 + 运行期特定路径 NPE |
| 2 | 双切 | 非双切 | 一切正常(spy 在收尾阶段正常生效) |
| 3 | 非双切 | \ | 一切正常 |
通用判据:
- 逐轮逐个检查被提前暴露的 bean:是双切 bean 就有风险(提前暴露链上交出的是坏 override,账本为空时收尾对账静默失败放行坏副本),非双切则该 bean 安全。
- 简单环中每轮只有一个被提前暴露的 bean(即该轮环起点/递归链起点);共享 bean 的复连通环中一条递归链可暴露多个 bean,每个都按此判据逐一判断。
- 无论环的形状如何,同一个环最多失败一次 ------失败后
dependentBeanMap被清、injectionMetadataCache永不清,下一轮收尾自检必然静默放行、不再触发 destroy,不会再有第三轮(除非 BeanDefinition 重注册触发 resetBeanDefinition 清掉 injectionMetadataCache)。Spring Boot 3.5.6 的 catch 对每次遍历都生效,但同一个环只会走到它一次。
6 结论与规避
6.1 问题的根源
记账冲突的本质是:多个 BeanPostProcessor 都对 bean 做包装时,靠各自记账来避免重复包装。每个 processor 第一次包装时记下"我收到的原对象";之后再收到这个 bean 时先对账:收到的对象和记账的原对象一致 → 已包过,直接放行;不一致 → 认为还没包过,再包一次。而 processor 之间彼此不知道对方的存在,两条钩子链收到的东西不一样:
- 提前暴露链 (getEarlyBeanReference,仅循环依赖时触发):第一个钩子收到的是裸 bean → 包装 → 记账"我收到的是裸 bean" → 交出包装后的对象;第二个钩子收到第一个钩子交出的包装对象 → 包装 → 记账"我收到的是包装后的对象" → 交出双层包装对象。这条链交出的是双层包装对象。
- 收尾链 (postProcessAfterInitialization):第一个钩子收到的又是裸 bean → 与它记账的"裸 bean"一致 → 对账成功,直接放行裸 bean;第二个钩子收到裸 bean → 与它记账的"包装后的对象"不一致 → 对账失败,误判"还没包过" → 再包一次。这条链交出的是单层包装对象。
这就解释了为什么不存在循环依赖时不会失败 :不存在循环依赖时,getEarlyBeanReference 钩子链根本不会触发,每个 processor 只在收尾链上做一次记账和一次包装,不存在对账失败和重新包装的可能,代码正常运行。而存在循环依赖时为什么会失败 :提前暴露机制使得包装和对账各发生两次------提前暴露链一次、收尾链一次;收尾链上第二个 processor 对账失败(收到的裸 bean ≠ 账上的包装对象),误判后重新包装,导致收尾链交出的不是裸 bean(exposedObject != bean),而自检的放行条件只有"收尾链原样放行裸 bean"这一个(满足时直接采纳提前暴露链产物,不比较两者是否相同),exposedObject != bean 且有人持有提前暴露产物 → 抛 BeanCurrentlyInCreationException,冲突由此产生。
@MockitoSpyBean 和 AOP 只是这个机制最典型的组合:任何"包装 + 记账"的 BeanPostProcessor 并存都会踩中。
6.2 规避
- 根治:消除循环依赖(本 bug 的所有路径都以环为前提)
- 缓解(要测环上 bean 的真实方法时):
- 方案:把用例拆到新的测试类 ,类里直接
@Autowired注入环上 bean(不挂任何 @MockitoSpyBean/@MockitoBean 注解),让容器走正常流程 - 缺点:context 分裂------Spring TestContext 把配置类、profile、BeanOverride 注解等合起来算一个缓存 key,key 不一样就是两个 context。带 @MockitoSpyBean/@MockitoBean 的旧测试类和只用 @Autowired 的新测试类 key 不同,拿到的是两个不同的 ApplicationContext,各启动一次、内存翻倍
- 方案:把用例拆到新的测试类 ,类里直接
附录:源码示意图




7 流程示意图
text
场景:@MockitoSpyBean(WRAP) + AOP + 循环依赖(Spring Boot 3.5 / Spring 6.2.11)
结果:两轮构造 → 异常被吞 → 启动成功 → 运行期特定路径 NPE
@SpringBootTest
│
├─ 阶段 0:Context 构建与容器定制
│ ├─ SPI:从 spring.factories 加载 ContextCustomizerFactory
│ │ └─ BeanOverrideContextCustomizerFactory#createContextCustomizer
│ └─ 注册 WrapEarlyBeanPostProcessor(提前暴露处理,排在 AnnotationAwareAspectJAutoProxyCreator 前面)
│ ★ spy 的快照语义:快照收到的 bean 本身,拷贝时刻在字段填充之前
│
└─ ApplicationContext.refresh()
├─ invokeBeanFactoryPostProcessors():业务 BeanDefinition 就位
├─ registerBeanPostProcessors():
│ ├─ WrapEarlyBeanPostProcessor(最前)
│ ├─ AutowiredAnnotationBeanPostProcessor
│ └─ AbstractAutoProxyCreator(AOP)
│
├─ finishBeanFactoryInitialization() → preInstantiateSingletons() 逐个 getBean:
│
│ ──【第一轮:遍历到 order】getBean(order) → doCreateBean(order):
│ ├─ new order@1 → addSingletonFactory(三级缓存)
│ ├─ populateBean → getBean(pay) → doCreateBean(pay):
│ │ ├─ new pay@1 → populateBean 反向要 order → 三级缓存命中
│ │ ├─ getEarlyBeanReference 钩子链(首个收到 order@1):
│ │ │ ├─ WrapEarlyBeanPostProcessor:收到 order@1 → 记账 map[order]=order@1 → 交出 override(order@1)
│ │ │ │ ★ spy 用 spiedInstance 拷贝 order@1 当时的字段 → order@1 的快照副本内 pay 字段=null
│ │ │ └─ AnnotationAwareAspectJAutoProxyCreator:收到 override(order@1) → 记账 earlyProxyReferences[order]=override(order@1) → 交出 aop(override(order@1))
│ │ ├─ pay@1 拿到需要的正是 aop(override(order@1))
│ │ ├─ pay@1 收尾链(pay@1 未被提前暴露,首钩子收到裸 pay@1 → spy 正常包装,拷贝的是已填充完的实例,快照是好的)→ aop(override(pay@1)) 完成入一级缓存
│ └─ order@1 注入需要的 aop(override(pay@1)),order@1 构造完成 → postProcessAfterInitialization 链(首个收到 order@1):
│ │ ├─ WrapEarlyBeanPostProcessor:记账 map[order]=order@1 == 收到的 order@1 → 直接放行 order@1(不再包装)
│ │ └─ AnnotationAwareAspectJAutoProxyCreator:记账 override(order@1) != 收到的 order@1 → 对账失败 → 重包 aop(order@1)
│ └─ doCreateBean 收尾自检:order@1 收尾链最终交出的 aop(order@1) ≠ bean(裸 order@1)
│ → 进入检查分支:earlySingletonReference != null(order@1 被提前暴露过,pay 拿到过提前暴露产物)
│ && exposedObject != bean
│ ★ 抛 BeanCurrentlyInCreationException
│ → destroySingleton 连锁:整环 dependentBeanMap 清空
│ ★ 但 injectionMetadataCache 的 cached=true 全部保留
│
│ ──【吞错】preInstantiateSingleton ~1185 行 catch (BeanCurrentlyInCreationException):
│ INFO "currently initialized by other thread - skipping"(误导:全程单线程)
│ → 主线继续,遍历下一个 bean(本例恰好是环上的 pay)触发整环重新构造
│
│ ──【第二轮:遍历到 pay】getBean(pay) → doCreateBean(pay):
│ ├─ new pay@2 → addSingletonFactory(三级缓存)
│ ├─ populateBean 反向要 order → getBean(order) → 触发重建 order:
│ │ ├─ new order@2 → addSingletonFactory(三级缓存)
│ │ ├─ order@2 populateBean 反向要 pay → 请求 pay 的 earlyBeanReference → 三级缓存命中
│ │ │ ├─ getEarlyBeanReference 钩子链(首个收到 pay@2):
│ │ │ │ ├─ WrapEarlyBeanPostProcessor:收到 pay@2 → 记账 map[pay]=pay@2 → 交出 override(pay@2)
│ │ │ │ │ ★ override 是 pay@2 提前暴露时刻的快照副本,拷贝时 order 字段还没注入 → 副本内 order 字段=null
│ │ │ │ └─ AnnotationAwareAspectJAutoProxyCreator:收到 override(pay@2) → 记账 → 交出 aop(override(pay@2))
│ │ │ └─ order@2 拿到需要的正是 aop(override(pay@2))
│ │ ├─ order@2 完成注入 → postProcessAfterInitialization:钩子链收到的又是裸 order@2
│ │ │ ├─ WrapEarlyBeanPostProcessor:记账 map[order] 无此轮记录 → 交出 override(order@2)
│ │ │ └─ AnnotationAwareAspectJAutoProxyCreator:收到 override(order@2) → 记账 → 交出 aop(override(order@2))
│ │ ├─ doCreateBean 收尾自检:order@2 无人拿过earlyBeanReference → 自检通过
│ │ └─ order@2 干净完成入一级缓存
│ ├─ pay@2 取依赖走 injectionMetadataCache cached=true 捷径 → getBean(order) 直接拿 order@2
│ │ ★ registerDependentBeans 被 if(!this.cached) 短路 → 账本不补登
│ ├─ pay@2 完成注入 → postProcessAfterInitialization:钩子链收到的又是裸 pay@2
│ │ ├─ WrapEarlyBeanPostProcessor:记账 map[pay]=pay@2 == 收到的 pay@2 → 直接放行(不再包装)
│ │ └─ AnnotationAwareAspectJAutoProxyCreator:记账 override(pay@2) != 收到的 pay@2 → 对账失败 → 重包 aop(pay@2)(不含 override)
│ └─ doCreateBean 收尾自检:pay@2 收尾链重包出的 aop(pay@2)(不含 override)≠ bean(裸 pay@2)
│ → 进入检查分支:earlySingletonReference != null(pay@2 被提前暴露过,order@2 拿到过提前暴露产物)
│ && exposedObject != bean
│ → 异常分支跳过 → aop(pay@2)(不含 override)被静默采纳入一级缓存
│ → 容器内外两套对象分叉:其他 bean 注入 aop(pay@2),order@2 持有的仍是坏 aop(override(pay@2))
│
│ ──【终局】所有 bean "就位",Started Application
│ ├─ 容器最终单例 = 重建轮健康实例
│ └─ 环上 bean 字段内 = 坏 override(提前暴露时刻拷贝的、未注入字段为 null 的对方快照副本)
│
└─ 阶段 4:运行期
├─ 穿环路径 orderA() → payA() → orderB():走到 pay 的 override(pay@2 提前暴露时刻的快照副本)内 orderService=null
│ ★ NPE(三层代理嵌套堆栈:AOP → 业务 → AOP → override(提前暴露时刻快照副本) → NPE)
└─ 其他路径(如 orderA() → payB(),调到 pay 后不再回头调 order)正常返回 → 症状隐蔽,难定位
8 Debug 断点表(IDEA,Spring Boot 3.5.6 / Spring 6.2.11,条件断点 beanName 过滤噪音)
| # | 断点目的 | 类#方法(spring-beans / spring-aop / spring-test) | 断点位置/行号(6.2.11) | 观察什么 |
|---|---|---|---|---|
| 1 | 间谍定义注册 | MockitoSpyBeanOverrideMetadataHandler / spring-test BeanOverrideTestExecutionListener 体系 |
createMetadata / OverrideMetadata 构造处 |
@MockitoSpyBean 字段解析出的 definition、策略(WRAP)、目标 beanName |
| 2 | Wrap 处理器就位 | WrapEarlyBeanPostProcessor(spring-test) |
类构造 / postProcessBeforeInstantiation |
与 AnnotationAwareAspectJAutoProxyCreator 的相对顺序(它在最前);targetBeanNames 记账 |
| 3 | 提前暴露包装(spy 现场) | WrapEarlyBeanPostProcessor#getEarlyBeanReference |
方法入口 | 收到 bean 本身 → 记账 → 交出 Mockito.spy 的 override;展开返回的 override:其内部 spiedInstance 字段值 = 当时的 bean(拷贝发生在属性填充中途 → 尚未注入的依赖字段为 null,已注入字段正常) |
| 4 | AOP 提前暴露记账 | AbstractAutoProxyCreator#getEarlyBeanReference |
this.earlyProxyReferences.put(cacheKey, bean) 行 |
收到的是 override(bean) ★------AnnotationAwareAspectJAutoProxyCreator 记的账是 override,与后续收尾链收到的裸 bean 对不上账,正是记账冲突的根源 |
| 5 | spy 收尾对账 | WrapEarlyBeanPostProcessor#postProcessAfterInitialization |
cache.remove(key) 对账行 |
第一轮(order):remove == 收到的 bean 对账成功,直接放行 bean 本身(不再包装);第二轮(pay)重建时同样逻辑 |
| 6 | AOP 收尾重包现场 | AbstractAutoProxyCreator#postProcessAfterInitialization |
earlyProxyReferences.get(cacheKey) != bean 判断行 |
账上是 override ≠ 收到的 bean 本身 → 误判未包装 → wrapIfNecessary 重包出 aop(bean) ★ |
| 7 | 冲突宣判(order 首次初始化) | AbstractAutowireCapableBeanFactory#doCreateBean |
~633 行 hasDependentBean(beanName) 判断处 |
第一轮(order):收尾链交出的 aop(order@1) ≠ bean(裸 order@1)且 hasDependentBean=true → 抛异常;第二轮(pay):同位置 hasDependentBean=false → 跳过 ★ 核心对比点 |
| 8 | 吞错现场 | DefaultListableBeanFactory#preInstantiateSingleton |
~1185 行 catch (BeanCurrentlyInCreationException ex) |
异常被吞、打出 "skipping it in mainline thread" INFO(实际单线程);此后循环继续 → 遍历下一个 bean(本例恰好是环上的 pay)触发整环重新构造 |
| 9 | 缓存短路(账本不补登) | AutowiredAnnotationBeanPostProcessor$AutowiredElement#inject |
~756 行 if (this.cached) |
第二轮(pay)后所有取依赖恒走捷径分支;resolveFieldValue:794 的 registerDependentBeans 一次都不进 |
| 10 | 账本移除 | DefaultSingletonBeanRegistry#destroyBean |
~784 行 this.dependentBeanMap.remove(beanName) |
连锁递归销毁整环;对照 dependenciesForBeanMap(反向表,单条路径不清) |
| 11 | 三级缓存命中/缺失 | DefaultSingletonBeanRegistry#getSingleton |
singletonFactories 分支 |
第一轮(order):环上反向取依赖命中三级缓存(触发提前暴露链);第二轮(pay)时的 order:此方法返回 null → 提前暴露检查整体跳过 |
| 12 | 最终症状现场 | 业务穿环方法(如 PayServiceImpl#payA) |
调用依赖字段行 | this.orderService == null NPE;调用栈里可见 $Proxy(AOP) → 业务 → $Proxy(AOP) → $Proxy(override=pay@2 提前暴露时刻的快照副本) → NPE 三层嵌套 |
备注:
- 3-8 号断点全部加条件
beanName.contains("orderService") || beanName.contains("payService")过滤噪音 - 重点对比同一断点在第一轮(order 入口链)与第二轮(pay 入口链)的变量差异 ------7 号断点的
hasDependentBean结果、9 号断点的this.cached值、11 号断点的返回值,是三个"第一轮炸、第二轮静默"的翻转开关