Spring循环依赖:为何有时能解有时报错
作者:搬砖的石头2026.11.07 02:16浏览量:0简介:本文从一次真实报错入手,剖析Spring循环依赖的处理机制:为何去掉@Async注解后同样的依赖关系能正常启动,而加上注解却抛出BeanCurrentlyInCreationException。文章讲解三级缓存、早期引用与最终代理对象不一致的成因,并给出构造器注入、@Lazy、AOP代理场景下的排查与规避方案,帮助读者真正理解“能解决”与“不能解决”的边界。
Spring 循环依赖:为何有时能解有时报错
在使用 Spring 框架开发企业级应用时,循环依赖是一个绕不开的话题。很多开发者都听说过“Spring 能解决循环依赖”,于是当项目启动时抛出下面这类异常,第一反应往往是困惑:
org.springframework.beans.factory.BeanCurrentlyInCreationException:Error creating bean with name 'testService1': Bean with name 'testService1'has been injected into other beans [testService2] in its raw version as partof a circular reference, but has eventually been wrapped. This means thatsaid other beans do not use the final version of the bean. This is often theresult of over-eager type matching - consider using 'getBeanNamesOfType'with the 'allowEagerInit' flag turned off, for example.
异常信息说得很明确:testService1 以“原始版本”被注入到了 testService2 中,但 testService1 最终被包装(wrapped)了,导致 testService2 持有的并不是最终版本。这确实是一个循环依赖引发的错误。可奇怪的是,如果把 TestService1 中某个方法上的 @Async 注解去掉,两个 Bean 依然互相注入对方实例,同样构成循环依赖,项目却能正常启动。
本文就来拆解这个看似矛盾的现象:Spring 到底能解决哪些循环依赖,又在什么情况下会“无能为力”。
一、先厘清概念:什么是循环依赖
循环依赖指两个或多个 Bean 之间形成相互引用的闭环。最常见的形态是 A 依赖 B、B 又依赖 A:
@Servicepublic class TestService1 {@Autowiredprivate TestService2 testService2;public void test1() { }}@Servicepublic class TestService2 {@Autowiredprivate TestService1 testService1;public void test2() { }}
在单例 Bean 的场景下,Spring 的 AbstractAutowireCapableBeanFactory 通过“提前暴露引用”的方式打破这个闭环:创建 testService1 时先实例化对象(此时属性尚未填充),把它的一个早期引用放入缓存,然后再去填充属性;填充过程中发现需要 testService2,于是转去创建 testService2;testService2 填充属性时又需要 testService1,此时可以从缓存中拿到那个早期引用,闭环就此打通,两个 Bean 都能完成初始化。
这个机制依赖三个缓存(俗称“三级缓存”):
- 一级缓存
singletonObjects:存放完全初始化好的单例 Bean; - 二级缓存
earlySingletonObjects:存放已实例化但未完成属性填充的早期引用; - 三级缓存
singletonFactories:存放用于生成早期引用的对象工厂(ObjectFactory)。
关键点在于:从三级缓存中取到的“早期引用”,可能就是最终对外暴露的对象,也可能不是。这正是问题的分水岭。
二、正常路径:为什么去掉 @Async 就能启动
当 TestService1 没有被任何需要代理的增强逻辑包裹时,它的早期引用与最终对象是同一个实例。流程大致如下:
- 创建
testService1实例,将其ObjectFactory注册进三级缓存; - 填充属性,发现依赖
testService2,转去创建testService2; - 创建
testService2实例,填充属性时发现依赖testService1; - 从三级缓存取得
testService1的早期引用(此时就是原始实例本身),放入二级缓存,注入给testService2; testService2初始化完成,注入回testService1;testService1完成初始化,进入一级缓存。
整个过程中,testService2 持有的 testService1 引用与最终放进容器的是同一个对象,因此没有任何一致性问题,启动自然成功。
三、异常路径:@Async 为何让早期引用“失效”
@Async 是一个典型的“需要代理”的注解。方法被它标记后,Spring 需要为这个 Bean 生成一个代理对象,以便把方法调用切换到异步执行线程。代理的生成时机,决定了早期引用与最终对象是否一致。
问题就出在这里:当 testService2 从缓存中拿到 testService1 的早期引用时,testService1 的代理对象可能尚未创建;等到 testService1 完成初始化、真正被包装成代理对象后,容器里最终存放的是代理,而 testService2 手里攥着的却是原始实例。二者不是同一个对象,@Async、事务、缓存等基于代理的增强在 testService2 调用 testService1 时就可能失效。
Spring 检测到这种“早期引用被注入、但最终被包装替换”的情况,就会抛出 BeanCurrentlyInCreationException,并提示这是“过度积极的类型匹配”可能导致的——本质上是在保护开发者,避免出现“注入的 Bean 不是最终版本”的隐蔽 bug。
可以用一段简化的伪代码来理解缓存工厂的判定逻辑:
// 三级缓存中存放的工厂,在被调用时决定返回什么ObjectFactory<?> factory = () -> getEarlyBeanReference(beanName, bean);// 若该 Bean 需要 AOP 代理,getEarlyBeanReference 可能返回代理对象// 若后续初始化又生成了新的代理,两者不一致,则触发校验失败
判断的核心是:早期暴露出去的对象,与最终放入一级缓存的对象是否为同一个引用。一致则通过,不一致则报错。
四、Spring 能解决与不能解决的边界
把上面的分析抽象一下,可以总结出清晰的边界:
通常可以解决的场景:
- 单例作用域下的 setter 注入、字段注入构成的循环依赖;
- 参与循环的 Bean 不需要生成代理,或早期引用与最终对象一致;
- 通过
@Lazy延迟注入,把闭环拆解为按需创建。
通常无法解决或需要额外处理的场景:
- 构造器注入形成的循环依赖——对象尚未实例化完成,无法提前暴露引用,只能通过
@Lazy或重构依赖关系解决; - 原型(prototype)作用域 Bean 的循环依赖——Spring 不缓存原型 Bean,无法提前暴露;
- 需要 AOP 代理且代理生成时机与早期引用不一致的循环依赖,即本文的
@Async场景; - 涉及
BeanPostProcessor在初始化后替换对象的场景。
需要强调的是,“Spring 能解决循环依赖”这句话本身是有前提的,它解决的是特定注入方式、特定作用域、特定代理时机下的闭环问题,而不是无条件兜底。
五、实践中的排查与规避建议
- 优先重构依赖关系。 循环依赖往往是职责划分不清的信号,抽取公共逻辑到第三个 Bean,是成本最低、收益最高的做法。
- 构造器注入场景使用
@Lazy。 在其中一个注入点加上@Lazy,让依赖在首次使用时才解析,可有效打破构造器闭环。 - 关注代理注解。 涉及
@Async、事务、缓存等基于代理的增强时,要意识到早期引用可能不是最终对象,尽量避免让这类 Bean 参与字段注入的循环。 - 阅读异常提示。 类似
allowEagerInit的提示指向的是类型匹配阶段的提前初始化行为,排查时可结合启动日志确认是哪个 Bean 触发了提前实例化。 - 保持依赖方向清晰。 在模块划分上避免双向强依赖,必要时通过事件机制、接口下沉等方式解耦。
六、结语
回到最初的问题:Spring 并非“号称能解决循环依赖”却自相矛盾,而是它的解决能力有明确的适用条件。去掉 @Async 后能启动,是因为早期引用与最终对象一致;加上 @Async 后报错,是因为代理包装让二者分离,Spring 主动拦截了这种不一致。理解三级缓存与代理生成时机的关系,才能真正判断一段循环依赖是“能被化解”还是“需要重构”,也才能在遇到 BeanCurrentlyInCreationException 时快速定位根因,而不是停留在“Spring 不是说能解决吗”的困惑里。

登录后可评论,请前往 登录 或 注册