主流IoC容器为什么使用三级缓存而不是两级?

作者:搬砖的石头2026.11.07 02:33浏览量:0

简介:本文从单例Bean的完整生命周期出发,逐级拆解三级缓存的职责分工与协作时序,推演两级缓存在“循环依赖+AOP代理”叠加场景下的两种缺陷方案,还原工厂层“延迟决策”的设计精髓,并结合异步代理导致启动失败的真实案例,说明该机制的适用边界与重构建议。

一、从一个反直觉的报错说起

在企业级Java开发中,“循环依赖”是面试高频题,也是线上问题高发区。很多开发者能脱口而出:“主流IoC容器用三级缓存解决了单例Bean的循环依赖。”但一旦追问——为什么偏偏是三级,两级不行吗?能讲清楚的人寥寥无几。

更现实的一幕是:不少团队以为“框架既然号称能解决循环依赖,那怎么写都不会出事”。直到某天,两个互相注入的服务中,因为一个方法上多了个异步注解,项目启动直接失败,异常信息还相当拗口。要理解这次报错的根因,必须先把三级缓存的设计动机吃透。

二、循环依赖到底难在哪

1. 单例Bean的完整生命周期

一个单例Bean从无到有,要依次经历三个阶段:

  1. 实例化:调用构造方法在堆上创建对象,此时成员属性还是默认值,属于“半成品”;
  2. 属性填充:把依赖的其它Bean注入进来;
  3. 初始化:依次执行各类回调通知、初始化方法以及后置处理,最终成为“成品”放入单例池。

2. 循环依赖的死锁点

假设A依赖B、B依赖A,按朴素流程走:创建A→属性填充时需要B→转去创建B→B填充属性时需要A→而A尚未成为成品、不在单例池里→流程卡死。

破局思路其实很朴素:提前曝光半成品。A在实例化完成后、属性填充之前,先把“自己”暴露出去;B需要A时先拿这个半成品应急。由于Java的引用指向同一块内存,等A走完属性填充与初始化后,B手里的引用会“自动升级”为最终形态。

3. 先划清能力边界

这个机制并非万能,必须先明确三条边界:

  • 只对“单例 + 属性/setter注入”的循环有效;
  • 构造器注入的循环无解:构造器注入意味着“实例化”本身就依赖对方,A连半成品都不是,没有任何可曝光的东西;
  • 原型作用域的循环无解:容器不缓存原型Bean,每次获取都新建,提前曝光无从谈起。

三、三级缓存各自扮演什么角色

主流IoC容器的单例注册表内部,大致维护着三个Map(伪代码):

  1. Map<String, Object> level1; // 一级:成品池,存放完整走完生命周期的单例
  2. Map<String, Object> level2; // 二级:早期引用池,存放被提前曝光过的对象(可能是代理)
  3. Map<String, Factory<Object>> level3; // 三级:工厂池,存放能按需产出早期引用的回调

用A↔B互相依赖,走一遍完整时序

  1. getBean(A):一级缓存未命中,将A标记为“创建中”,执行实例化,得到原始对象;
  2. 容器立刻向三级缓存放入一个针对A的工厂。注意:此刻没人知道A会不会参与循环依赖,工厂只是一张“欠条”——承诺“若将来有人提前要A,我能给出一个合法的早期引用”;
  3. A进入属性填充,发现需要B,触发getBean(B);
  4. B重复同样的动作:实例化→放工厂→填充属性,发现需要A;
  5. 再次getBean(A):一级缓存没有→发现A处于“创建中”,允许提前曝光→二级缓存没有→三级缓存命中工厂→调用工厂:容器借此询问各后置处理器“如果现在必须提前暴露A,该暴露什么?”若A需要被AOP代理,此处会提前生成代理→结果升入二级缓存,工厂同时从三级缓存移除;
  6. B拿到A的早期引用(此处为代理),完成填充与初始化,成为成品进入一级缓存;
  7. 流程回到A:注入B、走完初始化。由于A曾被提前曝光(二级缓存留有记录),容器在收尾时做对账:最终对外暴露的引用以二级缓存里那个为准,并跳过初始化后重复生成代理的动作,避免“双重代理”。

结果:B持有的、以及后续任何Bean获取到的,都是同一个代理对象,身份一致性保住了。

四、核心拷问:只用两级缓存行不行

假设砍掉工厂层,只保留“成品池 + 早期池”,有两种摆法,逐一推演。

方案A:二级缓存直接存“实例化后的原始对象”

  • 普通Bean(无任何切面代理):完全够用。后续的属性填充与初始化都是对同一对象的原地修改,引用不变,B提前拿到的引用会“自动长成”成品;
  • 叠加AOP后:B从二级缓存拿到的是原始A;而A初始化完成后,按约定会被后置处理器包装成代理再放入成品池。于是B内部持有原始A,所有经B发起的调用都绕过了切面——事务、日志、权限、监控全部失效。更糟的是,这种错误静默发生,极难排查。

方案B:二级缓存直接存“实例化后立刻生成的代理”

