Spring循环依赖:三级缓存到底解决了什么,没解决什么?
一、开篇一问:为什么这道面试题能考倒90%的人?
不是因为难,而是因为大多数人只背了"三级缓存解决循环依赖"这句话,却从没想过------它到底解决了哪种循环依赖?为什么有的循环依赖它偏偏不管?
今天,把这件事一次性说透。
二、先搞清楚:什么才算"循环依赖"?
Spring里的循环依赖,远不止"A依赖B,B依赖A"这一种。按注入方式和作用域,至少有三种:
| 类型 | 表现 | Spring能否解决 |
|---|---|---|
| 构造器注入循环依赖 | A的构造函数要B,B的构造函数要A | ❌ 直接抛异常 |
| 字段/Setter注入循环依赖(单例) | @Autowired private B b; 同理B里有A | ✅ 三级缓存完美解决 |
| 原型Bean循环依赖 | @Scope("prototype") + 字段注入 | ❌ 无法解决 |
一句话结论:三级缓存只解决一种情况------单例Bean + 字段/Setter注入的循环依赖。其余的,一概不管。
典型报错长这样:
vbnet
org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'albumInfoServiceImpl':
Requested bean is currently in creation: Is there an unresolvable circular reference?
看到这个异常,先别慌------这恰恰说明Spring在保护你,它检测到了自己解决不了的死循环。
三、三级缓存的"三级"到底是什么?
在DefaultSingletonBeanRegistry类中,Spring定义了三个Map:
typescript
java
// 一级缓存:成品仓库------完全初始化好的单例Bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
// 二级缓存:半成品暂存区------已实例化,但未完成属性注入和初始化
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);
// 三级缓存:原型制造工单------存的是工厂对象,用于按需生成早期引用
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
用一个工厂生产的比喻:
- 一级缓存 = 质检合格、包装完毕的成品,随时可以发货
- 二级缓存 = 骨架已搭好、零件还没装的半成品,先放这里备着
- 三级缓存 = 一张电子工单,上面写着"按这个图纸可以造出半成品",真需要时才动手造
关键洞察:三级缓存存的不是Bean本身,而是"造Bean的工厂"。 这就是它比二级缓存多出来的那一级的全部意义。
四、三级缓存到底解决了什么?------两个核心问题
✅ 解决了问题一:循环依赖的"死锁"
经典场景:A依赖B,B依赖A,都是单例+字段注入。
没有缓存时的死锁过程:
css
创建A → 实例化A(空壳)→ 需要注入B → 创建B → 实例化B(空壳)
→ 需要注入A → A还没创建完 → 死循环 → 栈溢出
三级缓存的破局之道:
css
1. 创建A → 实例化A → 把A的工厂放入三级缓存
2. A需要B → 创建B → 实例化B → 把B的工厂放入三级缓存
3. B需要A → 查一级缓存:没有
→ 查二级缓存:没有
→ 查三级缓存:找到A的工厂!
4. 调用工厂.getObject() → 拿到A的早期引用(半成品)
5. 把A从三级缓存升级到二级缓存
6. B注入A(半成品)→ B完成初始化 → 放入一级缓存
7. A注入B(成品)→ A完成初始化 → 放入一级缓存
死锁被打破的关键一步:第3步。 B不再傻等A创建完成,而是直接从三级缓存拿到了A的"早期引用"------虽然是半成品,但够用了。
这就是Spring官方文档说的 "提前暴露"(Early Exposure) :在Bean完全初始化之前,就把引用透露出去,让依赖链能够继续推进。
✅ 解决了问题二:AOP代理的一致性
这才是三级缓存存在的真正理由,也是最容易被忽略的点。
假设只有二级缓存,没有三级:
css
A实例化后直接放入二级缓存(原始对象)
B需要A → 从二级缓存拿到A的原始对象 → 注入
A完成初始化 → 生成AOP代理对象 → 放入一级缓存
灾难来了:B手里持有的是A的原始对象,而一级缓存里放的是A的代理对象。 同一个Bean,两个版本!B调用A的方法时,事务切面、日志切面全部失效。
三级缓存怎么解决的?
scss
三级缓存存的是ObjectFactory,它的getObject()方法会调用getEarlyBeanReference()
→ 这个方法会检查是否需要AOP增强
→ 如果需要,返回的是代理对象;如果不需要,返回原始对象
→ 确保B拿到的,和最终放入一级缓存的,是同一个对象!
源码佐证------AbstractAutowireCapableBeanFactory.getEarlyBeanReference():
typescript
java
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
// 如果有AOP感知的BeanPostProcessor,允许它们修改早期暴露的Bean
if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
}
}
return exposedObject; // 这里可能已经是代理对象了
}
所以,三级缓存的本质不是"多存一层",而是"延迟决策"------把"要不要生成代理"这个问题,推迟到真正有人要用这个Bean的那一刻才回答。
五、三级缓存没解决什么?------三个硬伤
❌ 硬伤一:构造器注入的循环依赖
kotlin
java
@Service
public class A {
private final B b;
public A(B b) { this.b = b; } // 创建A的瞬间就必须有B
}
@Service
public class B {
private final A a;
public B(A a) { this.a = a; } // 创建B的瞬间就必须有A
}
Spring直接抛异常,不尝试任何缓存机制。
原因很简单:构造器注入要求"实例化"和"依赖注入"合二为一,没有"先造空壳再填属性"的机会。Bean还没new出来就要依赖,这是逻辑上的悖论,三级缓存也无能为力。
❌ 硬伤二:原型Bean(prototype)的循环依赖
less
java
@Service
@Scope("prototype")
public class A {
@Autowired private B b;
}
@Service
@Scope("prototype")
public class B {
@Autowired private A a;
}
直接抛BeanCurrentlyInCreationException。
因为prototype每次请求都创建新实例,不存在"缓存"的前提。你存进去的是上一次的实例,下一次请求又要新的,缓存毫无意义。Spring的设计哲学很明确:三级缓存是为singleton服务的,prototype不参与这套机制。
❌ 硬伤三:多例循环依赖中的"同例"问题
即便是单例Bean,如果循环依赖链中混入了@Lazy、@DependsOn等复杂注解,或者BeanPostProcessor的执行顺序出了问题,三级缓存照样可能翻车。
比如这个坑:
kotlin
java
@Service
public class A {
@Autowired private B b;
}
@Service
public class B {
@Autowired private A a; // 看起来是循环依赖
@PostConstruct
public void init() {
// 如果这里抛异常,A的初始化会回滚
// 但B已经从三级缓存拿到了A的早期引用
// 导致A的状态不一致
}
}
这不是三级缓存的bug,而是Bean生命周期的复杂性超出了缓存机制的覆盖范围。
六、一张图总结:三级缓存的能力边界
css
┌─────────────────────────────┐
│ 三级缓存能解决的 │
│ ✅ 单例Bean │
│ ✅ 字段注入 / Setter注入 │
│ ✅ 包含AOP代理的场景 │
│ ✅ 任意长度的依赖链 │
│ (A→B→C→A 都行) │
└─────────────────────────────┘
│
┌─────────┴─────────┐
│ 三级缓存管不了的 │
│ ❌ 构造器注入 │
│ ❌ prototype作用域 │
│ ❌ 依赖链中有@Async │
│ ❌ BeanPostProcessor │
│ 执行顺序导致的竞态 │
└─────────────────────┘
七、实战建议:别等出了问题再查
| 场景 | 推荐方案 |
|---|---|
| 确有循环依赖(单例+字段注入) | 不用管,Spring自动搞定 |
| 构造器注入的循环依赖 | 改为Setter注入,或抽取中间类打破环路 |
| prototype的循环依赖 | 用@Lazy延迟加载,或ObjectProvider<T>手动获取 |
| 复杂依赖链(3个以上) | 定期用mvn dependency:tree分析,重构包结构 |
| 想验证三级缓存在工作 | 断点打在DefaultSingletonBeanRegistry.getSingleton(),看三级缓存的命中 |
八、写在最后
三级缓存不是银弹,它是Spring在"单例+字段注入"这个特定约束下,用空间换时间、用延迟换一致的精妙设计。
它解决的,是 "半成品能不能先借出去用" 的问题。
它没解决的,是 "借出去的半成品到底对不对" 的问题。
理解了这一点,你就理解了Spring IoC容器最深处的那行代码------
不是所有的循环都能被打破,但被打破的那个,一定是提前暴露了真相。