Spring 循环依赖与三级缓存:一场"先有鸡还是先有蛋"的优雅破局
本文从一个真实的报错场景出发,带你一步步理解 Spring 如何用三级缓存化解循环依赖,并回答那个经典追问:为什么是三级,而不是二级?
引言:一个让人摸不着头脑的报错
设想你正在开发一个电商系统。OrderService(订单服务)处理订单时需要查询用户信息,于是注入了 UserService;而 UserService 在推送消息时又需要知道用户有哪些订单,于是注入了 OrderService:
@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);
}
}
看起来很合理,对吧?但如果你把其中一个改成构造器注入,启动时会直接炸出这样的异常:
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 的朴素逻辑:
flowchart TD
A[创建 OrderService] --> B{需要 UserService?}
B -- 是 --> C[去创建 UserService]
C --> D{需要 OrderService?}
D -- 是 --> E[去创建 OrderService...]
E --> B
style E fill:#f66,color:#fff
如果不做任何干预,这就是一个无限递归,最终栈溢出。Spring 破局的思路其实非常朴素:
把"创建"拆成三步——先实例化(new 出空壳)→ 再填充属性 → 最后初始化。实例化一完成,就把自己的引用"借"出去。
于是 B 来要 A 时,拿到的虽然是"半成品 A"(属性还是 null),但引用是同一个对象,等 A 后续填充、初始化完成后,B 手里的引用自然就是完整的 A 了。死循环就此打破。
那三级缓存在这中间扮演什么角色?别急,先看它们各自存的是什么。
二、三级缓存:成品仓库、半成品展示柜、配方工厂
核心代码位于 DefaultSingletonBeanRegistry:
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):
sequenceDiagram
participant App as 调用方
participant BC as AbstractBeanFactory
participant A as 创建A流程
participant Reg as 三级缓存(Registry)
participant B as 创建B流程
App->>BC: getBean("a")
BC->>A: createBean(A)
A->>A: ①反射实例化A(空壳)
A->>Reg: ②addSingletonFactory("a", 工厂)<br/>放入三级缓存
A->>A: ③populateBean 填充属性
A->>BC: getBean("b") 发现需要B
BC->>B: createBean(B)
B->>B: ④反射实例化B(空壳)
B->>Reg: ⑤addSingletonFactory("b", 工厂)<br/>放入三级缓存
B->>B: ⑥populateBean 填充属性
B->>BC: getBean("a") 发现需要A
BC->>Reg: getSingleton("a")
Reg->>Reg: 一级缓存未命中<br/>二级缓存未命中<br/>三级缓存命中工厂
Reg-->>B: 工厂.getObject() 返回A的早期引用<br/>(可能是AOP代理),并升级入二级缓存
B->>B: ⑦B.a = a(引用注入完成)
B->>B: ⑧initializeBean 初始化B
B-->>BC: 返回成品B,放入一级缓存
BC-->>A: B 就绪
A->>A: ⑨a.b = b(引用注入完成)
A->>A: ⑩initializeBean 初始化A
Note over A: 若二级缓存中A的早期引用<br/>与初始化后对象不一致<br/>(被代理过),则A最终<br/>以二级缓存的代理为准
A-->>BC: 返回成品A,放入一级缓存
BC-->>App: getBean("a") 完成
对应到源码,关键步骤有三处。
步骤 1:A 实例化后,把"配方"放进三级缓存
AbstractAutowireCapableBeanFactory#doCreateBean:
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,这是整个机制的核心查找逻辑:
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;
}
查找的决策流程如下:
flowchart TD
Start[getBean 请求] --> S1{一级缓存<br/>singletonObjects 有?}
S1 -- 有 --> Done[直接返回成品]
S1 -- 无 --> S2{该 Bean 正在创建中?<br/>isSingletonCurrentlyInCreation}
S2 -- 否 --> Create[正常走 createBean 流程]
S2 -- 是 --> S3{二级缓存<br/>earlySingletonObjects 有?}
S3 -- 有 --> Ret2[返回早期引用]
S3 -- 无 --> S4{允许早期引用?<br/>allowEarlyReference}
S4 -- 否 --> Null[返回 null, 继续创建]
S4 -- 是 --> S5{三级缓存<br/>singletonFactories 有?}
S5 -- 有 --> S6[工厂.getObject<br/>可能生成AOP代理]
S6 --> S7[结果放入二级缓存<br/>从三级缓存删除]
S7 --> Ret2
S5 -- 无 --> Null
回到我们的例子:UserService 填充属性时来找 OrderService,一级没有(还没造完)、二级没有(还没人借过)、三级命中工厂 → 执行工厂 → 拿到 A 的早期引用 → 放入二级缓存。B 顺利拿到引用,继续走完自己的生命周期,进入一级缓存。
步骤 3:工厂里到底做了什么
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 块完成升级:
// 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 末尾会做一致性检查:
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 是惰性的:只有真的发生循环依赖、别人来取我时才执行,现场决定要不要提前生成代理。没有循环依赖就永远不触发,原有设计完全不受影响。
flowchart LR
subgraph L1[只有二级缓存的世界]
A1[Bean 刚实例化] --> B1[立刻做 AOP 判断<br/>所有 Bean 都被迫提前]
B1 --> C1[破坏代理在初始化后生成<br/>的设计原则]
end
subgraph L2[三级缓存的世界]
A2[Bean 刚实例化] --> B2[只存一个未执行的 Lambda<br/>零成本]
B2 --> C2{发生循环依赖<br/>有人来取?}
C2 -- 否 --> D2[初始化后再正常生成代理]
C2 -- 是 --> E2[现场执行工厂<br/>按需提前生成代理]
end
简记:二级缓存保证"给得出引用",三级缓存保证"代理不用提前造"。 三级缓存 = 惰性化的二级缓存,把"要不要提前代理"这个决策延迟到真正需要的那一刻。
六、三级缓存也不是万能的
理解了原理,就能推出它的边界——引用必须能在"实例化之后"被暴露出来,机制才生效:
| 场景 | 原因 |
|---|---|
| 构造器注入循环依赖 | 实例化阶段就互相要对方,而三级缓存在实例化之后才暴露,来不及 |
prototype 作用域 | 无缓存可提前暴露,每次都要新建,直接抛 BeanCurrentlyInCreationException |
@Async 增强的 Bean | 异步代理由 AsyncAnnotationBeanPostProcessor 在初始化后才生成,早期暴露对象与最终对象不一致,抛 BeanCurrentlyInCreationException |
这也解释了引言中的现象:字段/setter 注入发生在 populateBean 阶段(实例化之后),所以能被救;构造器注入发生在实例化阶段本身,三级缓存还没来得及放进去,救不了。
构造器注入的解法举例:
// 方案 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或重构依赖。