Spring 循环依赖为什么要三级缓存:两级会把 AOP 代理弄丢

上周面了三家 Java 后端,两家都问了循环依赖。

第一问几乎一样:「Spring 怎么解决循环依赖?」我把三级缓存的字段名背出来,面试官点点头。

第二问才是真的:「那为什么不是两级?第三级到底多出来干什么?」

这题我第一次挂过。后来把 Spring Framework 6.2.8 的 DefaultSingletonBeanRegistry 翻出来,才看清第三级不是为了「多一层保险」,是为了 AOP。

三级缓存到底是哪三个 Map

字段就在 DefaultSingletonBeanRegistry 里,名字比口诀直白:

java 复制代码
/** 成品:初始化完成、能直接给业务用 */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

/** 早期工厂:还没人来拿时,只存一个 ObjectFactory */
private final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(16);

/** 已经提前曝光过的对象:工厂跑过一次就放这里 */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

查找顺序也写死在 getSingleton 里:一级 → 正在创建才看二级 → 允许提前引用才跑三级工厂。

工厂跑完会从 singletonFactories 删掉,结果塞进 earlySingletonObjects。同一个 bean 的早期引用只会生产一次。

doCreateBean 里,实例化刚结束、属性还没填,就会把工厂挂上去:

java 复制代码
boolean earlySingletonExposure = (mbd.isSingleton()
        && this.allowCircularReferences
        && isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}

三件事同时成立才曝光:单例、允许循环引用(默认 true)、这个 bean 正在创建。原型 bean 进不来。构造器互相咬死也进不来,因为对象都还没 new 出来。

没有 AOP,两级其实够用

假设 AB 都是 setter / 字段注入,谁都没有切面。

容器先 new A()。A 已经是堆上的对象了,只是字段还是 null。这时把 A 的引用丢进缓存,再去创建 B。B 创建时发现依赖 A,从缓存拿到那个半成品 A,注入进去。B 初始化完,再回来把 B 填进 A。

这能成,靠的是 Java 引用语义:半成品和成品是同一个对象,后面把字段补上,B 手里那份引用跟着完整。

所以「循环依赖靠提前曝光」这件事,二级缓存就能做。网上说「必须三级」如果停在这里,其实没讲完。

第三级多出来的,是「要不要做成代理」

加一层 @Transactional 或别的 AOP 之后,容器最后交给别人的不该是原始 A,而是代理。

getEarlyBeanReference 会走 SmartInstantiationAwareBeanPostProcessor。AOP 那边 AbstractAutoProxyCreator 的实现很直接:

java 复制代码
public Object getEarlyBeanReference(Object bean, String beanName) {
    Object cacheKey = getCacheKey(bean.getClass(), beanName);
    this.earlyBeanReferences.put(cacheKey, bean);
    return wrapIfNecessary(bean, beanName, cacheKey);
}

也就是说,有人在初始化完成前就来拿 A,代理必须这一刻做出来。否则 B 注入的是原始对象,A 初始化后再包一层代理,两份引用就不是同一个东西了。

源码里这笔账是要报错的。doCreateBean 收尾如果发现「别人拿到的是 raw 版本,自己最终却 wrapped 了」,会抛 BeanCurrentlyInCreationException,原文是:

has been injected into other beans in its raw version as part of a circular reference, but has eventually been wrapped

这才是第三级存在的理由。

singletonFactories 存的不是对象,是 () -> getEarlyBeanReference(...)

没人来拿,工厂就不跑,AOP 还是按正常生命周期在 initializeBean 里做,不会无故提前包代理。

有人来拿,工厂跑一次,代理(或确认不需要代理的原始对象)放进二级缓存。后面再有人来,直接拿二级里那份,不会再 wrapIfNecessary 一次、造出第二个代理。

两级缓存如果一实例化就把原始对象塞进去,要么提前包了不该包的代理,要么循环对端拿到 raw、最终对外却是 proxy。第三级把「要不要包、包出来的那一个」推迟到真正有人来拿的时候。

构造器循环,三级缓存也救不了

官方文档写得很硬:A 的构造器要 B,B 的构造器要 A,容器会抛 BeanCurrentlyInCreationException

原因也简单。提前曝光发生在 createBeanInstance 之后。构造器还没返回,堆上没有对象,工厂没地方挂。setter / 字段注入能拆成「先 new、再填字段」;构造器做不到。

@Lazy 能绕,本质是先注入代理,真正用的时候再初始化对端。能启动,不代表设计对。Spring 自己的 javadoc 也劝:别依赖循环引用,把公共逻辑抽到第三个 bean。

我后来准备面试,不再背「三级缓存解决循环依赖」这句空的。问到就按这条说:

单例 + setter/字段注入,容器会在实例化后挂一个 ObjectFactory。循环对端来拿时,工厂调用 getEarlyBeanReference,需要 AOP 就提前生成同一个代理,放进二级缓存。构造器循环没有可曝光的实例,官方就是报错。

相关推荐
星空3 小时前
金蝶苍穹build.gradle配置
java·build.gradle
考虑考虑4 小时前
Springboot环境变量占位符语法
spring boot·后端·spring
步行cgn5 小时前
Spring 基于 XML 的自动装配:byName 详解
java·后端·spring
不会c+6 小时前
Day02 - 需求分析与用例建模
spring
大帅点兵6 小时前
Apache Ant 1.9.9 完整讲解
java
LuTshoes6 小时前
spring ai 实战 手搓 PlaneExecuteAgent
java·人工智能·spring·ai
这料鬼有毒6 小时前
二刷hot100-73.矩阵置零
java
caoerzhong6 小时前
JeeWMS 开源仓库管理系统二次开发与接口对接实战:Java WMS 如何与 ERP、MES 和自动化设备打通
java·开源
长谷深风1117 小时前
评测AI Agent:三种裁判各司其职
java·大数据·开发语言·人工智能·ai agent
边境悍匪7 小时前
蜗牛学苑 Java 智能体学习 Day42|项目周开发技术汇总 1 思维导图复盘
java·开发语言·spring boot·学习·spring