Spring 依赖注入与循环依赖详解
定位:讲透依赖注入的三种方式与选型、依赖解析规则、作用域、三级缓存解决循环依赖的完整机制与边界
适用版本:Spring Framework 6.x(JDK 17+)
目录
- 一、注入方式
- 二、依赖解析
- 三、作用域
- 四、循环依赖与三级缓存
- 五、特殊注入场景
- 六、总结
- 七、常见高频面试题
一、注入方式
1.1 三种方式对比
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 构造器注入 | 依赖作为构造参数(可配 @Autowired,单构造器可省略) | 依赖不可变(final)、必填性显式、对象创建即完整、便于单测 | 依赖多时参数长(这是信号不是缺陷) |
| 字段注入 | @Autowired private Xxx xxx; | 最简洁 | 无法 final、必须依赖容器才能构造、依赖数量被掩盖 |
| Setter 注入 | @Autowired public void setXxx(...) | 可选依赖、可后期重设 | 对象可能处于不完整状态 |
1.2 官方推荐构造器注入的四个理由
- 不可变性:final 字段,注入后不可篡改;
- 完整性保证:对象构造完成即依赖齐全,不存在"半初始化"状态;
- 可测试性:单测直接
new Service(mockDao),不需要容器; - 依赖报警:构造器参数超过五六个,说明类职责过重——字段注入把这个信号藏起来了。
循环依赖视角:构造器注入的循环依赖无法被三级缓存解决(对象都没实例化就互相要对方),启动直接报错——这反而是优点,把设计问题暴露在启动期而非运行期。
二、依赖解析
2.1 按类型与按名称
@Autowired(Spring):默认按类型 → 同类型多个候选? ① @Qualifier("name") 指定 ② @Primary 标记优先候选 ③ 按字段名匹配(兜底) → 仍无法确定:启动报错 @Resource(Jakarta/JDK):默认按名称,找不到再按类型2.2 多候选的三个处理手段
| 手段 | 用法 | 场景 |
|---|---|---|
| @Qualifier | @Autowired @Qualifier("mysqlUserDao") | 注入点明确要哪个 |
| @Primary | 实现类上标注 | 全局默认首选 |
| 集合注入 | List<PayChannel> channels | 策略模式:一次拿全部实现自行分发 |
集合注入 + 策略分发是 Spring 生态最优雅的可扩展模式之一:新增实现类即自动进入列表,调用方零改动。
2.3 可选与延迟
可选依赖:@Autowired(required = false) 或 @Nullable 延迟获取:ObjectProvider<Xxx>(注入 Provider,用时 getObject()) 配置值: @Value("${app.name}") / @Value("#{systemProperties['user.home']}")ObjectProvider的三个用途:可选依赖的安全获取、延迟到运行期获取、配合stream()遍历全部候选。
三、作用域
| 作用域 | 语义 | 注意 |
|---|---|---|
| singleton(默认) | 容器内一个定义一个实例 | 有状态单例有并发问题,Bean 应保持无状态 |
| prototype | 每次获取新建 | 销毁容器不管(01 篇) |
| request / session | Web 环境,按请求/会话 | 注入单例时实际注入的是作用域代理 |
| 自定义 | 实现Scope注册 | 如"按租户"作用域 |
作用域不匹配问题:单例依赖 prototype,注入只发生一次——单例持有的永远是第一次那个实例。解法见第五节。
四、循环依赖与三级缓存
4.1 问题定义
A 依赖 B,B 依赖 A(setter/字段注入) 若等对方完全就绪才注入 → 死锁,谁都创建不完Spring 的解法:先创建半成品(实例化但不注入),提前暴露引用,让对方先拿到引用完成创建,再回头补齐自己。三级缓存就是支撑这个过程的存储结构。
4.2 三级缓存结构
| 缓存 | 名称 | 存放 |
|---|---|---|
| 一级 | singletonObjects | 完成品:初始化完毕的 Bean |
| 二级 | earlySingletonObjects | 半成品:已实例化、提前暴露的对象 |
| 三级 | singletonFactories | 对象工厂:ObjectFactory,可生成半成品(必要时生成代理) |
4.3 解决流程(A↔B,字段注入)
① 创建 A:实例化(构造器)→ 把 A 的 ObjectFactory 放入【三级缓存】 ② A 填充属性:发现需要 B → 去创建 B ③ 创建 B:实例化 → B 的工厂入三级 → B 填充属性发现需要 A ④ B 获取 A:从一级二级都没有 → 三级缓存取出工厂 → 工厂生成 A 的早期引用(若 A 需 AOP 则此处生成代理) → 放入【二级缓存】,删除三级中 A 的工厂 ⑤ B 拿到 A 的引用 → B 完成初始化 → 放入一级缓存 ⑥ 回到 A:注入 B → A 完成初始化 → 放入一级缓存,二级清理4.4 为什么需要三级而不是两级
关键在于代理的时机。AOP 代理正常在生命周期末尾(初始化后)生成;但循环依赖要求提前暴露引用,若 A 需要代理,提前暴露的就必须是代理而不是原始对象。
- 三级缓存的工厂延迟决定:没人循环引用 A 时,代理按正常时机在末尾生成;一旦 B 提前取 A,工厂此时生成代理放入二级;
- 若只有两级缓存(提前暴露固定为原始对象),要么破坏代理时机,要么所有 Bean 都提前生成代理(破坏设计)。
一句话:三级缓存用"工厂"把"是否/何时生成代理"的决策延迟到真正需要时。
4.5 无法解决的情况
| 场景 | 原因 |
|---|---|
| 构造器注入成环 | 实例化阶段就要对方,无"半成品"可提前暴露 |
| prototype 成环 | 不缓存、不管生命周期,直接抛异常 |
| 异步/后置处理中新增的依赖成环 | 创建时机脱离主流程(@Async 的著名坑:代理生成时机与提前暴露冲突) |
实践态度:循环依赖多数是设计气味(职责纠缠),能重构就重构;框架兜底不等于鼓励依赖成环。
五、特殊注入场景
5.1 单例注入 prototype
@ComponentpublicclassOrderService{// 错误示范:注入只发生一次,永远是同一个 PriceCalculator@AutowiredprivatePriceCalculatorcalc;// prototype// 正确:每次用时现取@AutowiredprivateObjectProvider<PriceCalculator>calcProvider;publicvoiduse(){calcProvider.getObject().run();}// 或用 @Lookup(容器生成覆盖方法的子类)@LookupprotectedabstractPriceCalculatornewCalc();}5.2 @Async 与循环依赖的经典冲突
@Async 的代理在 BeanPostProcessor 中生成 若该 Bean 已被提前暴露(二级缓存存的是原始对象) → 最终代理与已暴露引用不一致 → 启动报循环依赖错误解法:打破循环(重构/@Lazy)、或将 @Async 职责拆出被循环引用的 Bean。
5.3 @Lazy 的两个用途
- 启动期延迟创建(重资源 Bean 用到再建);
- 注入点加
@Lazy注入的是代理,打破启动期循环(首次调用才解析真实对象)。
六、总结
- 注入方式:构造器注入是默认推荐(不可变、完整、可测、暴露依赖过多);字段注入掩盖问题;构造器循环依赖启动即报错,是设计问题的早期暴露。
- 解析规则:@Autowired 按类型(多候选用 @Qualifier/@Primary/集合注入),@Resource 按名称;可选依赖用 required=false/ObjectProvider。
- 作用域:默认无状态单例;单例注入 prototype 只注入一次,用 ObjectProvider/@Lookup 每次现取。
- 三级缓存:一级成品、二级半成品、三级工厂;流程是"实例化→提前暴露工厂→对方取用→补齐成品";三级的意义是延迟代理生成决策。
- 边界与态度:构造器环/prototype 环/@Async 环无法兜底;循环依赖多为设计气味,优先重构。
七、常见高频面试题
1. 构造器注入、字段注入、Setter 注入怎么选?
要点:推荐构造器注入——依赖可 final 不可变、对象创建即完整、单测可直接 new 传入 mock、依赖过多时参数列表长是职责过重的信号。字段注入写法最简但无法 final、脱离容器无法构造、掩盖依赖数量,易养出上帝类。Setter 适合可选依赖或后期重配。官方明确推荐构造器注入。
2. @Autowired 和 @Resource 的区别?
要点:来源与解析规则不同。@Autowired 是 Spring 注解,默认按类型注入,同类型多候选时配合 @Qualifier 或 @Primary,支持 required=false;@Resource 是 Jakarta(原 JSR-250)规范注解,默认按名称查找,找不到再按类型。需要可移植性(不绑定 Spring)用 @Resource;需要 Spring 特性(@Qualifier、集合注入、构造器注入)用 @Autowired。
3. 同一个接口有多个实现,注入时如何区分?
要点:三种手段。@Qualifier(“beanName”) 在注入点指定;@Primary 在实现类标注全局优先候选;集合注入(List/Map<接口>)一次拿到全部实现自行分发——这是策略模式的优雅实现,新增实现自动入列,调用方零改动。都不满足时启动报错(NoUniqueBeanDefinitionException),是配置问题的显性暴露。
4. Spring 如何解决循环依赖?详细说说三级缓存。
要点:仅支持单例的字段/Setter 注入循环。三级缓存:一级存成品、二级存提前暴露的半成品、三级存对象工厂。流程:A 实例化后工厂入三级;A 注入 B 时先创建 B;B 注入 A 时从三级取工厂生成 A 的早期引用(如需代理则此时生成代理)放入二级;B 完成后入一级;A 补齐注入后入一级并清理二级。核心思想:先给"半成品引用"打破等待死锁。
5. 为什么需要三级缓存,两级不行吗?
要点:关键在 AOP 代理的生成时机。代理正常在初始化后生成,但循环依赖要求提前暴露引用;若 A 需要代理,提前暴露的必须是代理。三级缓存的 ObjectFactory 把"是否生成代理"延迟到真正被循环引用时:无循环则代理按正常时机生成,有循环则工厂提前生成代理放入二级。若只有两级(提前暴露固定为原始对象),只能让所有 Bean 都提前生成代理,破坏 AOP 设计。
6. 哪些循环依赖 Spring 无法解决?为什么?
要点:① 构造器注入成环——实例化阶段就需要对方,连半成品都创建不出来,启动直接报错;② prototype 成环——prototype 不缓存、无生命周期管理,无法提前暴露;③ @Async 等场景——代理生成时机与提前暴露的引用冲突。这些限制反过来说明:循环依赖多数是设计问题,框架兜底有限,优先通过重构(抽接口、事件、@Lazy)消除。
7. @Autowired 注入的集合(List<接口>)是怎么工作的?
要点:注入点声明为集合类型时,容器把该类型的全部 Bean 收集注入(可配合 @Order/Ordered 排序)。这是策略模式/插件机制的标准做法:定义策略接口,各实现注册为 Bean,调用方注入 List 自行遍历或按条件分发;新增策略实现只需加一个 @Component,调用方无需改动,符合开闭原则。
8. 单例 Bean 依赖 prototype Bean 会有什么问题?怎么解?
要点:注入只在单例创建时发生一次,之后单例持有的永远是第一次注入的 prototype 实例,"每次都是新的"语义失效。解法:注入 ObjectProvider,每次使用时 getObject() 现取;或用 @Lookup 注解方法让容器生成返回新实例的子类;或改造设计(如把 prototype 职责改为显式工厂)。
9. @Lazy 有哪些用途?
要点:两个。① 延迟初始化:标注的 Bean 不在启动时创建,首次使用时才实例化,适合重资源对象加快启动;② 打破启动期循环依赖:在注入点加 @Lazy 注入的是代理,真实对象延迟到首次方法调用才解析,从而让启动期互相依赖的双方都能完成创建。注意 @Lazy 不能解决构造器循环的本质设计问题,只是延后触发。
10. 为什么 @Async 的 Bean 容易在循环依赖时报错?
要点:@Async 代理由 BeanPostProcessor 在初始化后生成;若该 Bean 因循环依赖被提前暴露(二级缓存存的是原始对象),后续生成的代理与已暴露给对方的引用不一致,Spring 检测到此冲突直接抛循环依赖异常。解法:重构消除循环、用 @Lazy 延迟一方、或将 @Async 方法拆到独立 Bean 避免被循环引用。这是"代理生成时机"与"提前暴露机制"冲突的典型案例。