一次 Spring 循环依赖源码走读:从三级缓存误用到 Bean 生命周期深度解析

在团队最近一次架构评审会上,关于 Spring 循环依赖的处理方式爆发了一场激烈争论。

"直接用 @Lazy 不就行了?" 小李拍着桌子说,"我上个月在订单服务里就这么干的,上线一点问题没有。"

"@Lazy 只是绕开问题,不是解决问题。" 架构师老张摇头,"如果两个核心 Bean 互相依赖,延迟加载会导致首次请求超时,用户体验直接崩掉。"

"那用 setter 注入?" 小王插话,"我看官方文档说构造器注入不支持循环依赖。"

"但 setter 注入破坏了不可变性,而且你们有没有想过------Spring 到底是怎么'变魔术'让循环依赖成立的?" 我抛出这个问题,会议室瞬间安静。

这场争论暴露了一个普遍误区:大多数开发者知道 Spring 能处理循环依赖,但很少有人真正理解其背后的三级缓存机制。更危险的是,很多人误以为只要不抛错就万事大吉,却忽略了 Bean 生命周期中的微妙时序问题。

本文将带你深入 Spring 源码,从一次错误的循环依赖实现开始,逐步修正认知偏差,最终揭示三级缓存的设计哲学与实战边界。

需求约束:为什么不能简单粗暴地禁止循环依赖?

在电商系统中,我们有一个典型的循环依赖场景:

  • OrderService 需要调用 PaymentService 完成支付
  • PaymentService 又需要调用 OrderService 查询订单状态以决定是否退款

业务逻辑上,这种双向调用是合理的。如果强行禁止循环依赖,会导致以下问题:

  1. 职责拆分困难:将两个强相关的服务拆成四个模块,增加维护成本
  2. 事务一致性风险:跨服务调用难以保证本地事务原子性
  3. 性能损耗:原本一次本地调用变成两次远程调用

因此,Spring 选择支持循环依赖,而非简单禁止。但这背后是有严格约束的:

  • 仅支持 单例作用域 的 Bean
  • 仅支持 setter/field 注入,不支持构造器注入
  • 必须启用 提前暴露引用 机制(即三级缓存)

架构设计:三级缓存的演进逻辑

错误方案:直接实例化 + 属性注入

最直观的想法是:先创建 A,再创建 B,然后互相设置引用。但这样会陷入死循环:

java 复制代码
// 伪代码:错误示范
A a = new A();
B b = new B();
// 此时 A 和 B 都未完成属性注入
a.setB(b); // B 还没注入 A,b.getA() 为 null
b.setA(a); // A 的 B 引用虽然有了,但 B 本身未完成初始化

问题在于:对象创建和属性注入是两个阶段,如果严格按顺序执行,循环依赖必然失败。

正确方案:三级缓存 + 提前暴露引用

Spring 的解法是引入"半成品"概念,通过三级缓存提前暴露尚未完成属性注入的 Bean 引用:

  • 一级缓存(singletonObjects):存放完全初始化好的 Bean
  • 二级缓存(earlySingletonObjects):存放已实例化但未完成属性注入的 Bean
  • 三级缓存(singletonFactories):存放生成早期引用的工厂函数

关键流程如下:

  1. 创建 A 实例(仅调用构造器,未完成属性注入)
  2. 将 A 的 ObjectFactory 放入三级缓存
  3. 发现 A 依赖 B,开始创建 B
  4. 创建 B 实例,将 B 的 ObjectFactory 放入三级缓存
  5. 发现 B 依赖 A,从三级缓存获取 A 的早期引用(此时 A 虽未完成注入,但引用已存在)
  6. B 完成属性注入,放入一级缓存
  7. A 完成属性注入,放入一级缓存

这个设计巧妙地利用了 引用提前暴露延迟初始化 的思想,打破了创建时序的死锁。

关键代码/组件:源码走读

我们聚焦 DefaultSingletonBeanRegistry 中的 getSingleton 方法,这是三级缓存的核心入口:

java 复制代码
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    // 1. 从一级缓存获取完整 Bean
    Object singletonObject = this.singletonObjects.get(beanName);
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        synchronized (this.singletonObjects) {
            // 2. 从二级缓存获取早期引用
            singletonObject = this.earlySingletonObjects.get(beanName);
            if (singletonObject == null && allowEarlyReference) {
                // 3. 从三级缓存获取 ObjectFactory 并生成早期引用
                ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                if (singletonFactory != null) {
                    singletonObject = singletonFactory.getObject();
                    this.earlySingletonObjects.put(beanName, singletonObject);
                    this.singletonFactories.remove(beanName);
                }
            }
        }
    }
    return singletonObject;
}

注意 allowEarlyReference 参数:只有在 Bean 创建过程中(isSingletonCurrentlyInCreation 为 true)才允许获取早期引用,防止外部提前访问半成品 Bean。

再看 doCreateBean 方法中的关键片段:

java 复制代码
// 将 ObjectFactory 加入三级缓存
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));

// 填充属性(此时可能触发对依赖 Bean 的创建,从而触发三级缓存查询)
populateBean(beanName, mbd, instanceWrapper);

