Spring 循环依赖:三级缓存的精妙设计
一句话结论(30s)
Spring 用三级缓存解决「两个单例互相引用」的循环依赖,本质是先暴露半成品打破创建循环,再延迟决策保证代理唯一性。一级 singletonObjects 存成品、二级 earlySingletonObjects 存半成品打破循环,三级 singletonFactories 用 ObjectFactory 把「是否需要 AOP 代理」的决策推迟到第一次被引用时,再用 earlyProxyReferences 标记保证 after 阶段不重复代理。但它解决不了构造器注入的循环依赖,因为构造器调用发生在实例化阶段、还没有任何缓存暴露,只能靠 @Lazy 打破。
核心原理(2min)
主流程:A 实例化后(new A())不是直接放二级缓存,而是往三级缓存 singletonFactories 放一个 ObjectFactory(内部是 getEarlyBeanReference)。当 B 创建时需要 A,触发 getBean(A),从三级缓存取出并执行 ObjectFactory.getObject(),此时按需决定:A 需要 AOP 就提前生成早期代理、不需要就返回原始对象,结果存入二级缓存 earlySingletonObjects;B 拿到 A 的引用完成创建,A 再注入已完成的 B,最终 A 完整后放入一级缓存 singletonObjects。关键机制在于:代理本来在 postProcessAfterInitialization 阶段生成,但那时 B 可能早已拿走原始 A,导致一级缓存里是代理、B 手里是原始对象、单例语义被破坏;三级缓存的延迟决策配合 earlyProxyReferences 标记,让一级缓存与 B 持有的引用是同一个对象。
底层深入(5-10min)
什么是循环依赖
@Component
class A {
@Autowired B b;
}
@Component
class B {
@Autowired A a;
}
A 创建时需要 B → B 创建时需要 A → 互相等 → 死循环。如果没有 Spring 的三级缓存,容器启动会 StackOverflow。
三级缓存的数据结构
DefaultSingletonBeanRegistry 中三级缓存就是三个 Map(Spring 源码逐字):
/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/** Creation-time registry of singleton factories: bean name to ObjectFactory. */
private final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(16);
/** Cache of early singleton objects: bean name to bean instance. */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
三个 Map 分工明确:
- 一级
singletonObjects:存完全初始化好的「成品 Bean」,所有getBean()最终都从这里取结果。 - 二级
earlySingletonObjects:存被提前暴露的「半成品」(原始对象或早期代理),专门用来打破创建循环。 - 三级
singletonFactories:存的是ObjectFactory而不是 Bean 本身,它把「要不要提前生成代理」的决策延迟到第一次被引用时。
思考一下:为什么非要三级,两级不行吗? 如果只留「成品 + 半成品」两级,那「要不要给 A 生成代理」就必须在 A 实例化后、暴露半成品的那一刻拍板。但此刻 A 还没走完初始化,我们根本不知道它最终会不会被 AOP 代理——提前生成可能白生成,不生成又怕 B 拿走原始对象导致代理不唯一。三级缓存里放的不是 Bean 而是
ObjectFactory,等于把「决策」也延迟了:等真有人来取 A 的引用时,再现场决定返回原始对象还是早期代理。所以三级的分工其实是——前两级解决「能不能拿到引用」,第三级解决「拿到的是不是正确的引用」。
getSingleton:查三级缓存
getBean("A") 最终会走到这里,按「一级 → 二级 → 三级」顺序查找(源码逐字):
protected @Nullable Object getSingleton(String beanName, boolean allowEarlyReference) {
// Quick check for existing instance without full singleton lock.
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
if (!this.singletonLock.tryLock()) {
// Avoid early singleton inference outside of original creation thread.
return null;
}
try {
// Consistent creation of early reference within full singleton lock.
singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null) {
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
// Singleton could have been added or removed in the meantime.
if (this.singletonFactories.remove(beanName) != null) {
this.earlySingletonObjects.put(beanName, singletonObject);
}
else {
singletonObject = this.singletonObjects.get(beanName);
}
}
}
}
}
finally {
this.singletonLock.unlock();
}
}
}
return singletonObject;
}
关键点:只有在「Bean 正在创建中」(isSingletonCurrentlyInCreation)时才会查二级、三级缓存,避免误拿别的未完成对象;一旦从三级取出早期引用,就立刻把工厂从三级移除、把结果升级进二级,保证同一个早期引用只生成一次。而 singletonFactory.getObject() 这一步正是触发 getEarlyBeanReference 的地方,也就是「延迟决策」的落点。
思考一下:为什么取到早期引用后要「移除三级 + 升级二级」? 如果工厂一直留在三级缓存,每次
getBean(A)都会重新执行ObjectFactory.getObject(),可能产出多个不同的早期引用——B 拿一个、C 又拿一个新对象,单例就名存实亡。把它升级进二级并删掉工厂,本质是一个**「懒加载 + 一次性缓存」**:第一次被引用时才真正产出早期对象,之后所有人都从二级缓存拿同一个引用。
addSingletonFactory:提前暴露半成品
Bean 实例化完成后、属性注入之前,容器把工厂塞进三级缓存(源码逐字):
protected void addSingletonFactory(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(singletonFactory, "Singleton factory must not be null");
this.singletonFactories.put(beanName, singletonFactory);
this.earlySingletonObjects.remove(beanName);
this.registeredSingletons.add(beanName);
}
注意放进三级缓存的是 ObjectFactory 而非 Bean 本身——此刻 Bean 还没走 populateBean 注入属性、更没走初始化,只是先把「能产出它引用」的工厂挂出去,别的 Bean 就能随时拿到这个半成品的引用来打破循环。
doCreateBean:提前暴露发生在哪一步
真正调用 addSingletonFactory 的地方在 AbstractAutowireCapableBeanFactory.doCreateBean(源码逐字):
// Eagerly cache singletons to be able to resolve circular references
// even when triggered by lifecycle interfaces like BeanFactoryAware.
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
if (logger.isTraceEnabled()) {
logger.trace("Eagerly caching bean '" + beanName +
"' to allow for resolving potential circular references");
}
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
三个条件必须同时成立——「单例 + 允许循环引用 + 当前 Bean 正在创建中」——才会提前暴露,且暴露发生在 populateBean(属性注入)之前。这就是字段/Setter 注入能解循环依赖的根本原因:实例化(new)和属性注入是两步,注入前引用已经挂出去了。
思考一下:这三个条件缺一个会怎样? 去掉「单例」——prototype 本来就不进缓存,提前暴露没有意义;去掉「允许循环引用」——Spring 2.6+ 默认就是关闭的,直接抛异常逼你重构;去掉「正在创建中」——那「提前引用」的判断就失去了前提。这三个条件其实在回答同一个问题:这个 Bean 此刻处于「能被别人提前借用」的状态吗?
为什么需要三级缓存?
如果 A 需要被 AOP 代理(如 @Transactional 注解在 A 上),B 拿到的应该是 A 的代理对象而不是原始对象。
问题:代理是在 Bean 初始化完成后的 postProcessAfterInitialization 阶段生成的——而此时 B 可能在 A 初始化完成之前就拿了二级缓存中的原始 A 对象。
后果:最终一级缓存中的 A 是代理对象,但 B 持有的是原始 A 对象——同一个 Bean 有两个不同引用的单例,单例语义被破坏。 三级缓存就是用来解决这个「代理唯一性」问题的。
getEarlyBeanReference:延迟决定是否代理
三级缓存工厂里真正执行的逻辑是 getEarlyBeanReference(源码逐字):
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
}
}
return exposedObject;
}
它本身不做代理判断,而是把「要不要提前生成代理」交给 SmartInstantiationAwareBeanPostProcessor(典型实现是 AOP 的 AbstractAutoProxyCreator)。如果 Bean 需要 AOP,它会在这里提前 wrap 出早期代理并记录到 earlyProxyReferences,postProcessAfterInitialization 阶段检测到该标记就跳过、不再重复代理;否则直接返回原始对象。结果被升级进二级缓存后,所有人都拿同一个引用,一级缓存与 B 手里持的是同一个对象,单例语义得以保全。
为什么构造器注入解决不了
构造器注入:A 的构造器需要 B,B 的构造器需要 A。构造器调用发生在实例化阶段(createBeanInstance),而提前暴露发生在实例化之后——也就是说连对象都还没 new 出来,三级缓存里没有任何东西可查,双方都无法提前拿到对方引用,只能互相死等。
A 实例化: new A(b) → 需要 B
B 实例化: new B(a) → 需要 A
→ 谁都无法被 new 出来 → 死循环 → BeanCurrentlyInCreationException
对比字段注入:实例化与注入是两步,实例化后能先暴露引用再注入;构造器注入的依赖在 new 的那一刻就必须就绪,没有「先挂出去」的机会,所以三级缓存无能为力。
解法:@Lazy 延迟注入——Bean 属性注入一个代理占位符,首次调用时才去获取真正的 Bean,打破构造器阶段的循环。
思考一下:
@Lazy为什么能解构造器循环,它到底做了什么? 它并没有真正「解决」循环,而是把「立刻要」改成了「将来再要」——注入的是一个代理占位符,构造器只拿到空壳引用就顺利new出来了,真正的 Bean 等首次调用时才去容器取。所以它绕开了「构造器阶段就必须就绪」的死结,代价是真正的循环问题被推迟到运行期才暴露。
总结
| 缓存 | 存储内容 | 作用 |
|---|---|---|
一级 singletonObjects | 完整就绪的 Bean | 最终结果 |
二级 earlySingletonObjects | 早期引用(原始或代理) | 打破循环 |
三级 singletonFactories | ObjectFactory(延迟判断) | 保证代理唯一性 |
Spring 2.6+ 默认禁止循环依赖,需要在配置中显式开启。官方推荐重构设计而非依赖三级缓存——循环依赖通常是设计问题,不是实现问题。
章末提问
1. 为什么非要三级缓存,两级不行吗?
结论先行:两级能解循环依赖,但解不了「AOP 代理唯一性」。
因为:二级缓存里存的是实例化后、初始化前的「半成品」,此时 AOP 代理还没生成(代理在 postProcessAfterInitialization 阶段才 wrap)。如果 B 在这个窗口期拿走了原始 A,等 A 初始化完生成代理放回一级缓存,就会出现「一级是代理、B 手里是原始对象」两个引用的单例。第三级用 ObjectFactory 把「是否提前生成代理」的决策推迟到第一次被引用那一刻,配合 earlyProxyReferences 标记,保证所有人拿到同一个对象。
2. 构造器注入的循环依赖为什么三级缓存救不了?
结论先行:因为构造器调用发生在实例化阶段,早于任何缓存暴露。
因为:三级缓存暴露半成品的时机是 new 之后、populateBean 之前,而构造器注入的依赖在 new A(b) 那一刻就必须就绪——连对象都没 new 出来,缓存里自然空无一物,双方互相死等,最终抛 BeanCurrentlyInCreationException。
3. earlyProxyReferences 到底防的是什么?
结论先行:防的是 AOP 代理被生成两次。
因为:提前暴露时如果 A 需要代理,getEarlyBeanReference 会提前 wrap 出早期代理并记录到 earlyProxyReferences;如果 after 阶段不检查这个标记,postProcessAfterInitialization 会再 wrap 一次,一级缓存里就放进了和 B 手里不一样的代理,单例语义再次被破坏。标记就是「这个人我已经提前代理过了」的备忘。
4. 为什么 Spring 2.6+ 反而默认禁止循环依赖?
结论先行:因为循环依赖绝大多数是设计问题,而不是实现问题。
因为:三级缓存只是「兜底机制」,它让坏设计也能跑起来,但代价是复杂度极高——三级缓存、早期引用、代理提前生成都是为它服务的。官方更希望你在设计阶段就理清依赖方向(如拆类、抽接口、用 @Lazy),而不是让框架默默替你擦屁股。
5. ObjectFactory 在这里为什么比直接存 Bean 更好?
结论先行:因为它把「要不要代理」从「创建时决定」变成了「使用时决定」。
因为:直接存 Bean,就得在 addSingletonFactory 的那一刻(实例化刚完成、尚未初始化)就确定存原始对象还是代理对象,而此刻 AOP 决策所需的信息还不完整;包一层 ObjectFactory 后,真正调用 getObject() 时才执行 getEarlyBeanReference,决策被推迟到「第一次被引用」这个信息最充分的时刻。