Spring 循环依赖与三级缓存:一场"先有鸡还是先有蛋"的优雅破局
本文从一个真实的报错场景出发,带你一步步理解 Spring 如何用三级缓存化解循环依赖,并回答那个经典追问:为什么是三级,而不是二级?
引言:一个让人摸不着头脑的报错
设想你正在开发一个电商系统。OrderService(订单服务)处理订单时需要查询用户信息,于是注入了 UserService;而 UserService 在推送消息时又需要知道用户有哪些订单,于是注入了 OrderService:
java
@Service
public class OrderService {
@Autowired
private UserService userService; // 订单要查用户
public void createOrder(Long userId) {
String userName = userService.getUserName(userId);
System.out.println("为用户 " + userName + " 创建订单");
}
}
@Service
public class UserService {
@Autowired
private OrderService orderService; // 用户要查订单
public void showUserOrders(Long userId) {
var orders = orderService.listByUser(userId);
System.out.println("用户订单:" + orders);
}
}
看起来很合理,对吧?但如果你把其中一个改成构造器注入,启动时会直接炸出这样的异常:
css
The dependencies of some of the beans in the application context
form a cycle:
┌─────┐
| orderService
↑ ↓
| userService
└─────┘
这就是循环依赖(Circular Dependency):创建 A 需要 B,创建 B 又需要 A,像极了"先有鸡还是先有蛋"。
有意思的是:上面的字段注入版本不会报错,而构造器注入版本会。为什么?答案藏在 Spring 的三级缓存里。读完本文,你不仅能解释这个现象,还能在面试中把源码级细节讲得明明白白。
一、问题本质:一个走不出去的死循环
先抛开 Spring,想想容器创建 Bean 的朴素逻辑:
如果不做任何干预,这就是一个无限递归,最终栈溢出。Spring 破局的思路其实非常朴素:
把"创建"拆成三步------先实例化(new 出空壳)→ 再填充属性 → 最后初始化。实例化一完成,就把自己的引用"借"出去。
于是 B 来要 A 时,拿到的虽然是"半成品 A"(属性还是 null),但引用是同一个对象,等 A 后续填充、初始化完成后,B 手里的引用自然就是完整的 A 了。死循环就此打破。
那三级缓存在这中间扮演什么角色?别急,先看它们各自存的是什么。
二、三级缓存:成品仓库、半成品展示柜、配方工厂
核心代码位于 DefaultSingletonBeanRegistry:
java
public class DefaultSingletonBeanRegistry ... {
// 一级缓存:成品 Bean(完整走完生命周期)
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
// 二级缓存:半成品 Bean(已实例化、未完成属性填充/初始化),
// 存的可能是提前生成的代理对象
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
// 三级缓存:对象工厂 Lambda,用于决定是否提前生成代理
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
}
用生活化的比喻来理解:
| 缓存 | 比个比方 | 存的东西 | 什么时候放进去 |
|---|---|---|---|
一级 singletonObjects |
成品仓库 | 完整走完生命周期的 Bean | Bean 全部创建完成后 |
二级 earlySingletonObjects |
半成品展示柜 | 提前曝光的早期对象(可能是 AOP 代理) | 三级缓存中的工厂被调用后,结果升级到这里 |
三级 singletonFactories |
配方/工厂 | ObjectFactory Lambda,尚未执行 |
Bean 刚实例化(还是空壳)时 |
一句话总结:一级存成品,二级存半成品,三级存制造半成品的配方。
你可能会问:直接搞一个二级缓存(实例化后就放半成品)不就完了吗?为什么还要多此一举搞个"没执行的工厂"?这是本文最重要的追问,我们先把流程走完,最后揭晓答案。
三、完整流程:跟着源码走一遍
下面用时序图把整个流程串起来(A = OrderService,B = UserService):
对应到源码,关键步骤有三处。
步骤 1:A 实例化后,把"配方"放进三级缓存
AbstractAutowireCapableBeanFactory#doCreateBean:
java
protected Object doCreateBean(...) {
// ① 实例化(反射调用构造器),此时只是空壳对象
// 对应 OrderService:内存里有了对象,但 userService 字段还是 null
instanceWrapper = createBeanInstance(beanName, mbd, args);
Object bean = instanceWrapper.getWrappedInstance();
// ② 若允许循环依赖,把"工厂"丢进三级缓存
// 注意:此时还没有生成代理,工厂里只是包了一层逻辑
if (earlySingletonExposure) {
addSingletonFactory(beanName,
() -> getEarlyBeanReference(beanName, mbd, bean)); // ③ Lambda 惰性执行
}
// ④ 属性填充:这里会触发 getBean("userService"),从而递归创建 B
populateBean(beanName, mbd, instanceWrapper);
// ⑤ 初始化(Aware → BeanPostProcessor 前置 → init → 后置)
exposedObject = initializeBean(beanName, exposedObject, mbd);
...
}
注意第 ② 步放进去的只是一个 Lambda(配方),此时并没有执行它,也没有生成任何代理。这一点是回答"为什么三级"的关键伏笔。
步骤 2:B 需要 A 时,三级查找取回早期引用
DefaultSingletonBeanRegistry#getSingleton,这是整个机制的核心查找逻辑:
java
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
Object singletonObject = this.singletonObjects.get(beanName); // 先查一级
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName); // 再查二级
if (singletonObject == null && allowEarlyReference) {
synchronized (this.singletonObjects) {
// 双重检查...
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); // 查三级
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject(); // 触发工厂:可能返回代理
this.earlySingletonObjects.put(beanName, singletonObject); // 升级到二级
this.singletonFactories.remove(beanName); // 从三级移除
}
}
}
}
return singletonObject;
}
查找的决策流程如下:
回到我们的例子:UserService 填充属性时来找 OrderService,一级没有(还没造完)、二级没有(还没人借过)、三级命中工厂 → 执行工厂 → 拿到 A 的早期引用 → 放入二级缓存。B 顺利拿到引用,继续走完自己的生命周期,进入一级缓存。
步骤 3:工厂里到底做了什么
java
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
// SmartInstantiationAwareBeanPostProcessor(如 AOP 的 AbstractAutoProxyCreator)
// 会在这里判断:如果该 Bean 需要被代理,就**提前**生成代理对象返回
for (SmartInstantiationAwareBeanPostProcessor bp : ...) {
exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
}
return exposedObject;
}
举个例子:假设 OrderService 上有 @Transactional 注解,那它正常情况下应该在初始化完成后 被 AOP 包成代理对象。但此刻 B 急着要 A 的引用,等不了那么久------于是工厂在这里现场判断"这个 Bean 要不要代理",要的话就提前生成。B 拿到的是代理后的 OrderService,这保证了事务增强不丢失。
四、关键细节:升级入一级缓存的时机与"对象一致性"
时机:该 Bean 自己的生命周期全部走完时 。由外层 doGetBean 中 getSingleton(beanName, singletonFactory) Lambda 的 finally 块完成升级:
java
// AbstractBeanFactory#doGetBean 中
singletonObject = getSingleton(beanName, () -> createBean(beanName, mbd, args));
// DefaultSingletonBeanRegistry#getSingleton(String, ObjectFactory)
try {
return singletonFactory.getObject(); // 内部走 doCreateBean 完整生命周期
} finally {
if (newSingleton) {
addSingleton(beanName, singletonObject); // ①放入一级 ②从二级、三级移除
}
}
protected void addSingleton(String beanName, Object singletonObject) {
synchronized (this.singletonObjects) {
this.singletonObjects.put(beanName, singletonObject); // 升级到一级
this.singletonFactories.remove(beanName); // 清理三级
this.earlySingletonObjects.remove(beanName); // 清理二级
}
}
这里有个容易被忽略的细节:放入一级的对象不一定是 initializeBean 之后的原对象 。如果二级缓存里的早期引用是提前生成的 AOP 代理,doCreateBean 末尾会做一致性检查:
java
Object earlySingletonReference = getSingleton(beanName, false); // 只查一、二级
if (earlySingletonReference != null) {
if (exposedObject == bean) {
exposedObject = earlySingletonReference; // 最终以二级缓存的代理为准
}
}
为什么?想象一下:如果 A 最终放入一级缓存的是原对象 ,而 B 手里拿着的是提前生成的代理 ,容器里就会出现两个"版本"的 OrderService------一个有事务增强,一个没有。Spring 用这个检查保证:一旦提前暴露了代理,全世界看到的就都是这个代理。
结合时序图再看一遍:B 是在第 ⑧ 步 initializeBean(B) 之后整体完成时升级入一级;A 是在回来走完自己的填充和初始化后升级入一级。二级缓存只是"借出引用期间的临时存放点",创建一结束就被清掉。
五、灵魂拷问:为什么是三级而不是二级?
这是面试官最爱追问的点,也是理解整个设计的钥匙。
先说结论:二级缓存其实能解决循环依赖本身,缺的那一级是为了保住 AOP 的设计原则。
推演一下如果只用二级缓存会怎样:
- Spring 的正常设计:代理应在 Bean 初始化完成之后 由
BeanPostProcessor#postProcessAfterInitialization生成(此时@PostConstruct等逻辑已在原对象上执行完毕)。 - 若只有二级缓存:Spring 就必须在 Bean 刚实例化时立刻决定"给原对象还是代理对象"并放进二级缓存。
- 后果一:所有 Bean 一实例化就要提前做 AOP 判断,哪怕 99% 的 Bean 根本没有循环依赖,也被迫破坏了"代理在初始化后生成"的设计原则。
- 后果二:提前代理可能让
@PostConstruct等初始化逻辑作用在错误的对象上(代理未生成/生成方式不对)。
而三级缓存存的 Lambda 是惰性的:只有真的发生循环依赖、别人来取我时才执行,现场决定要不要提前生成代理。没有循环依赖就永远不触发,原有设计完全不受影响。
简记:二级缓存保证"给得出引用",三级缓存保证"代理不用提前造"。 三级缓存 = 惰性化的二级缓存,把"要不要提前代理"这个决策延迟到真正需要的那一刻。
六、三级缓存也不是万能的
理解了原理,就能推出它的边界------引用必须能在"实例化之后"被暴露出来,机制才生效:
| 场景 | 原因 |
|---|---|
| 构造器注入循环依赖 | 实例化阶段就互相要对方,而三级缓存在实例化之后才暴露,来不及 |
prototype 作用域 |
无缓存可提前暴露,每次都要新建,直接抛 BeanCurrentlyInCreationException |
@Async 增强的 Bean |
异步代理由 AsyncAnnotationBeanPostProcessor 在初始化后才生成,早期暴露对象与最终对象不一致,抛 BeanCurrentlyInCreationException |
这也解释了引言中的现象:字段/setter 注入 发生在 populateBean 阶段(实例化之后),所以能被救;构造器注入发生在实例化阶段本身,三级缓存还没来得及放进去,救不了。
构造器注入的解法举例:
java
// 方案 1:@Lazy 注入代理,首次真正调用时才去 getBean
public OrderService(@Lazy UserService userService) { ... }
// 方案 2:ObjectProvider 延迟获取
public OrderService(ObjectProvider<UserService> userServiceProvider) {
this.userService = userServiceProvider.getObject();
}
// 方案 3(最推荐):重新设计依赖结构,把循环拆开
// 比如抽出 OrderQueryService,让 UserService 依赖它而不是 OrderService
七、总结
- 问题:A 依赖 B、B 依赖 A,创建过程形成死循环。
- 思路:把创建拆成"实例化 → 填充 → 初始化"三步,实例化后提前暴露引用,循环就断了。A 还没造完,就先把"自己的引用"借给了 B。
- 实现 :一级存成品、二级存半成品、三级存"制造半成品的配方"(未执行的
ObjectFactory)。别人来取时才执行配方,按需决定是否提前生成 AOP 代理,结果升级入二级缓存;Bean 走完生命周期后由addSingleton升级入一级。 - 为什么三级:二级缓存能解决死循环,但会让所有 Bean 在实例化时就被迫做 AOP 判断、提前生成代理,破坏"代理在初始化后生成"的设计原则。第三级是惰性的,只在真正发生循环依赖时才触发。
- 边界 :构造器注入、
prototype作用域、@Async场景无解,需@Lazy、ObjectProvider或重构依赖。