循环依赖:三级缓存、提前暴露、代理对象以及无法解决的场景(086)

Spring与Spring Boot约 15 分钟阅读8,057 字符
知识库文档 87 / 251

一、承接前两篇:为什么循环依赖值得单独一篇

084 提到过三级缓存的结构,085 讲了不同注入方式对循环依赖能否被解决的影响——两篇都是"点到即止",因为循环依赖本身的机制足够深,值得单独展开。这也是 Spring 源码里被问得最频繁的话题之一:不是因为它多难理解,而是因为它牵扯的知识点密度极高——Bean 生命周期(082)、容器刷新(083)、作用域(084)、注入方式(085)、AOP 代理(087 的内容)都会在这个话题里交汇。理解了循环依赖,相当于把 Spring IoC 容器的核心运作机制串成了一条完整的链路。

二、循环依赖的几种场景分类

循环依赖不是一种单一现象,按"谁依赖谁、依赖几层"可以分成几类:

java
// 场景一:两个 Bean 互相依赖(最常见)
class A { B b; }
class B { A a; }

// 场景二:多个 Bean 形成依赖环
class A { B b; }
class B { C c; }
class C { A a; }

// 场景三:Bean 依赖自己(自引用)
class A { A self; }

三种场景在容器内部的处理逻辑是统一的——只要能"提前暴露一个半成品引用",环有多大都能闭环。真正决定能不能解决的,不是环的大小,而是注入方式(085 讲过:构造器注入不行,字段/Setter 注入可以)和作用域(084 讲过:只有 singleton 有这套机制,prototype 没有)。

三、三级缓存:比 084 更进一步的源码细节

三级缓存定义在 DefaultSingletonBeanRegistry 里:

java
// 一级缓存:完全初始化好的成品单例
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// 二级缓存:已经"提前暴露"过的早期引用(可能是原始对象,也可能是代理对象)
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

// 三级缓存:单例工厂,负责"按需生成"提前暴露的引用
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

三级缓存不是三份互相独立的数据,而是同一个 Bean 在不同处理阶段的"引用快照",核心方法 getSingleton(String beanName, boolean allowEarlyReference) 的查找顺序揭示了它们的关系:

java
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    Object singletonObject = this.singletonObjects.get(beanName);
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        synchronized (this.singletonObjects) {
            singletonObject = this.earlySingletonObjects.get(beanName);
            if (singletonObject == null && allowEarlyReference) {
                ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                if (singletonFactory != null) {
                    singletonObject = singletonFactory.getObject(); // 三级缓存"产出"一个引用
                    this.earlySingletonObjects.put(beanName, singletonObject); // 升级到二级缓存
                    this.singletonFactories.remove(beanName); // 三级缓存里的工厂用完即删
                }
            }
        }
    }
    return singletonObject;
}

这段代码有两个关键信息:一是查找顺序永远是一级 → 二级 → 三级,找到就不再往下查;二是三级缓存的工厂被调用一次后就"升级"进二级缓存,同时自己被移除——所以三级缓存里的 ObjectFactory 只会被调用一次,这正是它用来控制"代理对象只生成一次"的关键设计(第五节详细展开)。

文章配图
文章配图

四、提前暴露:从"半成品对象"到"放进三级缓存"的完整链路

