" />
文章

Spring 是如何解决循环依赖问题的(三级缓存详解)

Spring 是如何解决循环依赖问题的(三级缓存详解)

面试指北 · 蔚来

题意

面试官问「Spring 怎么解决循环依赖」,表面在问一个机制,实际在考三件事:

  1. 你是否知道循环依赖发生在哪个阶段(实例化?属性填充?初始化?);
  2. 你是否说得清三级缓存各自存什么、按什么顺序被访问;
  3. 你是否知道这套方案什么时候失效——构造器注入、prototype 作用域、Spring Boot 2.6 之后的默认策略,这些才是区分「背过」和「理解过」的地方。

所谓循环依赖,就是 A 依赖 B、B 又依赖 A(也可能是 A→B→C→A 这种更长的环)。如果没有额外手段,创建 A 需要先创建 B,创建 B 又需要先创建 A,形成互相等待。

结论

  • Spring 只能解决单例作用域 + setter/字段注入这一类循环依赖,靠的是三级缓存 + 提前暴露半成品对象引用。
  • 三级缓存分别是:一级 singletonObjects(成品 Bean)、二级 earlySingletonObjects(半成品早期引用)、三级 singletonFactories(对象工厂 ObjectFactory)。
  • 三级缓存的关键价值在于延迟决定要不要生成代理,而不是「多存一层」。只靠两级缓存,AOP 场景下的注入对象就会出错。
  • 构造器注入的循环依赖 Spring 解决不了,因为对象还没实例化出来,没有任何东西可以提前暴露,只能抛 BeanCurrentlyInCreationException。
  • 工程上的正解通常是重构解耦,@Lazy、ObjectProvider 只是让代码能跑起来的补救手段。

要点展开

1. 三级缓存到底存了什么

缓存 字段 内容 生命周期位置
一级 singletonObjects 完整初始化好的单例 Bean 初始化完成、放入容器后
二级 earlySingletonObjects 已实例化但还没填充属性/初始化的「半成品」,也可能是提前生成的代理对象 发生循环依赖、被提前引用时
三级 singletonFactories ObjectFactory 工厂(本质是个 lambda) 实例化之后立刻放入

注意二级缓存里存的不一定是原始对象,如果这个 Bean 需要 AOP 代理,存进去的就是代理对象——这一点是理解三级缓存存在意义的前提。

2. 一次完整的 A ↔ B 循环依赖流程

以 A 依赖 B、B 依赖 A(字段注入)为例:

  1. getBean(A) → 一级缓存没有 → 走创建流程,createBeanInstance 通过反射实例化出原始 A(此时属性全是 null)。
  2. 实例化后立刻执行 addSingletonFactory,把一个 ObjectFactory 放进三级缓存,工厂内容是 () -> getEarlyBeanReference(beanName, mbd, bean)。
  3. populateBean(A) 填充属性,发现依赖 B → 转去 getBean(B)。
  4. B 同理:实例化 → 三级缓存放入工厂 → 填充属性时发现依赖 A → 再次 getBean(A)。
  5. 这次 getSingleton("a", true) 命中流程:一级没有 → 二级没有 → 三级有工厂 → 调用 getObject(),内部走 getEarlyBeanReference:需要 AOP 就返回代理对象,否则返回原始对象;结果放进二级缓存,同时从三级缓存移除。
  6. B 拿到 A 的早期引用,顺利完成属性填充和初始化,进入一级缓存。
  7. 回到 A,注入已经就绪的 B,完成自己的初始化,放入一级缓存,清理二级缓存。

简化成伪代码就是:

// AbstractAutowireCapableBeanFactory#doCreateBean 核心片段(示意)
Object bean = createBeanInstance(beanName, mbd, args); // 1. 实例化
if (earlySingletonExposure) {                          // 2. 提前暴露
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
populateBean(beanName, mbd, bean);                     // 3. 填充属性,可能触发循环依赖
initializeBean(beanName, bean, mbd);                   // 4. 初始化(AOP 代理正常在这里生成)

3. 为什么非要三级?两级不行吗

这是这道题最容易被追问的点。

如果只有两级缓存,那么「半成品」必须在实例化之后马上放进二级缓存——问题就在于,此时你无法确定这个对象将来会不会被 AOP 代理(代理通常发生在初始化之后的 postProcessAfterInitialization)。一旦提前就生成代理,等于所有 Bean 都得提前走一遍代理逻辑,既浪费,也破坏了 BeanPostProcessor 的正常执行顺序和语义。

三级缓存的做法是「先登记一个工厂,用到了再算」:没有循环依赖的 Bean,工厂永远不会被调用,代理依旧在初始化后正常生成;真的发生循环依赖时,才通过 getEarlyBeanReference 提前把代理对象做出来交给对方。

配套还有一个细节:AbstractAutoProxyCreator 会把提前生成过代理的 Bean 记录在 earlyProxyReferences 里,后续正常初始化完成时发现已经代理过,就不再重复包装,避免出现「代理套代理」。

所以可以这样总结:二级缓存解决「能不能提前拿到引用」,三级缓存解决「拿到的是不是最终应该拿到的那个对象」。

4. 为什么构造器注入解决不了

因为提前暴露的前提是「先有对象,再填属性」。构造器注入反过来:new A(b) 必须先把 B 造出来,而造 B 又需要 A,双方都卡在实例化阶段,容器里根本没有半成品可以暴露,只能检测到「当前 Bean 正在创建中」并抛出:

BeanCurrentlyInCreationException:
Error creating bean with name 'a': Is there an unresolvable circular reference?

这也是 Spring 官方文档长期推荐「构造器注入为主」却不担心循环依赖的原因——它逼着你在设计阶段就把环拆掉,而不是靠容器兜底。

5. @Lazy 为什么能救场

@Lazy 注入的不是真实对象,而是一个代理,真正调用方法时才去容器里取目标 Bean。这样一来,A 在实例化 B 时不需要 B 已就绪,环被打断了。

@Service
public class A {
    @Lazy
    @Autowired
    private B b;
}

同类思路还有 ObjectProvider<B>、ObjectFactory<B>、直接注入 ApplicationContext 延迟获取。它们都是「延迟到使用时再解析」,而不是真正修好了循环依赖。

需要提醒的是,@Lazy 解决的是「能启动」,但如果 A、B 在初始化阶段(比如 @PostConstruct 里)就互相调用对方的方法,仍可能拿到状态未完成的半成品,出现 NPE 或字段为空。能启动不等于语义正确。

易错点与对比

① Spring 能解决的范围要划清楚

  • ✅ 单例 + setter 注入 / @Autowired 字段注入
  • ❌ 构造器注入(除非配合 @Lazy)
  • ❌ prototype 作用域:不缓存、不提前暴露,直接抛异常
  • ❌ @Bean 方法之间互相调用构成的环,同样可能无解

② 三级缓存的常见误答

  • 说「三级缓存存对象工厂,用于实例化 Bean」是不准确的:工厂的作用是在需要早期引用时决定返回原始对象还是代理对象,不是用来实例化。
  • 说「二级缓存没必要,三级就够了」也不对:二级缓存避免了同一个 Bean 的工厂被反复调用、代理被反复生成,同时让「早期引用」这个状态有明确归属。

③ 与 AOP / 异步注解的冲突

如果某个 Bean 在初始化阶段会被 BeanPostProcessor 包装成代理(典型如 @Async、自定义 AOP),而发生循环依赖时提前暴露的是原始对象,注入方拿到的可能不是最终代理,导致注解「失效」。这属于循环依赖带来的隐性 bug,比启动报错更麻烦。

④ Spring Boot 2.6 之后的变化

从 Spring Boot 2.6 起,spring.main.allow-circular-references 默认是 false,循环依赖会直接启动失败。想打开可以配置为 true,但更推荐的做法是趁这个机会重构依赖关系,而不是把开关拨回去。

⑤ 循环依赖 ≠ 循环调用

前者是容器创建 Bean 阶段的依赖闭环(可解或不可解),后者是运行期方法互相调用导致的死循环/栈溢出,两者不是一回事,别在面试里混着讲。

⑥ 真正推荐的解法

优先抽第三层:把 A、B 共同依赖的逻辑下沉到 C,让依赖变成 A→C、B→C,环自然消失;其次用事件机制、回调接口解耦;最后才考虑 @Lazy 这类容器层面的补丁。

小结

一句话概括:Spring 通过三级缓存在「实例化完成、属性填充之前」把对象的早期引用暴露出去,让依赖方先拿到引用继续走流程,从而打破创建期的等待环;三级缓存里的 ObjectFactory 把「是否需要生成代理」这件事推迟到真正发生循环依赖时才决策,兼顾了正确性和性能。

面试时按「三级缓存结构 → 完整流程 → 为什么是三级 → 什么情况下失效 → 怎么重构」这条线展开,基本就覆盖了面试官所有可能的追问方向。

License:  CC BY 4.0