Spring循环依赖与三级缓存:从死锁到提前暴露的解决方案
发布时间:2026/10/16 5:44:12 锦皓数字建站

前阵子有同事拿着一段启动报错来找我一脸困惑BeanCurrentlyInCreationException提示ServiceA依赖了ServiceB而ServiceB又依赖回ServiceASpring 容器直接罢工了。这类问题在面试里是高频题在线上环境里也时不时冒出来——这就是咱们今天要聊的Spring 循环依赖。很多人一开始的理解就是死锁两个 Bean 互相等对方先创建完结果谁都等不到。但 Spring 并没有靠死锁检测去解决它而是用了一套三级缓存机制巧妙地把这个鸡生蛋蛋生鸡的僵局给拆开了。这篇文章我想把这两件事讲透循环依赖为什么像死锁、三级缓存又是怎么把僵局解开的。同时我也会把为什么非得是三级、两级行不行哪些循环依赖 Spring 救不了线上项目里到底该怎么处理这几个问题一并说清楚适合所有写 Java 的开发者尤其是刚接触 Spring 源码、或者被循环依赖坑过的人。1. 循环依赖为什么像死锁先搞清问题长什么样1.1 一个最小复现案例两个 Bean 互相引用先看一段最经典的循环依赖代码Component public class ServiceA { Autowired private ServiceB serviceB; } Component public class ServiceB { Autowired private ServiceA serviceA; }这两个类没有任何业务逻辑但只要把它俩同时放进 Spring 容器启动时大概率就会抛出一串让人摸不着头脑的异常BeanCurrentlyInCreationException: Error creating bean with name serviceA: Requested bean is currently in creation: Is there an unresolvable circular reference?serviceA正在创建的时候有人其实就是serviceB又来要serviceA但此刻serviceA还没创建完Spring 只能报错。这条错误的字面意思非常直白你正在创建我这个 Bean可你又来找我要我自己。很多人第一次看到这个错误第一反应是线程死锁。我当年也这么理解过但后来看源码才发现这里的死锁和操作系统里的死锁并不完全是一回事更准确地说它是一个创建流程上的逻辑死锁。1.2 Spring 创建 Bean 的两步走流程要明白为什么循环依赖会卡住得先知道 Spring 创建一个单例 Bean 大致要经过哪些阶段实例化Instantiation通过构造函数new出一个对象此刻对象里的属性都是空的可以理解为一个半成品。属性填充Populate把依赖的其他 Bean 注入进来比如Autowired字段就是在这个阶段处理的。初始化Initialization执行InitializingBean、PostConstruct、init-method等回调逻辑。放入单例池singletonObjects至此一个 Bean 才算真正创建完成可以被其他对象正常引用了。关键在于属性填充发生在对象完整初始化之前。也就是说ServiceA在填充serviceB字段时自己的初始化流程还没走完而ServiceB在填充serviceA字段时ServiceA也还躺在半成品状态。如果把这两个创建过程放到同一时间线上看就像两个线程互相等对方先释放资源ServiceA等ServiceB创建完ServiceB等ServiceA创建完但 Spring 的默认逻辑是必须等你完整创建完我才能拿去注入给别人。这样一来谁都不敢先松手形成了一个逻辑上的僵局。1.3 为什么死锁这个类比其实不够准确严格来说Spring 的循环依赖问题本质上是单线程内的创建顺序悖论而不是多线程资源竞争导致的死锁。真正的死锁需要满足互斥、持有并等待、不可剥夺、循环等待这四个条件而且通常发生在资源层面。Spring 循环依赖里并没有多线程同时去抢同一份资源它更像是一个必须先有 A 才能有 B可 B 又是 A 的前提的悖论。在函数调用栈上这个调用关系会一层层嵌套getBean(serviceA) → createBean(serviceA) → 填充 serviceB 字段 → getBean(serviceB) → createBean(serviceB) → 填充 serviceA 字段 → getBean(serviceA) // 又回到原点 → 此时 serviceA 还在创建中报错看到没有这是一条追着自己尾巴跑的调用链。Spring 之后的解决方案本质上就是在这个调用链上开了一道口子当你第二次回来要serviceA的时候我不再要求它完整创建完而是先把那个半成品引用给你用。后面的文章会详细拆解这个过程。这里先记住一个词——提前暴露它就是打破僵局的关键。2. 三级缓存的完整工作流程Spring 如何拆掉僵局2.1 三级缓存到底存的是什么东西Spring 解决单例 Bean 循环依赖的机制核心就是DefaultSingletonBeanRegistry里的三个 Map常说的三级缓存指的就是它们// 一级缓存存完整的 Bean也就是最终大家拿到的那个对象 private final MapString, Object singletonObjects new ConcurrentHashMap(256); // 二级缓存存半成品 Bean已经实例化但还没完全初始化 private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); // 三级缓存存 ObjectFactory用于在真正需要时生成早期引用 private final MapString, ObjectFactory? singletonFactories new HashMap(16);很多人第一次看这三个 Map很容易把注意力放在ConcurrentHashMap和HashMap的线程安全性区别上但这里真正重要的不是并发问题而是每个缓存层存放的东西不一样一级缓存里是完整对象属性填好了、初始化也执行完了可以放心使用二级缓存里是半成品对象它已经new出来了但属性还没填充或者还没初始化三级缓存里放的不是 Bean 本身而是一个ObjectFactory一个可以在需要时生产出早期引用的工厂回调。这里要特别注意第三级它里面不是一个对象引用而是一段逻辑。Spring 把如何生成早期引用的决策封装成了工厂只有当你真正需要这个早期引用时ObjectFactory.getObject()才会被调用。这个设计在后面讲 AOP 的时候会体现出它的精妙之处。2.2 走一遍完整创建流程A 和 B 的接力赛现在假设容器里只有ServiceA和ServiceB这两个 Bean并且它们互相Autowired。启动时容器扫描到这两个类开始创建。我把流程一步步拆开看用户或者容器初始化触发getBean(serviceA)一级缓存查不到进入创建流程。通过构造器反射实例化出ServiceA对象此时它的serviceB字段还是null。实例化完成后Spring 立刻做了一件事把这个半成品包装成一个ObjectFactory放入三级缓存singletonFactories。这一步的匿名内部类大概长这样addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean));接着填充ServiceA的属性发现需要ServiceB于是调用getBean(serviceB)。ServiceB也不在一级缓存里于是同样走创建流程实例化ServiceB对象放入三级缓存开始填充属性。填充ServiceB的属性时发现需要ServiceA于是又调用getBean(serviceA)。这次getBean(serviceA)会先查一级缓存——没有再查二级缓存——也没有接着查三级缓存singletonFactories——找到了里面是第 3 步放进去的ObjectFactory。Spring 调用这个ObjectFactory.getObject()拿到了ServiceA的早期引用也就是那个半成品对象然后把这个引用放入二级缓存earlySingletonObjects同时把ServiceA对应的ObjectFactory从三级缓存移除。ServiceB拿到了ServiceA的引用把它注入到自己的serviceA字段里。至此ServiceB完成属性填充继续走初始化逻辑最后被放入一级缓存singletonObjects。ServiceB创建完成后调用栈回到第 4 步的ServiceA属性填充阶段。此时ServiceA终于拿到了完整创建好的ServiceB把自己的serviceB字段也填上。ServiceA继续走初始化逻辑完成后放入一级缓存singletonObjects。整个创建流程结束。这个过程里第 6、7、8 步就是整套机制的核心当ServiceB再次索要ServiceA时Spring 没有拒绝而是把半成品提前交出去了。代价是ServiceB拿到的ServiceA当时还没有完成属性填充和初始化但它拿到的是同一个对象引用——引用是确定的不会变。2.3 第 8 步为什么要把引用挪到二级缓存有个细节值得单独拿出来说一下ObjectFactory的getObject()理论上并不保证每次返回同一个对象。如果第 8 步之后又有别的 Bean 问ServiceA要引用Spring 还能再从三级缓存里把那个ObjectFactory拿出来再调一次。但如果这个ObjectFactory内部逻辑依赖某些临时状态比如提前生成的代理对象每次调用都重新生成就可能导致同一个BeanName返回不同的对象引用——这就破坏了单例的语义。所以 Spring 的处理是只调用一次getObject()把结果放进二级缓存earlySingletonObjects以后别人再要直接从这个缓存里拿。这相当于把整个流程中唯一的生产动作固定下来之后全部走查缓存。用排队取餐来类比的话一级缓存是你已经坐好开吃的座位二级缓存是你已经端着餐盘找到的位置三级缓存则是你手里的排队小票——小票只能用一次换完餐就作废但你换到的餐是确定的不会一会儿变汉堡一会儿变面条。2.4 源码里的两个关键钩子看源码的话doCreateBean方法里实例化完成后的这段逻辑是整个机制的入口if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }它的意思是如果当前这个 Bean 允许提前暴露就把它包装成工厂挂到三级缓存里。getEarlyBeanReference则是给后置处理器一个篡改早期引用的机会——后面讲 AOP 的部分会重点说它。而getSingleton方法里查三级缓存并生成早期引用的逻辑是关键的转折点Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } }注意中间那个条件isSingletonCurrentlyInCreation(beanName)——只有当这个 Bean 此刻正处于创建过程中才会去查后两级缓存。如果你只是单纯想提前获取一个还没创建的 BeanSpring 不会给你走这条捷径。这也是为什么通常只有循环依赖发生时才会真正触发三级缓存到二级缓存的挪移。3. 为什么偏偏是三级缓存两级到底行不行3.1 一级缓存救不了这是必然的先回答一个最基础的问题为什么必须有多级缓存一级不行吗一级缓存singletonObjects只在 Bean 完整创建完成之后才放入。ServiceA还在填充属性阶段一级缓存里根本没有它。所以ServiceB去查一级缓存只能得到null而 Spring 对一个不存在的单例 Bean 的处理逻辑是那就新创建一个。于是ServiceB会尝试重新创建ServiceA但ServiceA的同名单例正在创建中且已标记为创建中状态直接导致BeanCurrentlyInCreationException。就算不用这个状态拦截真的强行再创建一个新的ServiceA也会打破单例语义而且ServiceA又会去查ServiceB最终无限递归直到栈溢出。所以说没有提前暴露机制单例循环依赖在单例池模型下根本走不通。3.2 两级缓存能解决循环本身但会毁掉 AOP那如果把逻辑改成实例化后直接把半成品对象丢到二级缓存里不搞三级缓存不也能让ServiceB拿到ServiceA的早期引用吗答案是对于普通 Bean两级缓存确实够用。很多 Spring 早期的实现讨论里都提过这点两级缓存完全可以解决引用互相等的问题。但真实项目中Bean 往往不只是简单的new出来的对象它们身上可能挂着事务注解、切面逻辑、异步代理等。这就涉及 AOP。问题出在代理对象上。假设ServiceA上加了TransactionalSpring 在正常流程中会等ServiceA初始化完成后通过BeanPostProcessor生成一个代理对象然后把这个代理对象放进一级缓存。也就是说其他 Bean 最终拿到的其实是代理后的ServiceA事务逻辑才能生效。但循环依赖发生时ServiceB在ServiceA初始化完成之前就要拿引用。如果只有两级缓存ServiceB拿到的是刚new出来的裸对象——它没有经过任何代理处理后续事务、切面全都丢了。ServiceB里持有的那个ServiceA和一级缓存里最终那个带代理的ServiceA根本不是同一个对象。这时候你可能想那把代理提前到早期引用阶段生成不就行了问题是代理不是所有 Bean 都需要。如果 Spring 对所有提前暴露的 Bean 都提前做 AOP那些压根没发生循环依赖的 Bean 也会被多做一层无意义的代理操作性能受影响且语义混乱。所以需要在不知道到底会不会发生循环依赖的情况下把是否生成代理这个决策往后拖延。3.3 三级缓存的本质把决策延迟到真相大白的时刻三级缓存里的ObjectFactory存的就是这段延迟决策的逻辑addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean));这个方法会遍历所有SmartInstantiationAwareBeanPostProcessor让它们有机会在早期暴露阶段就介入代理。比如AbstractAutoProxyCreator中就实现了getEarlyBeanReference它内部会检查当前 Bean 是否需要提前代理。如果这个引用最终真的被循环依赖方拿走了那么拿到的就是一个已经被提前代理的对象如果自始至终没有被拿走这套逻辑就压根不执行Bean 走完正常流程后在最后阶段再生成代理也不迟。所以第三级缓存的真正价值是缓存的内容不是半成品对象而是生成半成品对象的策略。这个策略只有在一个 Bean 真的被其他创建中的 Bean 索要时才会被触发。两级缓存加上ObjectFactory一起看三级缓存其实是一个懒加载的中间层——它把是否提前暴露、是否提前代理的选择权交还给了实际情况。这里可以手写一个极简伪代码帮大家理解三级缓存分工public class ThreeLevelCacheDemo { MapString, Object complete new HashMap(); // 一级完整 Bean MapString, Object early new HashMap(); // 二级早期引用 MapString, SupplierObject factories new HashMap(); // 三级工厂策略 public Object getBean(String name) { if (complete.containsKey(name)) return complete.get(name); if (early.containsKey(name)) return early.get(name); if (factories.containsKey(name)) { Object obj factories.remove(name).get(); // 执行真正的生产逻辑 early.put(name, obj); // 结果固定到二级 return obj; } return createBean(name); } }看到没有如果全程没有循环引用依赖到当前 Bean那么factories.remove(name).get()这一行永远不会执行。三级缓存就是这样一个很少被真正触发、但一旦触发就能救场的设计。3.4 一个经典反例没有三级缓存时 AOP 会出什么事故假设只有两级缓存并且规定实例化后立刻放入二级缓存。再假设存在这样一个场景ServiceA加了TransactionalServiceB注入了ServiceA但两者并不构成循环依赖——完全正常的依赖关系。按照两级缓存的粗暴逻辑ServiceA在实例化后就被提前暴露了它可能在某个时刻被别人从二级缓存拿走而这时的ServiceA还没生成事务代理。哪怕后续ServiceA正常完成了代理生成并进入了singletonObjects这个被别人从二级缓存拿走的老对象也已经流落到了外面的多个调用方手里。它们拿到的对象没有事务能力系统就会出现有的地方调用有事务、有的地方没事务这种诡异问题。三级缓存把提前暴露变成了一种按需行为没有循环依赖就不执行getEarlyBeanReference有循环依赖才把代理逻辑提前。这样既保住了循环依赖场景下的代理一致性又不影响正常场景的性能和语义。理解到这里你才算真正读懂了三级缓存的三字为什么要写成三。4. 循环依赖的边界哪些场景 Spring 也救不了三级缓存确实强大但它不是万能的。实际开发中我更常遇到的恰恰是那些它管不了、只能靠人工改代码的场景。我把几个最典型的边界情况列一下每个都给出一个判断依据方便你排查时快速定位。4.1 构造器注入的循环依赖无从下手构造器注入的循环依赖Spring 明确救不了而且会直接抛异常Component public class ServiceA { private final ServiceB serviceB; public ServiceA(ServiceB serviceB) { this.serviceB serviceB; } } Component public class ServiceB { private final ServiceA serviceA; public ServiceB(ServiceA serviceA) { this.serviceA serviceA; } }问题出在时机上。构造器注入要求在实例化阶段就必须把依赖参数准备齐全可它是通过构造器传入的——还没有 A 就不可能构造 B没有 B 就不可能构造 A。三级缓存能提供的半成品是在实例化完成之后才产生的而构造器阶段连半成品都还没有。所以不管缓存有几级都无法在实例化开始之前打破这个前置依赖。Spring 之所以不做特殊处理也是设计上的选择构造器注入的循环依赖几乎一定是设计问题与其支持不如报错提醒开发者重构。想验证这个结论很简单把Autowired直接写在构造器上或者用RequiredArgsConstructor配合final字段一旦出现 A 和 B 互相通过构造器引用启动时就会立刻看到异常。不是 Spring 能力不够是物理上没法破局。4.2 原型Prototype作用域的循环依赖没有缓存放你进来三级缓存机制是围绕单例 Bean 设计的。singletonObjects、earlySingletonObjects、singletonFactories存储的都是单例而 prototype 作用域的 Bean 本身不会被缓存每次getBean都会新建实例。假设ServiceA和ServiceB都是Scope(prototype)A 里注入了 BB 里又注入了 A。当容器尝试创建 A 时发现需要 B创建 B 时发现又需要 A。此时 A 并不在任何缓存里它只是刚刚还在创建过程中Spring 没有地方记录这个 A 正在创建你可以先从一个中间层拿到它的引用。于是会陷入无限递归直到抛出StackOverflowError或者像前面提到的那样报BeanCurrentlyInCreationException。所以记住一个判断标准循环依赖的救星只在单例 提前暴露这条路径上有效命题成立的前提是你用的作用域确实支持缓存。如果在 prototype 场景遇到循环依赖唯一的解法就是重构设计别指望框架。4.3 Async 引发的代理悖论还有一种情况比较隐蔽它跟 AOP 代理的时机有关。假设ServiceA上标了AsyncServiceB注入了ServiceA同时 A 又注入了 B形成循环依赖。你会发现启动时可能直接报错或者ServiceB拿到的ServiceA不是异步增强后的代理对象。原因在于Async的代理和普通Transactional的代理在生成时机和方式上不完全一样。它依赖的是一个独立的AsyncAnnotationBeanPostProcessor后置处理器而且该处理器在早期引用阶段的行为与事务代理也不同。具体到 Spring 版本不同、处理逻辑不同但结论是明确的涉及循环依赖的 Bean如果叠加了 Async很可能启动失败或代理失效。Spring Boot 2.6 之后也把循环依赖默认禁止了这类组合问题在较新版本中更是直接亮红灯。遇到这种问题我建议直接动手改代码而不是研究怎么让 Async Bean 也能循环。Async 本身意味着异步边界正常情况下就不该跟依赖方纠缠不清。4.4 Spring Boot 2.6 之后的默认禁止可能有人会问既然三级缓存能解决大部分循环依赖为什么 Spring Boot 从 2.6 起默认把allow-circular-references设为false也就是说哪怕你的代码是字段注入、单例、完全满足三级缓存解决的条件新版本启动时也会直接报错。官方的态度很明确循环依赖虽然被框架解开了但它仍然是一种设计上的坏味道。它让 Bean 的创建过程隐式地依赖彼此也让测试和排查变得更复杂。所以新版本宁可让你启动失败也要推动你去重构。如果你确实需要兼容旧行为可以在配置里临时开启spring: main: allow-circular-references: true我个人的建议是这只适合做短期过渡。长期留着循环依赖等于在上线前埋了一个雷等哪天改了其中一个 Bean 的作用域或者加了 AOP 代理问题就会突然爆发。5. 实战避坑应对循环依赖的几种可靠方案说了这么多机制最后聊点实际的项目里真的遇到循环依赖到底该怎么办。我的经验是不要第一时间想三级缓存会帮我解决而是先判断这个循环是不是我设计出来的问题。绝大多数情况下重构比绕道更划算。5.1 方案一打破循环本身抽出一个中间层循环依赖的本质是 A 要 B、B 要 A两者职责纠缠。最常见的解法是把其中一部分公共依赖抽到第三个类 C 里。比如原来的场景是ServiceA需要调用ServiceB.fetchData()同时ServiceB需要调用ServiceA.convertData()。这本身就是个设计味道很重的结构。更合理的做法是抽一个DataService负责fetchData和convertData的逻辑ServiceA和ServiceB都只依赖DataService互不引用。这样一来循环依赖自然消失而且每个类的单一职责也更清晰了。这是我最推荐的方案没有之一。它不依赖框架的救场后续维护成本最低。5.2 方案二Lazy 延迟注入让代理占坑如果重构成本比较高或者临时要尽快通过编译和启动可以在其中一个注入点上加LazyComponent public class ServiceA { Autowired private ServiceB serviceB; } Component public class ServiceB { Autowired Lazy private ServiceA serviceA; }Lazy的作用是让 Spring 注入一个 JDK 动态代理或 CGLIB 代理对象作为占位等到真正调用serviceA.xxx()时代理才会去容器里获取真实的ServiceA。这样做能破掉必须立刻拿到完整对象的约束但要注意它只是把问题往后拖延了并没有根治循环。如果两个对象实际运行时会一直互相调用最终可能还会在运行时暴露问题。5.3 方案三Setter 注入或字段注入配合三级缓存如果你确实不想动代码又只想让现有的字段注入循环依赖跑起来那可以确认一下你的 Bean 是单例吗是字段或 Setter 注入而不是构造器注入吗Spring Boot 版本允许循环引用吗单例满足字段注入 / Setter 注入满足Spring Boot 2.6需要在配置里设置spring.main.allow-circular-referencestrue。只要这三条都满足三级缓存机制确实能把这个循环跑通。但我还是要强调这属于框架兜底不是工程上值得推荐的做法。长期项目里循环依赖会让 Bean 之间的依赖图变得复杂排查问题时每一层引用都要多绕一圈。5.4 方案四善用 IDE 的依赖分析来预防与其出了事再救火不如在设计阶段就提前发现。IDEA 这类工具其实已经提供了依赖分析的能力你可以直接查看工程内部的模块依赖图如果项目用了 Maven也可以借助依赖插件分析模块间引用关系。规范一点的团队甚至会在 CI 里加一条自动化检查扫描所有 bean 的Autowired字段用图算法检测是否存在环存在就直接让构建失败。我习惯的做法是在代码评审阶段就重点关注新加的类是否和已有的类形成了双向引用一旦出现双向引用立刻追问设计原因。通常问两三轮就会发现有很多循环依赖其实是因为图省事把本应属于第三方的逻辑硬塞到了自己类里。5.5 处理 Async、构造器循环等疑难场景时的最终建议如果遇到的是构造器循环或者 Async 代理循环那是框架兜不了底的前面说的重构和 Lazy 也不一定都适用。我的实际体会是这类疑难场景十有八九指向同一个问题职责边界没划清。比如一个服务既要做耗时的异步任务又要被其他服务同步引用那不如拆成两个类一个异步执行、一个同步调度通过中间队列解耦。代码看似多了一个类但逻辑边界和测试边界都清爽得多。我自己在项目里踩过最深的坑是一个业务模块内部互相引用当时图省事加了 Lazy 让启动通过结果半年后有人改了其中一个 Bean 的作用域线上第二天就出现了偶发的初始化异常。从那之后我对循环依赖的态度就变了它是一门值得深挖的框架原理课但绝不是一个应该留在生产代码里的常规手段。如今每次看到循环依赖四个字我第一反应永远是三个字先重构。写到这里三级缓存的旅程也基本到站了。回头再看Spring 用三级缓存解决的问题本质上是在对象的完整性和创建的时序性之间做了一次优雅的妥协为了让你在不完整的时候拿到引用它引入了按需生产的工厂为了不破坏 AOP 的语义它把代理生成推迟到真正需要暴露的瞬间。这些设计思路比循环依赖本身更值得反复咀嚼。如果你正在学习 Spring 源码我强烈建议你亲手在doCreateBean里加几个断点跑一遍带循环依赖的示例程序观察三个缓存 Map 的变化过程——纸上谈兵一百遍不如断点走一遍。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。