回顾 085 讲的时序:容器创建 ServiceA 时,先走无参构造器得到一个半成品对象,紧接着(还没开始属性填充之前)会执行这一步(在 AbstractAutowireCapableBeanFactory#doCreateBean 里):

java
boolean earlySingletonExposure = mbd.isSingleton()
        && this.allowCircularReferences
        && isSingletonCurrentlyInCreation(beanName);
if (earlySingletonExposure) {
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}

addSingletonFactory 就是把一个 ObjectFactory 放进三级缓存——注意这里放的不是对象本身,而是一个"生成引用的函数",只有当真的有其他 Bean 需要提前拿这个引用时,getSingleton() 才会调用 getObject() 触发 getEarlyBeanReference() 执行。这是一种典型的"懒惰求值"设计:如果压根没有循环依赖,这个工厂永远不会被调用,也就不会有任何额外开销。

getEarlyBeanReference() 内部会遍历所有 SmartInstantiationAwareBeanPostProcessor,让它们有机会对这个"早期引用"做加工——这正是 AOP 代理介入循环依赖的入口,也是理解"为什么需要三级缓存而不是两级"的关键,下一节展开。

文章配图
文章配图

五、AOP 代理场景:为什么必须是三级缓存,不能只用两级

这是循环依赖里最容易被问倒的一个点:如果一个 Bean 需要被 AOP 切面代理(比如加了**@Transactional**),提前暴露给别人的那个引用,必须是最终的代理对象,而不是原始对象——否则会出现"同一个 Bean 在系统里存在两个不同实例"的严重 bug:一个是被提前注入到别处的原始对象,另一个是容器最终生成的代理对象,事务、日志等切面逻辑只会在代理对象上生效,注入了原始对象的那些地方完全绕过了 AOP。

java
@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
}

@Service
@Transactional // 需要被代理
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

正常情况下(没有循环依赖),ServiceB 的代理是在它自己初始化完成之后,由 BeanPostProcessor(AnnotationAwareAspectJAutoProxyCreator 的 postProcessAfterInitialization)统一生成的——这是"标准时机"。但如果 ServiceB 在被创建过程中因为循环依赖被提前暴露给了 ServiceA,标准时机还没到,容器就必须提前把代理生成出来,塞给 ServiceA。getEarlyBeanReference() 里正是通过判断"这个 Bean 是否已经被某个 SmartInstantiationAwareBeanPostProcessor 处理过提前暴露"来触发这个提前代理创建的:

java
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
        exposedObject = bp.getEarlyBeanReference(exposedObject, beanName); // AOP相关处理器在这里生成代理
    }
    return exposedObject;
}

为什么必须是三级缓存,而不是把代理对象直接放进二级缓存了事? 因为容器在实例化 ServiceB 的那一刻,根本不知道它接下来是否真的会被循环依赖用到。如果直接在实例化后立即生成代理(相当于把逻辑硬塞进二级缓存的填充时机),那就意味着所有需要 AOP 代理的 Bean,都会被强制提前代理,而不管它有没有涉及循环依赖——这打乱了 Spring 设计好的"代理默认在 postProcessAfterInitialization 阶段统一生成"的既定时机,可能引发一系列关于代理生成顺序、属性填充顺序的连锁问题。三级缓存用一个"工厂函数"而不是"提前算好的结果"来解决这个矛盾:工厂函数只有在真正被循环依赖触发时才会执行(对应 getObject() 被调用),没有循环依赖就完全不触发,代理仍然按原计划在标准时机生成——三级缓存本质上是"给可能存在也可能不存在的循环依赖预留的一个懒加载入口"。

文章配图
文章配图

六、二级缓存 vs 三级缓存:一张图看懂"为什么不能省一级"

方案 做法 问题
假想的"两级缓存"方案 实例化后立即判断要不要代理,直接把(代理后或原始)对象放入缓存 所有 Bean 无论是否涉及循环依赖,都要在实例化阶段被迫决策代理时机,打乱既定的生命周期顺序
Spring 实际的三级缓存方案 实例化后只放入一个"按需生成引用"的工厂,只有真正被其他 Bean 提前查找时才触发 getEarlyBeanReference() 没有循环依赖的 Bean 完全不受影响,代理仍在标准时机生成;只有真正卷入循环依赖的 Bean 才会被提前处理

一句话总结:三级缓存的核心价值不是"多存一份数据",而是"用懒加载把提前暴露这件事的影响范围,精确限制在真正需要它的 Bean 上"。

文章配图
文章配图

七、无法解决的场景全景

结合本篇和 085 的内容,把"循环依赖 Spring 处理不了"的场景汇总成一张表:

场景 原因 报错类型
双方都用构造器注入 实例化阶段就要求依赖就位,没有半成品对象可提前暴露 BeanCurrentlyInCreationException
双方都是 prototype 作用域 三级缓存机制专属于 singleton,prototype 每次都要求全新实例,无法"复用一个提前暴露的半成品" BeanCurrentlyInCreationException
显式关闭 allowCircularReferences AbstractRefreshableApplicationContext 可以设置该开关为 false,即使是字段/Setter 注入也会被禁止提前暴露 BeanCurrentlyInCreationException
单例的初始化回调阶段抛异常打断 即使循环依赖本身能闭环,如果 @PostConstruct/InitializingBean.afterPropertiesSet() 里抛异常,容器会清理缓存并向上抛出,整个链条创建失败 具体业务异常包装为 BeanCreationException
一方是 FactoryBean 且提前暴露的引用被误用为 FactoryBean 本身 FactoryBean 的产出对象和 FactoryBean 实例本身是两个不同的对象,提前暴露逻辑需要精确区分,处理不当会导致类型不匹配 BeanNotOfRequiredTypeException 或类型转换异常

文章配图
文章配图

八、排查思路:遇到循环依赖异常怎么定位

  1. 看异常堆栈里"当前正在创建"的 Bean 名称链:BeanCurrentlyInCreationException 的信息通常会列出一条"A 依赖 B,B 依赖 A"式的链条,直接定位到互相依赖的两个类。
  2. 确认注入方式:检查这两个类是否用了构造器注入——这是最常见的根因,改成字段/Setter 注入(配合第九节的权衡)能让容器"绕过去",但不代表这是最佳修复方式。
  3. 确认作用域:如果两个类都是 prototype,即使换成字段注入也解决不了,需要重新设计。
  4. 检查是否有自定义**BeanFactoryPostProcessor**/**BeanPostProcessor**干扰了提前暴露逻辑:少数场景下自定义的后处理器如果错误地缓存了 Bean 引用,可能导致明明能闭环的场景也报错。
  5. 本地复现优先于翻源码:写一个最小化的 @SpringBootTest 或者纯 AnnotationConfigApplicationContext 复现问题,比直接读源码排查更快定位是注入方式还是作用域的问题。

文章配图
文章配图

九、该不该主动利用循环依赖机制"绕过"设计问题

三级缓存能自动解决字段/Setter 注入的循环依赖,这是不是意味着遇到循环依赖直接把构造器注入换成字段注入就万事大吉?不建议把这当成默认解法。循环依赖在绝大多数场景下反映的是模块职责划分不清晰——两个 Service 互相调用,往往说明它们本该合并,或者该有一个更上层的协调者。字段注入能让代码"跑起来",但按 085 讲过的代价(不可变性、可测试性、依赖不显式),长期看不是免费的。更推荐的顺序是:先看能不能通过抽取公共依赖、引入事件机制解耦;确实需要临时绕过时,构造器参数上加**@Lazy**比直接换成字段注入更明确地表达了"这里有意为之,不是疏忽"的意图。

文章配图
文章配图

十、常见误区

  • "三级缓存就是为了解决循环依赖而存在的" —— 不完全准确。三级缓存的直接目的是"提前暴露引用",循环依赖只是它能解决的问题之一;它同时也是让 AOP 代理时机保持一致性(不被循环依赖打乱)的基础设施。
  • "只要不是构造器注入,循环依赖都能被解决" —— 不对,还要看作用域(prototype 不行)和容器配置(allowCircularReferences=false 时也不行)。
  • "二级缓存是多余的,直接用一级和三级就够了" —— 不对。二级缓存缓存的是"已经调用过工厂、生成好的早期引用",避免同一个 Bean 的早期引用被多次不同的其他 Bean 请求时反复触发工厂逻辑(尤其是 AOP 代理生成,只能发生一次),这是幂等性保证的关键一环。
  • "循环依赖被自动解决了,就代表没问题" —— 不对,能被解决和"设计合理"是两件事,长期存在的循环依赖是架构层面值得警惕的信号。

十一、总结

循环依赖是 Spring Bean 生命周期、作用域、注入方式、AOP 代理四个知识点在运行时的一次真实交汇:

  1. 三级缓存的核心是"用工厂函数实现懒加载的提前暴露",只有真正卷入循环依赖的 Bean 才会触发这套机制,代价被精确限制在必要范围内。
  2. 提前暴露的引用必须经过 SmartInstantiationAwareBeanPostProcessor 加工,这正是 AOP 代理提前生成的入口——保证同一个 Bean 无论何时被谁拿到,都是同一个(代理后的)实例。
  3. 构造器注入和 prototype 作用域是循环依赖机制的两个"硬边界",触碰到就只能报错,没有自动绕过的空间。
  4. 循环依赖能被自动解决不代表应该依赖它——它更适合当作一个报错信号,提示回头审视模块的依赖设计。

延伸阅读

  • Spring 源码:DefaultSingletonBeanRegistry#getSingleton、AbstractAutowireCapableBeanFactory#doCreateBean/#getEarlyBeanReference、AnnotationAwareAspectJAutoProxyCreator
  • 本系列相关篇目:081(Spring IoC 与 DI)、082(Bean 生命周期)、083(BeanDefinition 与容器刷新)、084(Bean 作用域)、085(依赖注入方式)、087(Spring AOP 原理)

面试官视角提问(不计入阅读时间)

Q1:既然三级缓存的唯一目的是"给可能存在的 AOP 代理留一个懒加载入口",那为什么不干脆在**populateBean**之前就判断"这个 Bean 需不需要代理",直接生成好放进二级缓存,省掉一层?

因为"需不需要代理"这件事,在实例化那一刻容器是可以判断的(切点匹配是静态信息),但"要不要提前暴露"这件事,容器在实例化那一刻不知道——只有当真的发生循环依赖、有其他 Bean 反过来查找它时,才需要这个引用。如果把代理决策提前到实例化后立即执行,等价于取消了"按需触发"这个语义,所有匹配切点的 Bean 无论是否卷入循环依赖,都会被强制在非标准时机生成代理,这会打乱 Spring 生命周期里其他依赖"代理在 postProcessAfterInitialization 之后才存在"这一假设的后续逻辑(比如某些 BeanPostProcessor 会检查代理类型做特殊处理)。三级缓存的工厂函数是一种"占位但不执行"的设计,执行时机完全交给"是否被查找"这个事件驱动,这比"预先算好"更节省,也更不破坏既有顺序。

Q2:三级缓存能解决字段/Setter 注入的 singleton 循环依赖,那是不是意味着只要不用构造器注入、不用 prototype,循环依赖就完全没有后遗症?

不是。三级缓存只保证"能创建出来、不报错",不保证没有后遗症。至少有两类隐患:一是初始化时机错乱——被提前暴露的那个引用还没执行 @PostConstruct/afterPropertiesSet(),如果对方在构造阶段就调用了它的某个依赖初始化逻辑才能生效的方法,会拿到一个"逻辑上未就位"的对象,只是编译期和大多数场景下不报错,容易埋雷;二是代理身份污染的排查成本——如第五节所说,提前暴露迫使代理提前生成,如果这个提前生成的时机早于某些其他 BeanPostProcessor 依赖的顺序假设,可能引发一些"只在有循环依赖时才出现"的诡异 bug,且很难在日志里直接看出跟循环依赖有关。所以"能自动解决"从来不是"没有代价"的同义词。

Q3:如果两个不同的 Bean(比如 C 和 D)在各自创建过程中,都通过三级缓存的工厂拿到了同一个正在创建中的 Bean A 的早期引用,会不会导致 A 被代理了两次,生成两个不同的代理对象?

不会,这正是二级缓存存在的意义。三级缓存里的 ObjectFactory.getObject() 第一次被调用时,会触发 getEarlyBeanReference() 生成一次代理(如果需要),生成结果立即被存入 earlySingletonObjects(二级缓存),同时这个 Bean 对应的 ObjectFactory 从三级缓存里被移除。回顾第三节的 getSingleton() 源码:查找顺序是一级 → 二级 → 三级,且只有三级缓存里还有工厂时才会触发 getObject()。所以 C 第一次查找 A 时触发工厂生成代理并存入二级缓存、移除三级缓存里的工厂;D 随后查找 A 时,一级缓存没有、但二级缓存已经有了,直接返回同一个引用,工厂根本不会被第二次调用。这也是"三级缓存的工厂只会被调用一次"这个设计要点在并发正确性上的直接体现。

Q4:如果自定义了一个**BeanPostProcessor**,它的**postProcessBeforeInitialization**方法对某个正处于循环依赖链条中的 Bean 抛出了异常,容器会怎么处理三级缓存里已经写入的那些"半成品"引用?会不会造成缓存脏数据残留?

不会长期残留。Spring 的单例创建流程被 try/catch 包裹在 DefaultSingletonBeanRegistry#getSingleton(String, ObjectFactory) 这个更外层的方法里:一旦创建过程中任何阶段抛出异常,容器会执行 afterSingletonCreation() 把这个 Bean 从"正在创建中"的标记集合(singletonsCurrentlyInCreation)里移除,并调用 destroySingleton() 清理掉这个 Bean 名字在一级、二级、三级缓存里可能已经写入的所有痕迹,然后把异常包装成 BeanCreationException 向上抛出,导致整个容器启动失败(对于 singleton 而言,创建失败通常意味着 refresh() 整体失败)。所以不存在"缓存脏数据残留但容器继续跑"的中间态——Spring 对单例创建失败的处理是"全有或全无",这也是为什么循环依赖链条里任何一环的初始化异常,都会让人一开始很难看出问题跟循环依赖本身有关,需要顺着异常链条往回找。

Q5:**allowCircularReferences**这个开关平时几乎没人手动设置过,如果我在一个新项目里把它显式设为**false**,哪些原来"能跑"的代码会突然报错?如何在代码审查里提前发现这种风险?

关闭之后,所有 singleton 之间的循环依赖都会变成 BeanCurrentlyInCreationException,不管注入方式是构造器还是字段/Setter——这个开关是"总闸",字段/Setter 注入能被三级缓存解决的前提正是这个开关默认为 true。如果新项目关闭了它,之前靠字段注入"悄悄跑起来"的循环依赖会集体在启动阶段炸出来,报错信息里会有明确的 Bean 名称链条。审查这种风险不能只看单个类,要看整体依赖图:可以写一个简单的静态分析脚本,扫描所有 @Component/@Service 类之间通过 @Autowired 形成的依赖关系,构建有向图后检测环——检测到环就应该在代码评审阶段提出重构建议,而不是等到某天有人改了容器配置才被动发现。

Q6:抛开 Spring 现有的三级缓存方案,如果让你从零设计一个 IoC 容器,你会选择"允许循环依赖并提供机制解决",还是"完全禁止循环依赖,检测到就在启动期报错,不做任何绕过"?说说你的理由。

会倾向于后者,至少作为默认行为。三级缓存这类机制本质上是在用运行时的复杂度换取"业务代码可以不那么规范也能跑起来"的容忍度——它解决的是症状(跑不起来),不解决病因(模块耦合设计不合理)。一个新设计的容器如果默认禁止循环依赖、检测到直接报错并打印完整依赖链条,会把这类设计问题在编译/启动期就逼出来,长期看对代码库健康度更有利,这也和 Spring 官方现在明确推荐构造器注入(间接等于推荐"让循环依赖报错")的立场是一致的。但如果要兼顾大型历史项目的迁移成本,可以保留一个"逃生舱"式的开关(类似 allowCircularReferences,但默认关闭),让确实需要临时兼容旧代码的团队显式开启,而不是像现在这样默认开启、悄悄掩盖问题。这种"默认严格、显式放宽"的取舍,比"默认宽松"更能倒逼团队在依赖设计阶段思考清楚。