// 初始化 Bean
exposedObject = initializeBean(beanName, exposedObject, mbd);

getEarlyBeanReference 方法会处理 AOP 代理的情况:如果 Bean 被 AOP 代理,返回的是代理对象;否则返回原始对象。这保证了循环依赖中获取的引用始终是一致的。

复盘:常见误区与边界条件

经过这次源码走读,我们总结了几个关键教训:

误区 1:认为 @Lazy 是银弹

@Lazy 确实能解决部分循环依赖问题,但它改变了 Bean 的初始化时机。在以下场景会出问题:

  • 启动时需要预热的 Bean(如缓存加载)
  • 依赖 Bean 的 @PostConstruct 方法执行顺序

误区 2:忽视 AOP 代理的影响

如果循环依赖的 Bean 被 AOP 代理,必须确保获取的是同一个代理实例。Spring 通过 getEarlyBeanReference 保证这一点,但如果手动创建代理(如 ProxyFactory),就会破坏一致性。

误区 3:在构造器中调用依赖 Bean 的方法

即使 Spring 解决了引用问题,如果在构造器中调用 b.doSomething(),此时 B 可能尚未完成初始化(如 @PostConstruct 未执行),导致状态不一致。

边界条件

  • 原型作用域(Prototype):不支持循环依赖,因为每次获取都是新实例
  • 构造器注入:无法使用三级缓存,因为构造器必须在实例化时完成参数注入
  • 多例循环依赖:即使使用 setter 注入,也会因多次创建实例而失败

技术补丁包

  1. 三级缓存机制 原理:通过 singletonFactories、earlySingletonObjects、singletonObjects 三级结构,实现 Bean 引用提前暴露与延迟初始化。 设计动机:解决单例 Bean 在 setter/field 注入场景下的循环依赖问题,避免创建时序死锁。 边界条件:仅适用于单例作用域;不支持构造器注入;AOP 代理需通过 getEarlyBeanReference 统一处理。 落地建议:避免在构造器中调用依赖 Bean 方法;优先使用 setter 注入处理循环依赖;慎用 @Lazy 替代根本解决方案。

  2. ObjectFactory 的作用 原理:作为三级缓存的值,封装了生成早期引用的逻辑,支持 AOP 代理的动态创建。 设计动机:解耦 Bean 实例化与引用暴露的时机,允许在属性注入前提供一致引用。 边界条件:仅在 Bean 创建过程中有效;外部调用 getSingleton 时 allowEarlyReference 必须为 false。 落地建议:不要手动操作三级缓存;理解 getEarlyBeanReference 对 AOP 的处理逻辑。

  3. 循环依赖与 AOP 的交互 原理:通过 SmartInstantiationAwareBeanPostProcessor 在早期阶段创建代理,确保循环依赖中获取的是同一代理实例。 设计动机:避免因代理对象不一致导致的方法调用异常或事务失效。 边界条件:手动创建代理会破坏一致性;CGLIB 代理需考虑 final 方法限制。 落地建议:避免在 Bean 构造器中执行依赖 Bean 的业务逻辑;使用 @DependsOn 显式控制初始化顺序。

  4. 构造器注入的局限性 原理:构造器参数必须在实例化时确定,无法利用三级缓存延迟注入。 设计动机:强制显式声明依赖,提升代码可读性与不可变性。 边界条件:循环依赖场景下必须改用 setter 注入;多参数构造器需注意参数顺序。 落地建议:非循环依赖场景优先使用构造器注入;循环依赖场景权衡不可变性与实用性。

  5. @Lazy 的适用场景 原理:通过代理延迟 Bean 的实际初始化,打破创建时序依赖。 设计动机:简化循环依赖处理,适用于初始化成本高或可选依赖的场景。 边界条件:首次访问有性能开销;@PostConstruct 执行时机延后;不适用于启动预热场景。 落地建议:作为临时解决方案而非架构设计;结合 @DependsOn 控制初始化顺序;监控首次请求延迟。

相关推荐
knight_9___2 分钟前
RAG面试篇11
java·面试·职场和发展·agent·rag·智能体
念越3 分钟前
Java 文件操作与IO流详解(File类 + 字节流 + 字符流全总结)
java·开发语言·io
派大星酷6 分钟前
AOP 完整精讲:原理、核心概念、五种通知、切点语法、自定义注解实战
java·mysql·spring
七颗糖很甜7 分钟前
预警!超级厄尔尼诺即将登场:2026-2027年全球气候或迎“极端狂暴模式”
java·大数据·python·算法·github
夫礼者16 分钟前
【极简监控】挖出被遗忘的 JMX 金矿:用 Jolokia + Hawtio 把 VisualVM 搬进浏览器
java·监控·jolokia·jmx·hawtio
Slow菜鸟16 分钟前
Java 开发环境安装指南(7) | Nginx 安装
java·开发语言·nginx
沐苏瑶17 分钟前
Java反序列化漏洞
java·开发语言·网络安全
Rsun045512 小时前
为什么要配置maven
java·maven
人道领域2 小时前
【Redis实战篇】初步基于Redis实现的分布式锁---基于黑马点评
java·数据库·redis·分布式·缓存
呱牛do it7 小时前
企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 3)
java·vue