上周面了三家 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,两级其实够用
假设 A 和 B 都是 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 就提前生成同一个代理,放进二级缓存。构造器循环没有可曝光的实例,官方就是报错。