Spring 的三级缓存与循环依赖

在 Spring 的日常开发中,循环依赖(Circular Dependency)是一个绕不开的话题。当 Bean A 依赖 Bean B,而 Bean B 又反过来依赖 A 时,如果没有特殊的处理机制,容器在创建对象时就会陷入无限递归的死锁。

然而,Spring 却能在绝大多数场景下优雅地化解这一危机。其背后的核心功臣,便是大名鼎鼎的"三级缓存"机制。但如果你深入探究,往往会产生一连串的疑问:为什么非要三级?两级不行吗?AOP 代理在其中扮演了什么角色?

本文将带你拨开源码的迷雾,用通俗的逻辑和代码链路,彻底讲透三级缓存的设计哲学。

1. Spring 的"破局"三板斧

Spring 解决这个问题的核心思路是:"先上车,后补票"。它允许一个对象在还未完全创建好(比如属性还没填完)时,就提前暴露一个"半成品"引用,让依赖它的其他对象先拿着用,等自己真正完成创建后,再"补全"自己。

为了实现这个精妙的机制,Spring 在 DefaultSingletonBeanRegistry 中准备了三层缓存,它们各司其职:

  • 一级缓存 (singletonObjects):成品库,存放完全初始化好的 Bean 实例。
  • 二级缓存 (earlySingletonObjects):半成品库,存放提前暴露的、但还未完成属性填充和初始化的 Bean 实例。
  • 三级缓存 (singletonFactories):工厂库,存放 ObjectFactory 对象工厂,用于按需生成半成品引用或 AOP 代理。

为了让你直观地看到它们是如何协同工作的,我们以经典的 A 依赖 B、B 依赖 A 为例,来看一张时序图:

sequenceDiagram participant A as AService (创建中) participant B as BService (创建中) participant L1 as 一级缓存 (成品) participant L2 as 二级缓存 (半成品) participant L3 as 三级缓存 (工厂) Note over A: 1. 开始创建 A A->>A: 实例化 (new 对象) A->>L3: 2. 将自身工厂放入三级缓存 Note over A: 3. 开始属性填充,发现依赖 B A->>B: 4. 去获取 B Note over B: 5. 开始创建 B B->>B: 实例化 (new 对象) B->>L3: 6. 将自身工厂放入三级缓存 Note over B: 7. 开始属性填充,发现依赖 A B->>L1: 8. 获取 A (一级缓存未命中) B->>L2: 9. 获取 A (二级缓存未命中) B->>L3: 10. 获取 A 的工厂并执行 L3-->>B: 11. 返回 A 的早期引用(半成品) B->>L2: 12. 将 A 的半成品放入二级缓存 B->>L3: 13. 从三级缓存移除 A 的工厂 Note over B: 14. B 的属性填充完成 Note over B: 15. B 执行初始化 B->>L1: 16. 将完整的 B 放入一级缓存 Note over A: 17. A 获取到完整的 B,继续填充 Note over A: 18. A 执行初始化 A->>L1: 19. 将完整的 A 放入一级缓存

看到这里,你可能和我一样,脑子里会冒出第一个疑问。

2. 常见疑问一:为什么二级缓存不够用?

等等,从流程图上看,A 实例化后直接把半成品放到二级缓存,B 不就能直接从二级缓存拿到 A 了吗?那三级缓存是不是多余的?"

答案是:单看解决循环依赖,二级缓存确实足够了。 但三级缓存的存在,是为了解决一个更棘手的问题------AOP 代理。

如果没有三级缓存,只在二级缓存中存放原始对象,那么当 A 被 AOP 增强后(比如有 @Transactional 注解),B 依赖的 A 应该是代理对象,而不是原始对象。如果二级缓存中只存了原始对象,B 就拿不到正确的代理对象,导致事务等功能失效。

如果为了解决这个问题,把二级缓存改成"实例化后立即检查是否要生成 AOP 代理,需要就生成代理再放进去",那又会引发新的问题:每一个 Bean 在实例化后都要执行一遍 AOP 检查,这对绝大多数没有循环依赖的 Bean 来说,是一种性能浪费。

这就是三级缓存设计的精妙之处:它存储的是一个 ObjectFactory(对象工厂),而不是最终对象。这个工厂只在 "真正发生循环依赖" 时(即其他 Bean 来获取早期引用时)才会被触发执行,从而按需、延迟地创建 AOP 代理。没有循环依赖的 Bean,就不会触发这个工厂,代理会照常在其生命周期后期生成。

3. 常见疑问二:那我把"工厂"和"结果"合并到一个缓存里不行吗?

"既然三级缓存是工厂,二级缓存是工厂的执行结果,那为什么不合二为一?缓存里如果没有值,就当场执行工厂逻辑拿到对象再返回,不也行吗?"

这是一个非常深入的思考,我们刚才也重点探讨过。这个方案在逻辑上完全可行,但 Spring 最终选择了"分离"而不是"合并",主要出于以下考虑:

  • 单一职责:三级缓存只管"怎么造"(工厂),二级缓存只管"造出来什么"(结果)。职责清晰,代码可读性极高。
  • 并发安全:在并发场景下,如果有两个线程同时来获取 A,Spring 通过从三级缓存中移除工厂(remove 操作)这一原子动作,确保了工厂逻辑有且仅有一个线程能成功执行并生成对象。如果工厂和结果混在一个 Map 里,你就需要用额外的锁或状态标记来防止重复执行,逻辑会变得复杂且容易出错。

所以,Spring 的设计是典型的用空间(多一个 Map)换清晰度和并发安全性,这是一种非常优雅的架构权衡。

4. 总结

Spring 的三级缓存,是一个层层递进、逻辑严密的解决方案:

  • 一级缓存 是最终成品库,确保单例。
  • 二级缓存 是半成品库,也是三级缓存执行结果的缓存,保证循环依赖中大家拿到的是同一个对象。
  • 三级缓存 是工厂库,实现了 AOP 代理的按需、延迟创建,避免了不必要的性能开销。

这套机制在保证高性能的同时,优雅地解耦了 Spring 的 IoC 容器和 AOP 等高级功能,无愧于 Spring 框架最精妙的设计之一。

相关推荐
神奇小汤圆19 分钟前
2026 Java岗面试题库(已收录GitHub),覆盖所有考点
后端
ArixBit30 分钟前
用 Go 手撸 Agent 框架 #4:工具定义与契约——让 Agent 学会调用多工具
后端
枕星而眠34 分钟前
【数据结构】红黑树入门指南
运维·数据结构·c++·后端
神奇小汤圆36 分钟前
JDK 8 → 17 → 21 → 25:一次性讲清四代版本的关键跃迁
后端
要阿尔卑斯吗41 分钟前
无限深度章节树如何一遍建成?路径编码+单调栈
后端
大东_tom42 分钟前
Python 与 PyTorch 工程实践——从脚本到训练框架
后端
Joose都有人用1 小时前
我把一个「九成正常、一成作恶」的风控难题做稳了,聊聊踩过的坑
后端
她说彩礼65万2 小时前
Asp.net core返回类型总结
后端·asp.net
用户6757049885023 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第二章:发布之sshPublisher)
后端·jenkins
用户6757049885023 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第一章:部署)
后端·jenkins