logo

Spring循环依赖的真相:从报错到原理的层层剖析

作者:起个名字好难2026.11.07 02:27浏览量:0

简介:本文从一个真实项目中的@Async循环依赖问题切入,系统梳理了Spring解决循环依赖的核心机制、三级缓存的工作原理,以及为何在引入AOP或异步代理后会出现BeanCurrentlyInCreationException异常。文章不仅解释了“为什么有的循环依赖能跑通、有的却启动失败”,更深入探讨了构造器注入与字段注入的本质差异、提前暴露原始Bean的潜在风险,并给出了规避循环依赖的工程化建议,帮助读者真正理解Spring底层设计与使用边界。

一、问题的起点:一段看似正常的代码引发的思考

在日常开发中,循环依赖一直是Spring使用者津津乐道的话题。Spring官方宣称容器能够处理大部分Bean之间的循环引用,但“能处理”并不意味着“任何场景都能处理”。项目里的一位同事在代码中写了两个Service类:

  1. @Service
  2. public class TestService1 {
  3. @Autowired
  4. private TestService2 testService2;
  5. @Async
  6. public void test1() {
  7. // 异步业务逻辑
  8. }
  9. }
  10. @Service
  11. public class TestService2 {
  12. @Autowired
  13. private TestService1 testService1;
  14. public void test2() {
  15. // 普通业务逻辑
  16. }
  17. }

两个类相互注入对方的实例,形成典型的循环依赖。启动项目时,却直接抛出了以下异常:

  1. BeanCurrentlyInCreationException: Error creating bean with name 'testService1':
  2. Bean with name 'testService1' has been injected into other beans [testService2]
  3. in its raw version as part of a circular reference, but has eventually been wrapped.

更令人费解的是,当去掉test1()方法上的@Async注解后,同样的两个类再次启动,程序却运行正常。这不禁让人困惑:Spring不是号称解决了循环依赖吗?为什么加了一个注解就“失灵”了?

要想弄清这个问题,必须先弄明白Spring解决循环依赖的底层机制,以及@Async在Bean的生命周期中究竟扮演了怎样的角色。

二、循环依赖的本质:Bean的“先有鸡还是先有蛋”难题

循环依赖,简单说就是A对象中需要注入B对象,而B对象中又需要注入A对象。在Spring容器中,一个Bean的创建流程大致分为三步:

  1. 实例化:通过构造器或工厂方法创建Bean实例,此时对象已存在,但属性尚未填充。
  2. 属性填充:执行依赖注入,将其他Bean的引用赋值给当前Bean的字段或属性。
  3. 初始化:执行BeanPostProcessor的前置和后置处理方法,包括@PostConstruct、InitializingBean、AOP代理生成等。

这三步并非原子操作。如果A在属性填充阶段需要B,而B在属性填充阶段又需要A,就会形成互相等待的局面。如果没有特殊机制,容器将陷入死锁——A等B完成,B等A完成,最终两个Bean都无法创建成功。

三、三级缓存:Spring破解循环依赖的核心武器

Spring容器在创建Bean的过程中,提前暴露了一个“半成品”对象,使得循环依赖得以化解。这一机制依赖于三个缓存容器(即“三级缓存”):

缓存级别 存储内容 作用
一级缓存(singletonObjects) 成品Bean 存放已经完整创建、可供外界使用的单例对象
二级缓存(earlySingletonObjects) 提前暴露的原始Bean 存放实例化完成但尚未执行属性填充和初始化的Bean
三级缓存(singletonFactories) 对象工厂 存放可以生成该Bean的工厂,通常用于生成代理对象

具体流程以A和B互相依赖为例:

  1. 容器开始创建A,先实例化A对象,并将其封装为一个ObjectFactory存入三级缓存。
  2. 继续填充A的属性,发现需要B,于是向容器索取B。
  3. 容器开始创建B,实例化B对象后也将其ObjectFactory存入三级缓存。
  4. 填充B的属性时发现需要A,容器从三级缓存中取出A的工厂,通过该工厂拿到一个提前暴露的A对象的引用(可能是原始对象,也可能是代理对象),并将其放入二级缓存。
  5. B拿到A的引用后完成属性填充,随后完成B的初始化,最终将B放入一级缓存。
  6. 回到A的创建流程,此时A已经能够从一级缓存中拿到B的成品引用,完成自己的属性填充和初始化,最终也进入一级缓存。

整个过程的核心在于:三级缓存中提前暴露了尚未初始化完整的Bean,使得对方能够先拿到引用,而不用等待对方完全创建完毕。这也解释了为什么字段注入能够解决循环依赖——因为字段注入发生在属性填充阶段,此时Bean已经完成实例化,可以提前暴露。

四、为什么@Async会打破循环依赖的“好局”?