为了保一致性,也可以规定:凡需要代理的Bean,实例化一完成就立即生成代理放入二级缓存。

  • 一致性确实保住了,但代价是全局性的:为了照顾极少数参与循环依赖的Bean,所有需要代理的Bean都被迫把“生成代理”从“初始化之后”提前到“实例化之后”;
  • 这破坏了框架的时序契约:代理生成本属于“初始化后的后置处理”环节,与其它后置处理器有明确的先后关系。将代理无限前移,意味着它必须抢在所有其它处理之前执行,包装层次与处理顺序全被打乱,第三方扩展的兼容性无从谈起;
  • 最亏的是:绝大多数Bean根本不会参与循环依赖,却要为这个罕见场景统一买单。

三级缓存的答案:把决策推迟到“不得不发生”的那一刻

方案 引用一致性 代理生成时机 对普通Bean的影响
两级·存原始对象 无AOP时成立,有AOP时静默崩坏 初始化后(正常) 无
两级·存提前代理 成立 实例化后(全局提前) 全部Bean受影响
三级缓存 恒成立 默认初始化后;仅循环依赖时提前 零影响

工厂层(三级缓存)的本质,是把“要不要提前生成代理”这个昂贵决策,从“创建Bean时”推迟到“真的有别的Bean提前要它时”:

  • A不参与循环依赖→工厂永远不被调用,代理按正常时点在初始化后生成,生命周期语义零改动;
  • A参与循环依赖→在被需要的那一瞬间调用工厂,通过“早期引用钩子”提前生成代理,结果升入二级缓存。

二级缓存则承担两个关键职责:

  1. 记忆化:保证工厂只执行一次。若没有二级缓存,多个Bean同时提前依赖A时,工厂会被反复调用,可能生成多个代理,单例语义直接崩塌;
  2. 对账标记:它记录“该Bean曾被提前曝光”,容器在Bean生命周期收尾时据此决定最终对外暴露哪个引用,确保身份唯一。

一句话总结:第三级缓存解决的从来不是“循环依赖能不能解”——没有AOP时,两级缓存足矣;它真正解决的是“循环依赖与AOP同时发生时,代理的生成时机与引用一致性”问题。这是用一层间接(工厂)换来的设计弹性。

五、回到开头的报错:一个异步注解为何能击穿三级缓存

看一个真实案例的骨架(示意代码,注解语义以中文表达,仅用于说明结构):

  1. public class OrderService {
  2. @自动注入
  3. private StockService stockService;
  4. @异步执行 // 关键:方法需要被异步代理包装
  5. public void notifyUser() { ... }
  6. }
  7. public class StockService {
  8. @自动注入
  9. private OrderService orderService;
  10. }

两个服务互相注入构成循环依赖,同时OrderService的方法上带了一个异步注解。启动直接失败,异常大意是:该Bean曾以原始版本被注入其它Bean,但最终又被包装过,导致其它Bean持有的并非最终版本。

为什么三级缓存这次没兜住?关键在于:异步代理的包装器没有参与“早期引用钩子”:

  • StockService通过工厂提前获取OrderService时,工厂询问一圈后置处理器,AOP相关的钩子参与了,但异步包装器不在参与方之列,于是曝光出去的是不含异步包装的引用;
  • OrderService初始化完成后,异步包装器才在后置处理环节把它包成代理;
  • 容器收尾对账时发现:这个Bean曾以“旧版本”曝光给别人,最终版本却又换了包装——身份不一致,于是选择快速失败,直接抛出异常。

这个报错恰恰反证了设计的严谨:与其让StockService拿着一个“看起来正常、实际绕过异步逻辑”的引用在线上静默运行,不如在启动阶段就暴露矛盾(fail fast),把排查成本压到最低。

遇到这类问题,推荐按优先级处理:

  1. 重构消除循环依赖:把互相调用的公共逻辑下沉到第三个服务,或改用事件机制解耦——唯一的根治方案;
  2. 注入点改为延迟解析:注入的不是实例而是一个“占位代理”,首次真正调用时才解析目标Bean,把循环依赖从注入期推迟到运行期;
  3. 改用依赖查找:借助工厂/Provider风格的API在需要时主动获取,从写法上消灭注入期循环。

六、写在最后:三级缓存教给我们三条设计哲学

  1. 延迟决策(惰性求值):不确定是否需要的高成本动作,先挂一个“承诺”,真正需要时再兑现。工厂层正是这一思想的教科书级实现;
  2. 默认路径零成本:不为小概率场景向所有对象征税。不参与循环依赖的Bean,其生命周期与代理时序完全不受影响,罕见场景的代价只由触发它的Bean承担;
  3. 语义完整性优先:宁可启动失败,也不产出身份不一致的静默错误。框架在“开发者犯错”与“系统正确性”之间,永远站在后者一边。

最后必须强调:容器提供的循环依赖支持,是框架的“人道主义援助”,而不是鼓励写出循环依赖的通行证。循环依赖往往意味着职责划分出了问题——能靠重构解决的,永远不要指望缓存来兜底。理解“为什么是三级”,是为了理解框架在灵活性、性能与正确性之间的精妙权衡,而不是为了理直气壮地写出互相纠缠的代码。