logo

Spring循环依赖:为何有时能解有时报错

作者:搬砖的石头2026.11.07 02:16浏览量:0

简介:本文从一次真实报错入手,剖析Spring循环依赖的处理机制:为何去掉@Async注解后同样的依赖关系能正常启动,而加上注解却抛出BeanCurrentlyInCreationException。文章讲解三级缓存、早期引用与最终代理对象不一致的成因,并给出构造器注入、@Lazy、AOP代理场景下的排查与规避方案,帮助读者真正理解“能解决”与“不能解决”的边界。

Spring 循环依赖:为何有时能解有时报错

在使用 Spring 框架开发企业级应用时,循环依赖是一个绕不开的话题。很多开发者都听说过“Spring 能解决循环依赖”,于是当项目启动时抛出下面这类异常,第一反应往往是困惑:

  1. org.springframework.beans.factory.BeanCurrentlyInCreationException:
  2. Error creating bean with name 'testService1': Bean with name 'testService1'
  3. has been injected into other beans [testService2] in its raw version as part
  4. of a circular reference, but has eventually been wrapped. This means that
  5. said other beans do not use the final version of the bean. This is often the
  6. result of over-eager type matching - consider using 'getBeanNamesOfType'
  7. with the 'allowEagerInit' flag turned off, for example.

异常信息说得很明确:testService1 以“原始版本”被注入到了 testService2 中,但 testService1 最终被包装(wrapped)了,导致 testService2 持有的并不是最终版本。这确实是一个循环依赖引发的错误。可奇怪的是,如果把 TestService1 中某个方法上的 @Async 注解去掉,两个 Bean 依然互相注入对方实例,同样构成循环依赖,项目却能正常启动。

本文就来拆解这个看似矛盾的现象:Spring 到底能解决哪些循环依赖,又在什么情况下会“无能为力”。

一、先厘清概念:什么是循环依赖

循环依赖指两个或多个 Bean 之间形成相互引用的闭环。最常见的形态是 A 依赖 B、B 又依赖 A:

  1. @Service
  2. public class TestService1 {
  3. @Autowired
  4. private TestService2 testService2;
  5. public void test1() { }
  6. }
  7. @Service
  8. public class TestService2 {
  9. @Autowired
  10. private TestService1 testService1;
  11. public void test2() { }
  12. }

在单例 Bean 的场景下,Spring 的 AbstractAutowireCapableBeanFactory 通过“提前暴露引用”的方式打破这个闭环:创建 testService1 时先实例化对象(此时属性尚未填充),把它的一个早期引用放入缓存,然后再去填充属性;填充过程中发现需要 testService2,于是转去创建 testService2;testService2 填充属性时又需要 testService1,此时可以从缓存中拿到那个早期引用,闭环就此打通,两个 Bean 都能完成初始化。

这个机制依赖三个缓存(俗称“三级缓存”):

  • 一级缓存 singletonObjects:存放完全初始化好的单例 Bean;
  • 二级缓存 earlySingletonObjects:存放已实例化但未完成属性填充的早期引用;
  • 三级缓存 singletonFactories:存放用于生成早期引用的对象工厂(ObjectFactory)。

关键点在于:从三级缓存中取到的“早期引用”,可能就是最终对外暴露的对象,也可能不是。这正是问题的分水岭。

二、正常路径:为什么去掉 @Async 就能启动

当 TestService1 没有被任何需要代理的增强逻辑包裹时,它的早期引用与最终对象是同一个实例。流程大致如下:

  1. 创建 testService1 实例,将其 ObjectFactory 注册进三级缓存;
  2. 填充属性,发现依赖 testService2,转去创建 testService2;
  3. 创建 testService2 实例,填充属性时发现依赖 testService1;
  4. 从三级缓存取得 testService1 的早期引用(此时就是原始实例本身),放入二级缓存,注入给 testService2;
  5. testService2 初始化完成,注入回 testService1;
  6. testService1 完成初始化,进入一级缓存。

整个过程中,testService2 持有的 testService1 引用与最终放进容器的是同一个对象,因此没有任何一致性问题,启动自然成功。

