拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring循环依赖从报错到根治:三级缓存、@Lazy与重构实战

我先抛个真实的报错现场。你启动一个 Spring Boot 服务控制台突然出现这么一段红字Error creating bean with name aService: Requested bean is currently in creation: Is there an unresolvable circular reference?看到circular reference这个短语基本可以断定循环依赖Circular dependency炸了。这个报错在 Spring 开发里太经典了尤其在业务模块一多、Service 之间互相调用的项目里几乎每个人都会遇到一次。很多人第一次碰见时一脸懵明明就是两个类互相Autowired一下怎么容器直接启动失败这篇文章我会从三个角度帮你彻底解决循环依赖先讲清楚它为什么报错再给出 3 种靠谱的解决方案最后分享我这些年踩过的坑——尤其是那种“启动不报错运行期才出诡异问题”的隐蔽坑。无论你是刚接触 Spring 的新手还是已经在项目里被循环依赖折磨过的中级开发者这篇文章都能让你少走弯路。1. 循环依赖到底是什么——先搞清楚它为什么报错很多人拿到报错第一反应是去搜“Spring 循环依赖怎么解决”但我建议你先把问题本身看清楚。只有理解了 Spring 创建 Bean 的过程你才知道哪些循环依赖能救、哪些救不了、哪些根本不该存在。1.1 一个最直观的报错现场先看一段最典型的构造器注入循环依赖代码Service public class AService { private final BService bService; public AService(BService bService) { this.bService bService; } } Service public class BService { private final AService aService; public BService(AService aService) { this.aService aService; } }启动项目后Spring 会创建AService但创建它需要先拿到BService于是 Spring 转头去创建BService结果BService的构造器又要AService。这时候AService还在创建流程中半成品都不算完整Spring 根本拿不出一个可用的AService给BService于是抛出前面那个异常。用大白话讲这就像两个人约好互相开门A 说“你先开门我才能进去”B 说“你先开门我才能进去”结果两个人都堵在门口谁也别想进。这个场景下Spring 的三级缓存也好、各种黑科技也好统统救不了。原因后面第 6 章我会详细讲你只需要先记住一个结论构造器注入的循环依赖Spring 无解。1.2 循环依赖的三种常见形态与处置边界循环依赖根据注入方式不同命运完全不同。我整理了一张表你可以直接收藏依赖形态典型写法Spring 能否自动解决构造器注入循环依赖构造器互相传对方不能只能靠Lazy或重构Setter 注入循环依赖Autowired加在 setter 上单例 Bean 下可以靠三级缓存字段注入循环依赖Autowired直接加在字段上单例 Bean 下可以靠三级缓存注意表格里反复出现的“单例 Bean”这个前提。Spring 容器里默认的Service、Component都是单例三级缓存只对单例 Bean 生效。如果你的 Bean 是Scope(prototype)哪怕用的是字段注入循环依赖照样直接炸。这四种情况有没有更细的边界构造器循环依赖确实无解但可以通过Lazy这个“拆弹器”把循环打破字段注入和 setter 注入虽然能救但救下来的 Bean 是个“半成品提前暴露”的产物后面可能埋下代理失效的坑。这些我都会在对应章节展开。2. 方案一构造器循环依赖——Lazy 的代理解法先说结论如果你的项目坚持使用构造器注入这也是 Spring 官方推荐的方式因为能保证依赖不可变、方便测试遇到循环依赖时Lazy是最快的解法。2.1 Lazy 为什么能“骗过” SpringLazy的原理并不复杂它给被注入的对象生成一个代理占位符真正调用这个对象的方法时才去容器里查找真实 Bean 并触发其创建。还是用刚才的例子修改一下Service public class AService { private final BService bService; public AService(Lazy BService bService) { this.bService bService; } }现在 Spring 创建AService时虽然构造器需要BService但注入进去的是一个BService的代理对象这个代理背后并没有真正去创建BService。等到AService创建完成、方法真正调用bService.someMethod()时代理才会去容器里找真正的BService并完成创建。这样循环依赖就被“拆”开了A 创建时不强制要完整 BB 之后可以正常创建。那 B 需要 A 怎么办B 是在 A 创建完成之后才被真正触发的此时从容器里拿 A 已经是一级缓存里的完整实例一切顺畅。这个方案对构造器循环依赖同时适用只要在任意一端的构造器参数上加Lazy就能打破循环。同样Lazy也可以用在字段注入和 setter 注入上比如Service public class BService { Autowired Lazy private AService aService; public void doSomething() { aService.hello(); // 第一次调用时才触发 A 的创建 } }用Lazy有一个很大的好处不改变原有代码结构一行注解就能解决问题生产环境紧急修复时非常快。2.2 Lazy 的适用边界与注意事项Lazy虽然好用但绝不是银弹。我见过有人为了图省事把项目里互相依赖的 Bean 全部加上Lazy最后整个容器的初始化顺序变得非常不可控启动时不再报错但业务方法执行时机完全依赖第一次调用的运气这种代码维护起来非常痛苦。我的建议是Lazy作为“临时拆弹”手段可以但不要把它写进核心架构里。它有几个明显的副作用错误被延后如果 B 的创建本身有问题比如依赖的配置缺失启动时不会暴露直到你第一次调用相关方法才炸排查问题的难度会直线上升。代理链变复杂Lazy本身就是一个代理对象如果 Bean 同时还有 AOP 代理、事务代理多层代理嵌套会让排查问题异常痛苦。容易掩盖设计问题循环依赖本身往往意味着类之间的职责边界没划清楚。用Lazy只是让程序能跑起来并没有消除坏的设计。所以在真实项目中我一般把Lazy当作“止血工具”后面一定会找时间做第 4 章说的重构。3. 方案二Setter/字段注入循环依赖——三级缓存天然支持如果说构造器循环依赖是无解的那 setter 注入和字段注入的循环依赖就是 Spring 专门留了一条“后门”。这个后门就是面试高频考点Spring 三级缓存。3.1 三级缓存到底存了什么先看 Spring 源码里的三级缓存定义在DefaultSingletonBeanRegistry中/** 一级缓存存放完整的单例 Bean 对象 */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存存放提前暴露的早期单例 Bean 对象半成品 */ private final MapString, Object earlySingletonObjects new HashMap(16); /** 三级缓存存放单例 Bean 的 ObjectFactory用于生成早期引用 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);三个缓存各司其职我直接用大白话解释一级缓存singletonObjects存“成品”。Bean 已经完成实例化、属性填充、初始化随时可以对外提供服务。二级缓存earlySingletonObjects存“半成品”。Bean 已经执行了构造器、完成了实例化但属性还没填充完整就被人提前拿走了。三级缓存singletonFactories存“半成品的生产配方”。里面不是 Bean 本身而是一个ObjectFactory等有人需要这个半成品时再调用工厂方法现场生成一个早期引用。你可能会问三级缓存为什么不直接放对象这正是第 3.3 节要讲的核心问题这里先留个悬念。3.2 一次完整的循环依赖创建流程拆解假设 A 和 B 互相依赖并且都是字段注入。我用最经典的流程带你走一遍Spring 开始创建AService先调用构造器实例化出一个 A 对象此时 A 还没有填充任何属性。实例化完成后Spring 立刻把 A 的ObjectFactory放入三级缓存这个行为叫“提前暴露”。Spring 开始为 A 填充属性发现 A 依赖BService于是去容器里找 B。容器里没有 BSpring 开始创建BService实例化出 B 对象后同样把 B 的ObjectFactory放入三级缓存。Spring 开始为 B 填充属性发现 B 依赖AService于是再次去容器里找 A。一级缓存和二级缓存里都没有 A但在三级缓存里找到了 A 的ObjectFactory。Spring 调用这个工厂得到一个 A 的早期引用把它放入二级缓存并从三级缓存中移除 A 的工厂。B 拿着这个 A 的早期引用成功完成自己的属性填充、初始化和创建最终放入一级缓存。回到第 3 步A 从一级缓存里拿到完整的 B继续完成自己的属性填充、初始化和创建最终也放入一级缓存。整个流程的关键在于第 6 步A 还在“属性填充”阶段就被 B 拿走了但因为 A 已经实例化完成B 拿着 A 的引用调用 A 的方法是没问题的——只要 A 的属性在那一刻还没有被使用到或者提前暴露前被正确设置。这个机制就像饭店里先给排队的客人发个号牌客人拿了号可以先坐着等后厨有菜了再补齐。B 拿到的是“号牌”但它并不需要 A 立刻把所有菜上齐只要后续能补上就行。3.3 为什么二级缓存不够——AOP 代理的提前暴露问题这是我被问到最多的一个问题也是很多人对三级缓存理解不到位的地方。网上很多文章说二级缓存就能解决循环依赖这话理论上是成立的。如果只是需要一个普通对象引用二级缓存确实够了。但 Spring 在引入三级缓存时还考虑了一个重要场景AOP 代理。假设 A 类上有Transactional注解。正常流程下A 实例化、填充属性、初始化完成之后Spring 的BeanPostProcessor会为 A 生成一个事务代理对象最后放在一级缓存里的其实是代理对象。现在问题来了B 在属性填充时拿到的 A 早期引用应该是原始对象还是代理对象正确答案是代理对象。因为 B 之后调用 A 的方法如果走原始对象事务注解就完全失效了。如果只有一级缓存和二级缓存三级缓存不存在Spring 在 B 需要 A 时只能从“已经实例化的 A”里直接拿一个原始对象放到二级缓存。这个原始对象没有经过 AOP 代理后面 A 即使生成了代理对象B 手里拿的依然是原始对象事务失效、缓存失效等各种诡异问题接踵而至。三级缓存之所以存在是因为第三级里存的是ObjectFactory这个工厂在执行时有机会调用getEarlyBeanReference在 A 还没有完成初始化前就提前把 AOP 代理生成出来。B 拿到的早期引用于是就是正确的代理对象。一句话总结三级缓存既是为了解决循环依赖也是为了在解决循环依赖的同时让早期暴露的 Bean 仍然是正确的代理对象。没有三级缓存循环依赖虽然能解决但 Spring 的 AOP 体系会大打折扣。4. 方案三重构设计——从根上消除循环依赖前两种方案都是在“既有代码”上进行修补而第三种方案才是治本之策重新审视你的类设计把循环依赖从源头消灭掉。4.1 循环依赖本身就是设计的坏味道我见过太多循环依赖案例绝大多数不是 Spring 的问题而是类的职责边界没划清楚。举个例子订单服务和支付服务互相依赖。Service public class OrderService { Autowired private PaymentService paymentService; public void createOrder() { // 创建订单后需要支付 paymentService.pay(100); } } Service public class PaymentService { Autowired private OrderService orderService; public void pay(double amount) { // 支付时需要查询订单信息 Order order orderService.getOrderById(1L); // 处理支付 } }这个设计看起来合理两边确实都需要对方的能力。但仔细想想订单服务和支付服务本质上是两个独立的业务域它们之间应当是“A 调用 B”或者“B 调用 A”的单向关系而不是互相依赖。循环依赖出现后不仅类之间耦合变重后续想要拆模块、做单测都会非常痛苦。更麻烦的是循环依赖会掩盖问题启动时一切正常但一旦某个类加了Transactional、Async等注解早期暴露的对象就会和最终容器里的代理对象不一致出问题的时间完全随缘排查起来极其心累。4.2 三种重构手法中间层、依赖抽象、事件解耦根据业务场景不同我总结了三种最实用的重构手法你可以按需选择。手法一提取中间协调者当两个服务互相调用是“业务编排”层面的需求时把编排逻辑抽到一个独立的 Facade/协调类里让两个服务只依赖协调者不互相依赖Component public class OrderPaymentFacade { private final OrderService orderService; private final PaymentService paymentService; public OrderPaymentFacade(OrderService orderService, PaymentService paymentService) { this.orderService orderService; this.paymentService paymentService; } public void createOrderAndPay(double amount) { Long orderId orderService.createOrder(); paymentService.payByOrderId(orderId, amount); } }改造后OrderService和PaymentService都不再互相引用循环依赖被彻底切断。这个方案对代码侵入最小最推荐。手法二依赖抽象或延迟查找如果两个类确实需要互相协作通过引入接口来打破强依赖。比如PaymentService不直接注入OrderService而是注入一个只含有自己所需方法的接口由外部实现。另一种做法是使用 Spring 的ObjectProvider延迟获取Service public class PaymentService { private final ObjectProviderOrderService orderServiceProvider; public PaymentService(ObjectProviderOrderService orderServiceProvider) { this.orderServiceProvider orderServiceProvider; } public void pay(double amount) { OrderService orderService orderServiceProvider.getIfAvailable(); Order order orderService.getOrderById(1L); } }ObjectProvider的好处是它在需要时才去容器里找OrderService不会在构造阶段造成循环依赖。而且就算OrderService不存在也不会启动失败配合getIfAvailable可以做更优雅的降级处理。手法三事件驱动解耦如果业务本身是“事件触发”的天然模型用事件发布订阅代替强引用是最彻底的方案。比如支付完成之后订单需要更新状态这完全符合事件语义Service public class PaymentService { private final ApplicationEventPublisher eventPublisher; public void pay(double amount) { // 支付逻辑 eventPublisher.publishEvent(new PaymentSuccessEvent(orderId, amount)); } } Component public class OrderServiceListener { EventListener public void onPaymentSuccess(PaymentSuccessEvent event) { // 更新订单状态不再直接调用 PaymentService } }事件化改造后PaymentService和OrderService完全解耦新增逻辑不会引入新的循环依赖。当然事件模式也有它的代价调用链变长、即时性变弱适合对强一致性要求不高的场景。5. 避坑指南我踩过的循环依赖之坑把循环依赖解决了只是第一步。在实际开发中更折磨人的是那些“表面能启动运行期才炸”的隐蔽问题。下面这几个坑都是我亲测踩过的。5.1 Async 循环依赖为什么你的异步方法不生效先看一个非常典型的场景Service public class AService { Autowired private BService bService; Async public void asyncMethod() { // 异步执行的任务 } } Service public class BService { Autowired private AService aService; public void callAsync() { aService.asyncMethod(); } }如果AService和BService形成循环依赖Spring 会通过三级缓存在创建AService的半成品阶段就让BService拿到 A 的早期引用。问题来了Async的代理是由AsyncAnnotationBeanPostProcessor在 Bean 初始化之后生成的。也就是说B 拿到的早期 A 可能还是原始对象还没有经过异步代理的包装。最终你会看到一个诡异现象BService里调用aService.asyncMethod()时方法没有异步执行而是在当前调用线程里同步跑完。更严重的甚至会出现ClassCastException因为容器里最终放的是代理后的 A而 B 持有的是早期原始 A两边不是同一个对象。怎么避免我的经验是循环依赖场景下Async和Transactional这种依赖 AOP 代理的注解最容易出问题。一旦出现这类组合不要指望三级缓存帮你搞定一切最简单的处理方式是给其中一个注入点加Lazy从源头避免早期暴露。5.2 prototype 作用域循环依赖必炸很多人在单例 Bean 里习惯了循环依赖能自动解决然后有一天把某个 Bean 改成Scope(prototype)服务启动直接挂掉。Component Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class AService { Autowired private BService bService; } Component Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class BService { Autowired private AService aService; }prototype 作用域的 Bean 默认不缓存、不提前暴露每次获取都新建Spring 根本没有地方存“半成品”所以遇到循环依赖时直接抛BeanCurrentlyInCreationException。这个坑的核心教训是循环依赖能救的前提是单例 Bean。如果你需要多例对象应该改用ObjectProvider、ObjectFactory或者Lookup方法去动态获取而不是让多例 Bean 之间互相Autowired。5.3 代理失效、延迟初始化错乱循环依赖的连锁反应循环依赖最隐蔽的危害是它会让 Spring 的 Bean 生命周期看起来非常混乱。举个例子A 依赖 BB 又依赖 AA 上有Transactional。B 在创建时从三级缓存拿到了 A 的早期引用如果此时 A 还没被AnnotationAwareAspectJAutoProxyCreator处理B 拿到的就是原始 A就算getEarlyBeanReference触发了 AOP 代理生成但也可能只生成了部分切面组合。等 A 完整创建完毕后Spring 又为 A 生成了最终代理这时候容器里就存在两个“A 的代理”或者“原始对象 代理对象”的混乱局面。导致的现象五花八门事务不生效、缓存注解不生效、方法内this调用绕过代理、BeanNotOfRequiredTypeException等等。这类问题排查起来很费时。我的建议是把循环依赖当作一个设计告警信号而不仅仅是一个启动报错。遇到一次就值得花时间重构掉。6. 面试官爱问的循环依赖细节与速查循环依赖是 Spring 面试的高频考点。面试官不一定真的要在生产环境见你重构设计他们更想确认你对 Spring 容器的理解深度。这里我把最常被追问的几个问题整理成了自问自答。6.1 二级缓存就够为什么非要用三级缓存这个问题几乎是必考。你得先说清楚单纯解决 setter 注入循环依赖二级缓存就够。但 Spring 要兼顾 AOP 代理的早期暴露所以必须引入第三级缓存。第三级缓存存的是ObjectFactory它的存在价值在于延迟Spring 不确定某个 Bean 是否需要 AOP 代理如果一开始就生成代理放进二级缓存对绝大多数不需要代理的 Bean 来说就是浪费如果不生成代理等真正被依赖时又可能拿错对象。三级缓存在“需要时”才通过getEarlyBeanReference触发代理创建完美平衡了这两点。6.2 构造器循环依赖为什么救不了构造器循环依赖和 setter 循环依赖的本质区别在于“暴露时机”。setter 注入的 Bean构造器执行完后就已经有了一个可以被引用的实例对象哪怕属性还没有装配完整二三级缓存里可以保存这个“半成品”。但构造器注入下Spring 必须先拿到完整的构造器参数才能实例化对象——对象都还没造出来缓存里自然什么都没得存循环依赖就彻底无解。所以 Spring 官方宁可推荐构造器注入保证依赖完整性同时也承认构造器注入无法解决循环依赖需要开发者自行规避。6.3 三分钟自查清单我在团队里给同事分享过一个最小的自查流程放在这里你也能直接用情况判断处理方式报错里有BeanCurrentlyInCreationException且是构造器注入构造器循环依赖加Lazy临时拆弹后续重构报错里两个 Bean 是 setter/字段注入且都是单例三级缓存可救检查后续 AOP/异步代理是否受影响报错里涉及prototype作用域三级缓存救不了改设计用ObjectProvider延迟获取启动不报错但Async/Transactional不生效代理被早期引用绕过加Lazy或拆解循环依赖你在实际项目里如果再遇到循环依赖照着这个表走一遍基本几分钟就能定位到方向。最后再分享一个小技巧排查循环依赖时不要只盯启动日志的最后一行。Spring 的报错堆栈里通常会把参与循环的 Bean 名字挨个列出来你直接搜circular或者currently in creation前后的 Bean 名称就能快速锁定是哪两个类在互相引用。这个习惯帮我省下了很多查日志的时间。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门