Bean 的循环依赖问题及三级缓存的解决机制
1. Bean 循环依赖是什么?
- A 类的属性依赖 B 类(
A.has-a B
);B 类的属性依赖 A 类(B.has-a A
) - Spring 默认只解决 “单例 Bean” 的循环依赖,而原型 Bean(
scope="prototype"
)的循环依赖无法解决。
👇 俩问题,单例Bean的循环依赖是怎么解决的?为什么原型Bean的循环依赖无法解决?
2. 单例Bean的循环依赖怎么解决?
通过三级缓存(三个 Map)实现单例 Bean 的循环依赖
核心思路是:提前暴露 “未完全初始化” 的 Bean 实例,让依赖方先引用,避免死等
- 一级缓存:
singletonObjects
: 完全初始化完成 成品 Bean 缓存,是最终对外提供的 “可用 Bean”,其他 Bean 获取依赖时优先从这里查 - 二级缓存:earlySingletonObjects : 未完全初始化但已实例化 从三级缓存的工厂中获取实例,放入二级缓存
- 三级缓存:singletonFactories : 实例化后(还未注入属性) Spring 会将其包装成工厂对象放入三级缓存
- 解决循环依赖流程(依赖关系:
A → B → A
)- Spring 实例化 A,将 A 的工厂对象放入三级缓存
- A 需要注入 B,检查一/二/三级缓存没有 B,开始创建 B 的实例
- Spring 实例化 B,将 B 的工厂对象放入三级缓存
- B 需要注入 A,检查一/二/三级缓存,三级缓存中有 A,通过工厂获取 A 的早期实例,将 A 的早期实例从三级缓存移到二级缓存
- B 注入 A 的早期实例,完成自身初始化,B 放入一级缓存
- B 已在一级缓存中,A 直接注入 B,完成自身初始化,A 从二级缓存移到一级缓存
👇 这里面提到了 初始化、未完全初始化、实例化、未注入属性 都代表什么意思?
3. 初始化/实例化基本概念
- 实例化:调用类的构造方法,创建一个 “空对象”(仅分配内存,属性未赋值)
- 属性注入:给实例化后的对象设置依赖(如
@Autowired
标注的属性) - 初始化:执行初始化方法(如
@PostConstruct
标注的方法、InitializingBean
接口的afterPropertiesSet()
) - 完全初始化后,Bean 被放入
singletonObjects
,供其他 Bean 使用**(放入一级缓存)**
👇 概念搞懂后,有一个新的问题,判断循环依赖的依据是否是:“B需要注入A时,发现A在三级缓存中”得出的结论?
4. 循环依赖的依据是否是发现实例在三级缓存中?
- Spring 通过一个专门的标记(
singletonsCurrentlyInCreation
集合)来记录 Bean 的创建状态 - Spring 在创建单例 Bean 时,会先将 Bean 的名称(或标识)加入
singletonsCurrentlyInCreation
集合,标记为 “正在创建中” - 当 Bean 完全初始化并放入一级缓存后,再从该集合中移除
- 循环依赖的核心取决于被依赖的 Bean 是否处于 “正在创建中” 且形成了依赖闭环,而非单纯看三级缓存中是否有某个 Bean
👇 三级缓存 和 正在创建中 的关联关系?
5. 三级缓存和正在创建中关联关系
- “正在创建中” 的标记先于 “进入三级缓存”
- 在 Bean 开始实例化前,Spring 就会将其加入
singletonsCurrentlyInCreation
集合,标记为 “正在创建中” - 在 Bean 完成实例化之后,Spring 会将其 “早期实例工厂” 放入三级缓存
- 只有先标记为 “正在创建中”,才会进入后续的实例化和三级缓存暴露流程
- 在 Bean 开始实例化前,Spring 就会将其加入
- 三级缓存中的 Bean,必然处于 “正在创建中”
- “正在创建中” 的 Bean,绝大多数会进入三级缓存,但有例外
- 例外情况:若手动关闭循环依赖支持,此时仍会被标记为 “正在创建中”,但不会进入三级缓存(因为
allowCircularReferences=false
,跳过了暴露工厂的步骤)
👇 到这里,三级缓存解决循环依赖告一段落,但只有单例Bean才能解决循环依赖,如何区分单例Bean和原型Bean?
6. 如何区分单例Bean和原型Bean?
单例 Bean(Singleton)与原型 Bean(Prototype)的核心区别
维度 单例 Bean(默认) 原型 Bean( scope="prototype"
)实例数量 整个 Spring 容器中只创建一个实例,全局共享。 每次通过 getBean()
获取或被依赖注入时,都会创建新实例。生命周期 与 Spring 容器一致(容器启动时创建,容器关闭时销毁)。 由开发者手动管理(Spring 创建后不再跟踪,不会自动销毁)。 适用场景 无状态 Bean(如 Service、Dao 层,无成员变量或成员变量不可变)。 有状态 Bean(如命令对象、会话对象,需要为不同请求创建独立实例)。 循环依赖处理 支持(通过三级缓存解决)。 不支持(会抛出 BeanCurrentlyInCreationException
)。代码中指定作用域
// 单例Bean(默认,可省略@Scope注解) @Service @Scope("singleton") // 等价于不写 public class UserService { ... } // 原型Bean(每次获取都创建新实例) @Service @Scope("prototype") public class OrderCommand { ... }
验证实例是否为单例
@SpringBootTest public class ScopeTest { @Autowired private ApplicationContext context; @Test public void testScope() { // 单例Bean:两次获取的是同一个实例 UserService service1 = context.getBean(UserService.class); UserService service2 = context.getBean(UserService.class); System.out.println(service1 == service2); // 输出:true // 原型Bean:两次获取的是不同实例 OrderCommand command1 = context.getBean(OrderCommand.class); OrderCommand command2 = context.getBean(OrderCommand.class); System.out.println(command1 == command2); // 输出:false } }
此时也解决了第一个标题中引出的第二个问题:为什么原型Bean的循环依赖无法解决?
- 原型 Bean 的作用域是
prototype
,即每次获取都会创建新实例(getBean()
一次就 new 一个)。 - 当 A(原型)依赖 B(原型),B 又依赖 A 时,会触发 “无限创建新实例” 的循环(A1 依赖 B1,B1 依赖 A2,A2 依赖 B2...),导致 OOM。
- Spring 无法处理这种场景,直接抛出
BeanCurrentlyInCreationException
。
👇 好,新的问题是,日常开发中默认是单例Bean,按照上面说的会通过三级缓存解决循环依赖,但还是会报循环依赖的错,为什么?
7. 三级缓存处理循环依赖范围
两个核心条件:
- 依赖注入方式为字段注入(Field Injection)或 setter 注入 :这两种注入方式发生在 Bean 实例化(调用构造器创建对象)之后,此时 Bean 已完成实例化,可通过三级缓存提前暴露 “早期实例”(未初始化完成的对象)供依赖方引用。
- 循环依赖的 Bean 均为单例:只有单例 Bean 会进入 Spring 的三级缓存机制,原型 Bean 或其他作用域的 Bean 无法通过缓存解决循环依赖。
单例 Bean 仍报循环依赖的常见原因:
- 循环依赖中使用了构造器注入
- 循环依赖涉及非单例 Bean(如原型、request 等作用域)
- 循环依赖中使用了final 字段注入 :
final
字段的注入发生在构造器执行阶段 - 循环依赖涉及代理对象(如 AOP 增强)且注入时机异常
- 当循环依赖的 Bean 被 AOP 增强(如
@Transactional
、@Async
等)时,Spring 会先创建目标对象的早期实例,再生成代理对象 - 此时 Bean 的实际类型是 “代理对象” 而非原始对象
- 如果代理对象的创建时机与依赖注入顺序冲突,可能导致循环依赖无法处理
- 循环依赖中,当需要注入A对象时,A的代理对象可能尚未生成(此时只有原始对象的早期实例在三级缓存中),导致注入的是 “原始对象” 而非 “代理对象”
- 当循环依赖的 Bean 被 AOP 增强(如
- 未正确使用
@Lazy
注解处理构造器循环依赖
👇 既然说到了注入方式,那再回顾一下所有的注入方式以及和三级缓存的关系。
8. 注入方式和三级缓存
常见注入方式及循环依赖解决能力对比
注入方式 是否能通过三级缓存解决循环依赖 核心原因 构造器注入 不能 注入发生在实例化阶段(调用构造方法时),对象未实例化,无法暴露早期实例到三级缓存。 @Autowired 能 注入发生在实例化之后(属性注入阶段),对象已实例化并放入三级缓存,可暴露早期实例。 @Resource 不能(默认) 按名字注入时不经过 getBean()
,直接查找成熟 Bean,不触发三级缓存。能解决循环依赖的注入方式:实例化之后注入 在对象实例化后(已放入三级缓存)才注入依赖
不能解决循环依赖的注入方式:构造器注入 构造器调用时对象尚未创建,依赖的 Bean 也无法获取当前 Bean 的引用
@Resource
默认不兼容三级缓存- 注入逻辑由 JDK 自带的
CommonAnnotationBeanPostProcessor
处理(早期版本),或 Spring 扩展的处理器处理 - 其注入时默认不通过
getBean()
流程,而是直接从容器的beanDefinitionMap
中查找 Bean 实例 - 若未找到成熟 Bean(一级缓存中无),则不会触发三级缓存的查找,而是直接抛出
BeanCurrentlyInCreationException
(循环依赖异常)
- 注入逻辑由 JDK 自带的
👇 好,那现在根据注入方式看来,@Autowired能解决循环依赖,构造器注入不能,但是Spring官方却推荐使用构造器注入,这是为什么?
9. 为什么Spring官方推荐使用构造器注入?
因为构造器注入在多数场景比 @Autowired 下更优
- 确保依赖不可变(Immutability)
- 构造器注入的依赖可以用
final
修饰,一旦注入后就无法被修改,避免了运行时被意外篡改的风险,增强了对象的稳定性 - 而
@Autowired
字段注入无法使用final
修饰依赖(因为final
字段必须在构造器中初始化)
- 构造器注入的依赖可以用
- 强制依赖在对象创建时初始化,避免空指针
- 构造器是对象创建的必经步骤,依赖必须在构造器调用时传入,否则对象无法实例化。这就强制保证了依赖在对象使用前一定被初始化
@Autowired
字段注入是 “对象先创建,后注入依赖”:如果注入过程失败(比如依赖不存在),对象已经被实例化,但依赖为null
- 依赖关系更明确,可读性更强
- 无侵入性,脱离 Spring 也能正常工作
- 构造器注入的 Bean 在测试时更容易通过构造方法手动传入依赖
- 提前暴露循环依赖问题
- 循环依赖本质上是代码设计的隐患(可能意味着职责耦合)
- 构造器注入在循环依赖时会直接报错(启动时就抛出
BeanCurrentlyInCreationException
),强制开发者提前发现并解决循环依赖问题
👇 为什么@Lazy能解决循环依赖呢?
10. @Lazy解决循环依赖
@Lazy
的核心是 “延迟初始化依赖对象”- 不直接创建其原始对象或代理对象,而是先创建一个临时代理(占位符)
- 直到第一次实际使用某对象时,才会真正初始化他的原始对象或代理对象
- 循环依赖是双向的,只需在其中一方的依赖注入上添加
@Lazy
即可打破循环