Spring 循环依赖
Spring 循环依赖指 Bean 之间的依赖形成了环,例如 A 依赖 B,B 又依赖 A。本文先比较构造器注入与属性注入,再说明早期引用和三级缓存怎样处理部分单例循环。最后介绍作用域限制与职责拆分。Spring Boot 2.6 起默认禁止循环引用,本文的处理过程以显式允许循环引用为前提。
1. 面试核心
先记住这几条,再读正文- 构造器循环。 直接构造器循环中,双方都需要先取得对方实例,因此容器没有可提前交付的对象。
- 早期引用。 允许循环引用时,容器可提前交付尚未初始化完成的引用,处理部分 singleton 属性注入循环。卡 · 构造器注入的循环
- 三级缓存职责。 三级缓存分别保存完整单例、已生成的早期引用和早期引用工厂,工厂可按需生成早期代理。
- Boot 默认配置。 Boot 2.6 起
spring.main.allow-circular-references默认为false。 - 作用域与注入次数。 singleton 直接注入 prototype 时,只解析一次该依赖。卡 · 作用域
2. 构造器注入的循环
构造器要求依赖先准备好,容器才能创建当前对象。下面两个服务互相要求对方先存在,因此形成直接构造器循环:
@Service
class OrderService {
OrderService(PaymentService paymentService) {}
}
@Service
class PaymentService {
PaymentService(OrderService orderService) {}
}
创建 OrderService 前需要先得到 PaymentService,后者又要求先得到 OrderService。双方都缺少可用实例,所以容器会报告 BeanCurrentlyInCreationException,表示遇到了正在创建中的依赖。
注入点的 @Lazy 可先注入延迟代理,等调用时再取得目标。ObjectProvider 则让代码显式决定何时向容器获取依赖。两者都只是推迟获取;如果在构造或初始化阶段立即访问目标,循环仍会出现。服务职责互相依赖的问题还需单独拆分。
3. 属性注入的循环
属性注入允许容器先创建对象,再填充字段或调用 Setter,因此有机会提前交付对象引用。以下假设 A、B 都是 singleton,也就是容器内共享的单例,并且已允许循环引用:
实例化 A
→ 登记 A 的早期引用工厂
→ 为 A 注入 B,开始创建 B
→ 为 B 注入 A,取得 A 的早期引用
→ 完成 B 初始化
→ 将 B 注入 A
→ 完成 A 初始化
这里的早期引用指向已经实例化、但还未完成初始化的 A。B 取得它之后才能完成注入,容器也就能继续创建 A。代价是 B 一度持有尚未可用的对象:如果初始化 B 时调用 A,就会访问尚未填充的属性。因此,这条路径更适合作为兼容机制,业务设计仍应优先拆开依赖环。
4. 三级缓存
容器需要区分已经创建完成的单例,以及可供循环依赖提前使用的引用。Spring Framework 6.2.12 的单例注册表用以下三个结构保存它们:
| 结构 | 内容 |
|---|---|
singletonObjects | 完成创建的单例 |
earlySingletonObjects | 已生成的早期引用 |
singletonFactories | 按需生成早期引用的工厂 |
查询先检查完整单例。对象仍在创建中时,容器再检查已缓存的早期引用。若依赖方需要早期引用而缓存中尚未生成,容器就调用工厂取得它。
这里的工厂是 ObjectFactory,负责按需生成引用。它会调用 getEarlyBeanReference() 扩展点,让后处理器参与创建早期引用。结果随后进入 earlySingletonObjects,对应工厂项从 singletonFactories 移除。
自动代理创建器可在这个扩展点返回代理,最终发布时再复用同一份早期代理。这样,依赖方与容器后续返回的引用就能保持一致。Bean 创建完成后,最终引用进入完整单例缓存,早期缓存项随之清理。
工厂层把早期代理的创建推迟到确有依赖方请求时。简单属性循环可以通过提前交付原始实例完成;涉及代理时,则要协调早期引用与最终包装结果。业务代码应通过容器取得 Bean,缓存字段属于框架实现细节。
5. Spring Boot 的默认行为
底层容器有处理能力,还需要 Boot 允许它使用这条路径。Boot 2.6 起默认 spring.main.allow-circular-references=false,遇到依赖环会启动失败。设为 true 后,容器才会尝试通过早期引用处理循环。
打开开关后,仍需考虑以下四类限制:
- 直接构造器循环缺少可提前暴露的实例。
- prototype 循环没有单例早期缓存协议。
- 跨作用域依赖需满足目标作用域的上下文要求。
- 后处理器若产生不同代理,早期引用与最终引用会冲突。
优先拆分职责,可减少初始化阶段对半成品对象的依赖。
6. 作用域
作用域决定容器何时创建对象,以及哪些调用共享对象。判断依赖是否需要延迟获取时,要先区分以下四种作用域:
- singleton 按容器和 Bean 定义共享实例。
- prototype 在每次向容器获取时创建新实例。
- request 在同一次 Web 请求内共享实例。
- session 在同一 Web 会话内共享实例。
singleton 构造时直接注入 prototype,只会获取当时的那一份。后续调用继续使用相同引用,因为调用方法并没有再次请求容器创建对象。需要每次获取新对象时,可以使用 ObjectProvider:
class ReportService {
private final ObjectProvider<ReportBuilder> builders;
ReportService(ObjectProvider<ReportBuilder> builders) {
this.builders = builders;
}
Report create() {
return builders.getObject().build();
}
}
ReportBuilder 为 prototype 时,每次 create() 都通过 getObject() 取得新实例。容器交付 prototype 后,其资源清理由使用方负责。方法注入或作用域代理也可把获取目标推迟到使用时。
request Bean 可通过作用域代理注入 singleton。作用域代理是在调用时查找当前作用域目标的中间对象,因此能随请求切换实际实例。调用必须发生在有效请求上下文中。异步线程离开该上下文后,解析目标会失败,应改为显式传递需要的请求数据。
7. 循环依赖的职责拆分
拆分循环依赖时,先判断两个服务为何必须相互调用,再调整调用方向。常见的职责拆分方式有以下几种:
- 把共同逻辑提取为第三个组件,让两个服务分别依赖它。
- 由上层应用服务编排流程,单向调用原来的两个服务。
- 状态变更后发布领域事件,由监听者处理后续工作。
- 重新定义接口,让服务返回结果,由调用方决定下一步,或通过明确的回调接口通知结果。
Provider 适合确实需要延迟获取的资源。若只是用它隐藏直接依赖,初始化错误就会被推迟到使用阶段,职责之间的环仍然存在。
8. 常见问题
8.1 循环依赖的支持范围
允许循环引用时,容器可处理部分 singleton 属性循环。直接构造器循环和 prototype 循环会失败。
8.2 早期代理的一致性
早期代理要与最终发布的代理一致,才能让各调用方获得相同的增强行为。若 B 持有原始 A,而容器发布的是事务代理 A,B 的调用就会绕过事务。早期引用工厂给自动代理创建器协调引用的机会,自定义后处理器也需遵守这套协议。
9 · 面试题卡片
9.1 Spring 的三级缓存怎样处理循环依赖,有哪些限制
出现公司:美团
考察重点:解释实例化与属性填充为何可以分开,并说明早期代理怎样保持引用一致。能结合构造器、作用域和 Boot 配置判断处理范围。
相关内容:构造器注入的循环至Spring Boot 的默认行为。
参考回答
允许循环引用时,三级缓存通过提前交付单例引用,完成部分 Setter 或字段注入循环。
容器实例化 A 后登记引用工厂,创建 B、为 B 注入 A 时,再按需取得 A 的早期引用。B 完成初始化后被注入 A,容器最后完成 A 并发布完整单例。
完整单例、已生成的早期引用和引用工厂分别保存在三个缓存中。工厂还能生成早期代理,让最终发布结果复用同一引用,保证各调用方的增强行为一致。
这条路径要求对象可以先实例化,所以直接构造器循环和 prototype 循环仍会失败。Boot 2.6 起默认禁止循环引用,即使显式开启,也应优先用职责提取、上层编排或事件拆开依赖环。
9.2 singleton 注入 prototype 后,每次调用都会创建新对象吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分方法调用与容器获取对象的时机,说明 Provider 怎样触发新建,以及 prototype 由谁清理。
相关内容:作用域。
参考回答
直接注入后,每次方法调用都会使用同一份 prototype 实例,因为依赖只在 singleton 创建时解析一次。
若每次调用都需要新对象,可注入 ObjectProvider,再调用 getObject() 向容器获取。目标为 prototype 时,每次获取才会创建新实例,使用者负责实例的资源清理。