Spring循环依赖的真相:从报错到原理的层层剖析
作者:起个名字好难2026.11.07 02:27浏览量:0简介:本文从一个真实项目中的@Async循环依赖问题切入,系统梳理了Spring解决循环依赖的核心机制、三级缓存的工作原理,以及为何在引入AOP或异步代理后会出现BeanCurrentlyInCreationException异常。文章不仅解释了“为什么有的循环依赖能跑通、有的却启动失败”,更深入探讨了构造器注入与字段注入的本质差异、提前暴露原始Bean的潜在风险,并给出了规避循环依赖的工程化建议,帮助读者真正理解Spring底层设计与使用边界。
一、问题的起点:一段看似正常的代码引发的思考
在日常开发中,循环依赖一直是Spring使用者津津乐道的话题。Spring官方宣称容器能够处理大部分Bean之间的循环引用,但“能处理”并不意味着“任何场景都能处理”。项目里的一位同事在代码中写了两个Service类:
@Servicepublic class TestService1 {@Autowiredprivate TestService2 testService2;@Asyncpublic void test1() {// 异步业务逻辑}}@Servicepublic class TestService2 {@Autowiredprivate TestService1 testService1;public void test2() {// 普通业务逻辑}}
两个类相互注入对方的实例,形成典型的循环依赖。启动项目时,却直接抛出了以下异常:
BeanCurrentlyInCreationException: Error creating bean with name 'testService1':Bean with name 'testService1' has been injected into other beans [testService2]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的创建流程大致分为三步:
- 实例化:通过构造器或工厂方法创建Bean实例,此时对象已存在,但属性尚未填充。
- 属性填充:执行依赖注入,将其他Bean的引用赋值给当前Bean的字段或属性。
- 初始化:执行
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互相依赖为例:
- 容器开始创建A,先实例化A对象,并将其封装为一个
ObjectFactory存入三级缓存。 - 继续填充A的属性,发现需要B,于是向容器索取B。
- 容器开始创建B,实例化B对象后也将其
ObjectFactory存入三级缓存。 - 填充B的属性时发现需要A,容器从三级缓存中取出A的工厂,通过该工厂拿到一个提前暴露的A对象的引用(可能是原始对象,也可能是代理对象),并将其放入二级缓存。
- B拿到A的引用后完成属性填充,随后完成B的初始化,最终将B放入一级缓存。
- 回到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进行包装替换的行为
六、构造器注入为什么解决不了循环依赖?
细心的读者可能会发现,上面的讨论都基于字段注入。如果使用构造器注入:
@Servicepublic class TestService1 {private final TestService2 testService2;public TestService1(TestService2 testService2) {this.testService2 = testService2;}}
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就报错”的问题,也能促使我们在日常开发中写出更清晰、更健壮的代码——真正的工程能力,不是依赖于框架的“捷径”,而是理解框架的边界,在正确的场景中做出正确的设计决策。

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