回到开头的场景,加上@Async注解后,Spring容器在处理Bean时会发生一个重要变化:该Bean需要被代理。

@Async的本质,是Spring容器在对Bean进行初始化后处理时,通过BeanPostProcessor机制将目标Bean包装为代理对象,使方法调用能够被拦截并提交到线程池异步执行。对于当前场景下的TestService1,Spring在完成其初始化后,会生成一个代理对象替换原始的Bean。

此时再看循环依赖的处理流程,问题就出在代理对象的生成时机上:

  • 如果A在注入B时,恰好B也在依赖A,那么B从三级缓存中获得的是A的原始对象(或者说是尚未被代理包装的对象)。
  • 等到A完成初始化后,Spring又为A生成了代理对象,并最终将代理对象放入一级缓存。
  • 但此时B中持有的仍然是那个提前暴露的、未被包装的原始A对象。

这意味着B引用的对象与容器最终保存的对象不一致——B拿到的不是那个包含了异步能力的最终版本。Spring判断这种情况会引发不可预知的行为,因此直接抛出异常,阻止应用继续启动。

换句话说,Spring解决循环依赖的前提是:提前暴露的对象最终与成品对象是同一个引用。一旦追求代理,提前暴露的原始对象与最终代理对象不再一致,循环依赖的解决机制就失效了。

五、为什么去掉@Async就能正常运行?

当去掉@Async注解后,TestService1不再需要被代理,容器在整个创建过程中只有这一个对象,不存在原始对象与代理对象的区分。提前暴露的引用就是最终放入一级缓存的引用,引用一致性得到保证,循环依赖自然可以顺利解决。

这也侧面反映出一个重要结论:Spring的循环依赖解决方案本质上只适用于不需要改变Bean原始引用的场景。一旦Bean被AOP代理、CGLIB增强或动态代理包装,提前暴露的对象就沦为了“过期的中间品”,Spring宁可报错也不愿意让这种不一致的状态蔓延到业务代码中。

实际开发中,除了@Async,以下场景也可能导致类似问题:

  • 使用@Transactional启用事务代理
  • 使用AOP切面进行日志、权限控制
  • 方法级别的缓存注解(如@Cacheable)配合动态代理
  • 任何自定义的BeanPostProcessor中对Bean进行包装替换的行为

六、构造器注入为什么解决不了循环依赖?

细心的读者可能会发现,上面的讨论都基于字段注入。如果使用构造器注入:

  1. @Service
  2. public class TestService1 {
  3. private final TestService2 testService2;
  4. public TestService1(TestService2 testService2) {
  5. this.testService2 = testService2;
  6. }
  7. }

Spring在实例化TestService1时,必须先获取构造器参数TestService2,而TestService2又在构造器中需要TestService1。此时两个Bean都还没有完成实例化,三级缓存中根本没有可提前暴露的对象,因此循环依赖完全无法被容器解决,只能启动报错。

这并不算Spring的缺陷,而是一种基于安全性的设计选择——构造器注入强调依赖的完整性,不允许出现半成品对象,因此也天然规避了循环依赖的隐患。

七、工程实践中的规避策略与设计思考

了解原理之后,再来看如何从工程角度规避循环依赖的困境:

1. 优先采用构造器注入,从设计层面禁止循环依赖
构造器注入不仅有利于依赖清晰化、便于单元测试,也会在编译期或启动期就暴露出循环依赖问题,避免问题被隐藏到运行时。

2. 重构业务逻辑,消除依赖环
如果A与B相互依赖,往往是设计上的“坏味道”。可以考虑将公共部分提取为独立的C类,A和B只依赖C,从而打散循环结构。或者利用@Lazy注解在注入端延迟代理依赖对象,中断容器的即时循环解析。

3. 明确@Async等代理注解的使用边界
在使用@Async、@Transactional等需要代理的注解时,避免让该Bean成为循环依赖中的一环。必要时可将异步逻辑拆分到单独的Service中,确保被代理的Bean不参与循环引用。

4. 理解Spring的“安全机制”而非依赖“黑科技”
Spring的二级缓存机制是为了兼容历史遗留代码而保留的能力,并不是鼓励开发者主动制造循环依赖。在现代Spring Boot应用中,应当将循环依赖视为设计缺陷而不是可依赖的功能特性。

八、结语

Spring解决循环依赖的关键在于三级缓存的提前暴露机制,但这种机制有其严格的适用边界:仅当提前暴露的对象与最终成品是同一个引用时才有效。任何需要对Bean进行代理包装的场景都会打破这一前提,从而引发启动异常。理解这一原理,不仅能帮助开发者快速定位类似“加上@Async就报错”的问题,也能促使我们在日常开发中写出更清晰、更健壮的代码——真正的工程能力,不是依赖于框架的“捷径”,而是理解框架的边界,在正确的场景中做出正确的设计决策。

发表评论

活动