三、异常路径:@Async 为何让早期引用“失效”

@Async 是一个典型的“需要代理”的注解。方法被它标记后,Spring 需要为这个 Bean 生成一个代理对象,以便把方法调用切换到异步执行线程。代理的生成时机,决定了早期引用与最终对象是否一致。

问题就出在这里:当 testService2 从缓存中拿到 testService1 的早期引用时,testService1 的代理对象可能尚未创建;等到 testService1 完成初始化、真正被包装成代理对象后,容器里最终存放的是代理,而 testService2 手里攥着的却是原始实例。二者不是同一个对象,@Async、事务、缓存等基于代理的增强在 testService2 调用 testService1 时就可能失效。

Spring 检测到这种“早期引用被注入、但最终被包装替换”的情况,就会抛出 BeanCurrentlyInCreationException,并提示这是“过度积极的类型匹配”可能导致的——本质上是在保护开发者,避免出现“注入的 Bean 不是最终版本”的隐蔽 bug。

可以用一段简化的伪代码来理解缓存工厂的判定逻辑:

  1. // 三级缓存中存放的工厂,在被调用时决定返回什么
  2. ObjectFactory<?> factory = () -> getEarlyBeanReference(beanName, bean);
  3. // 若该 Bean 需要 AOP 代理,getEarlyBeanReference 可能返回代理对象
  4. // 若后续初始化又生成了新的代理,两者不一致,则触发校验失败

判断的核心是:早期暴露出去的对象,与最终放入一级缓存的对象是否为同一个引用。一致则通过,不一致则报错。

四、Spring 能解决与不能解决的边界

把上面的分析抽象一下,可以总结出清晰的边界:

通常可以解决的场景:

  • 单例作用域下的 setter 注入、字段注入构成的循环依赖;
  • 参与循环的 Bean 不需要生成代理,或早期引用与最终对象一致;
  • 通过 @Lazy 延迟注入,把闭环拆解为按需创建。

通常无法解决或需要额外处理的场景:

  • 构造器注入形成的循环依赖——对象尚未实例化完成,无法提前暴露引用,只能通过 @Lazy 或重构依赖关系解决;
  • 原型(prototype)作用域 Bean 的循环依赖——Spring 不缓存原型 Bean,无法提前暴露;
  • 需要 AOP 代理且代理生成时机与早期引用不一致的循环依赖,即本文的 @Async 场景;
  • 涉及 BeanPostProcessor 在初始化后替换对象的场景。

需要强调的是,“Spring 能解决循环依赖”这句话本身是有前提的,它解决的是特定注入方式、特定作用域、特定代理时机下的闭环问题,而不是无条件兜底。

五、实践中的排查与规避建议

  1. 优先重构依赖关系。 循环依赖往往是职责划分不清的信号,抽取公共逻辑到第三个 Bean,是成本最低、收益最高的做法。
  2. 构造器注入场景使用 @Lazy。 在其中一个注入点加上 @Lazy,让依赖在首次使用时才解析,可有效打破构造器闭环。
  3. 关注代理注解。 涉及 @Async、事务、缓存等基于代理的增强时,要意识到早期引用可能不是最终对象,尽量避免让这类 Bean 参与字段注入的循环。
  4. 阅读异常提示。 类似 allowEagerInit 的提示指向的是类型匹配阶段的提前初始化行为,排查时可结合启动日志确认是哪个 Bean 触发了提前实例化。
  5. 保持依赖方向清晰。 在模块划分上避免双向强依赖,必要时通过事件机制、接口下沉等方式解耦。

六、结语

回到最初的问题:Spring 并非“号称能解决循环依赖”却自相矛盾,而是它的解决能力有明确的适用条件。去掉 @Async 后能启动,是因为早期引用与最终对象一致;加上 @Async 后报错,是因为代理包装让二者分离,Spring 主动拦截了这种不一致。理解三级缓存与代理生成时机的关系,才能真正判断一段循环依赖是“能被化解”还是“需要重构”,也才能在遇到 BeanCurrentlyInCreationException 时快速定位根因,而不是停留在“Spring 不是说能解决吗”的困惑里。

发表评论

活动