☰
Spring三级缓存能否砍成二级?AOP代理与循环依赖的深层解析
2026/10/15 17:16:19 网站建设 项目流程

1. 问题源头:三级缓存到底在解决什么问题

先把这个老生常谈的问题聊透:Spring的循环依赖处理机制,本质上解决的是“两个Bean互相持有对方引用”的创建时序矛盾。假设Bean A依赖Bean B,Bean B依赖Bean A,容器在初始化时严格按照“先创建A、再填充A属性、然后创建B、填充B属性”的顺序推进,那么当A填充属性时B还没创建,当B填充属性时A还没完成完整初始化,两边都等对方,死锁就出现了。

Spring给出的答案是:在Bean的生命周期里提前暴露一个“半成品”引用,让依赖方先拿到这个早期引用,等完整的初始化流程走完,再通过某种机制确保最终拿到手的对象是完整的、符合预期的。

这里有个容易混淆的细节:三级缓存并不是“为了解决循环依赖而发明”的,它原本是Bean实例化流程中一个自然的产物。singletonObjects保存最终成品,earlySingletonObjects保存提前暴露的半成品,singletonFactories保存创建半成品的工厂。三级结构天然构成了一个“优先取成品、次取半成品、最后通过工厂现场生产半成品”的查找顺序。设计者并不是为了炫技才搞出三层Map,而是因为单例Bean的生命周期本身就存在“实例化已完成、初始化未完成”的中间状态,这个状态需要有个地方临时存放。

网上很多文章喜欢把三级缓存讲成“为了解决AOP代理才不得不引入三级”,这个说法其实只对了一半。真实的逻辑顺序是:先有了三级缓存的结构,然后在循环依赖场景下,第三级缓存额外承担了“延迟生成代理对象”的职责。要真正理解“能不能砍掉一级”,必须把这两件事拆开看。

2. 为什么第三级缓存不能随便砍掉:代理对象的生成时机

先看一个典型场景。某个Service内部使用了事务注解或切面逻辑,Spring需要通过AOP生成代理对象。正常情况下,Bean完整初始化完成后,容器会在初始化阶段的最后一步生成代理并将代理放入singletonObjects。这没问题,普通依赖注入拿到的都是代理。

但循环依赖场景下情况变了。A需要注入B,B需要注入A,而A是需要被代理的。容器先实例化A的原始对象,然后A填充属性时发现需要B,于是去创建B。B填充属性时发现需要A,此时从缓存里找A——如果只有两级缓存(一级存成品,二级存半成品),A还没有走完初始化,不可能出现在一级缓存里,那就只能从二级缓存里拿出A的原始对象交给B。B拿到的就是A的原始对象,而不是最终要用的代理对象。

问题来了:A真正走完初始化并生成代理之后,B持有了一个“过期的原始引用”。前面说过,A的原始对象在填充B时已经被注入给B了,后续A容器里的引用已经替换成了代理,但B手里的引用指向的还是那个老对象。这带来的后果不只是多了一层无用的转发,更严重的是事务注解、切面逻辑在B调用A时完全失效——B调用的方法绕过了代理,事务管理器根本感知不到调用。

三级缓存是怎么化解这个矛盾的?关键在于singletonFactories里存的是ObjectFactory,一个延迟执行的工厂。B需要A时,容器不是直接给原始对象,而是执行这个工厂,工厂内部会调用getEarlyBeanReference方法。这个方法会遍历所有SmartInstantiationAwareBeanPostProcessor,让它们有机会对原始对象做“提前处理”。Spring AOP的AbstractAutoProxyCreator正好实现了这个接口,它在这里会检查当前Bean是否命中切面规则,如果命中了,就直接生成代理对象返回。这个提前生成的代理被放进二级缓存earlySingletonObjects,B拿到的就是代理。

等A走完完整的初始化流程,容器会在初始化末尾再次检查是否需要代理。此时发现二级缓存里已经有过提前生成的代理了,就会直接复用,不会重复生成。最终一级缓存里存的是这个代理,B持有的也是这个代理,两边引用一致,事务和切面都能正常工作。

所以第三级缓存的本质价值是:把代理对象的创建时机从“初始化完成后”提前到“首次被循环依赖引用时”,并且保证整个容器里同一个Bean只有一份代理对象。砍掉第三级,等于放弃了这种延迟决策能力,要么把代理创建强制提前,要么接受原始对象泄露的后果。

3. 第三级缓存对无代理场景的影响:是不是白设计了

这里要深入一层。如果整个应用里没有任何AOP、没有事务、没有切面,第三级缓存是不是就多余了?答案从结果上看确实如此——getEarlyBeanReference遍历后处理器时什么都不会做,直接返回原始对象,跟二级缓存直接存原始对象没有本质区别。这也是“可以减少为二级缓存”这个观点最有说服力的论据。

但注意一个关键细节:Bean是不是需要代理,容器在早期阶段其实“不知道”。切面匹配规则通常基于类和方法上的注解、方法签名、类名模式等,理论上在实例化完成后就能判断,但Spring为了避免过早地触发代理创建,刻意把决策时机推后了。为什么?因为代理创建本身是有成本的——要生成新的字节码、构建ProxyFactory、解析切面链,如果在每个Bean实例化阶段都去判断一次是否需要代理,即使不需要代理的Bean也要白白做一遍匹配检查,整个启动耗时会被明显拉长。

Spring的取舍是:默认情况下不提前判断,把判断留给两个时机。一个是Bean初始化完成后的标准流程,每个Bean都会走一遍后处理器,决定是否创建代理;另一个是循环依赖场景下,三级缓存的工厂被触发时,通过getEarlyBeanReference做一次“应急判断”。后者的触发条件极为苛刻——只有当Bean确实处于循环依赖中、且确实被其他Bean提前引用时才会执行。

也就是说,第三级缓存承担的不是普通的代理生成职责,而是“仅针对循环依赖中的Bean,在必要时提前生成代理”的应急通道。这个设计保证了99%的非循环依赖Bean不会因为代理判断而付出额外性能代价,同时让那1%的循环依赖Bean能够拿到正确的代理。这种“按需触发”的思路,比“把所有Bean的代理判断都提前”要精细得多。

如果把三级缓存减为二级缓存,想保持功能的完整,只有一个办法:把所有Bean的代理判断统一提前到实例化阶段。这样一来,每个Bean从创建到放入缓存,就已经确定了是不是代理、代理长什么样。听起来简单,但代价很大——每个Bean都要提前执行切面匹配和可能的代理创建,启动性能明显下降;更麻烦的是,某些代理创建依赖Bean的完整属性状态,属性还没填充完就生成代理,代理内部拿到的依赖可能还是空的,行为可能失真。

这是一个典型的“用全局性能代价换取局部数据结构简化”的交换,Spring的官方选择是保留三级缓存,宁可多一层Map和一次工厂调用,也不愿意在启动路径上增加全局代理判断的开销。

4. 如果把三级缓存改成二级缓存的实操实验

单说理论不够过瘾,我实际动手改过一个简化版的测试环境,专门验证“二级缓存方案”在哪些场景下能跑通、哪些场景下会翻车。

先交代验证思路:不改动Spring源码主流程,而是写一个模拟容器,只实现单例Bean的实例化、依赖注入、循环依赖检测和缓存查找四个核心环节,用自定义注解模拟事务代理的生成。

第一版直接按照两级结构实现:Map1保存成品Bean,Map2保存半成品Bean。循环依赖发生时,B直接从Map2拿到A的原始引用。测试结果很直接:完全不需要AOP时,B注入A后A的方法调用正常,日志输出符合预期,两个Bean都能正常初始化。这一步验证了一个基本结论——无代理场景下二级缓存确实够用。

第二版加入模拟的事务代理。A类上标注@MockTransactional,容器初始化结束后,A的成品会被替换为代理对象。但B在循环依赖阶段拿到的还是原始对象。我在B的方法里调了一次A的“事务方法”,通过日志观察是否经过代理拦截器,结果代理拦截逻辑没有被触发。这就复现了前面提到的原始对象泄露问题。

第三版尝试一种补救思路:在二级缓存里保存的半成品不是原始对象,而是“占位代理引用”——先创建一个空的代理对象框架,等A完整初始化后再把真实目标填充进去。这个方案确实能保证B拿到的也是代理,但带来了两个新问题。一是Bean实例化阶段的顺序约束被打破,代理目标还没准备好就被外部引用,代理内部状态跟容器最终状态可能不一致;二是重复代理问题变得棘手,容器初始化完成后还要再次检查二级缓存里是否已经有代理,避免再生成一遍,这又额外引入了缓存替换和状态同步的逻辑。

实验做到这里,可以直观地感受到一个事实:二级缓存方案不是“不能实现”,而是“每解决一个问题,就要在旁边补一个新的边缘逻辑”,补着补着,复杂度又悄悄涨回来了。Spring的三级缓存看着多了一层,反而让各层职责更纯粹:一级管成品、二级管提前暴露的最终确定对象、三级管“还没确定但可能被提前要”的候选工厂。

5. 各项方案对比与适用边界

方案结构设计是否能处理循环依赖AOP代理是否正确启动性能开销复杂程度适用场景
三级缓存(Spring现状)成品/半成品/工厂三层可以正确,代理延迟生成且全局唯一低,代理判断按需触发中等,三层职责清晰任意场景,尤其是事务+循环依赖并存的复杂项目
二级缓存-直接存原始对象成品/半成品两层可以不正确,B拿到原始对象,代理逻辑失效低最低完全无AOP的纯POJO项目
二级缓存-提前生成代理成品/半成品两层可以正确高,所有Bean都要提前做代理匹配高,需要额外处理状态一致性少量Bean且代理逻辑简单的场景
二级缓存-占位代理引用成品/半成品两层可以部分正确,存在状态同步风险中高,边界情况多不推荐

表格里最后一行可能最有参考价值。单独看“三级缓存能否减少为二级缓存”,答案不是一刀切的“能”或“不能”,而是要追问三个问题:项目里有没有AOP或者事务?循环依赖涉及的Bean是否会被代理?能否接受启动性能的全局开销?

如果三个问题的答案分别是“没有”“不涉及”“能接受”,那么二级缓存确实够用。但现实是:稍微有点规模的项目,事务注解几乎无处不在,AOP切面要么在用要么迟早会用,为了省一层Map去承担代理错乱的风险,性价比很低。

有意思的是,即便Spring官方后来引入了@Lazy注解来规避循环依赖,也没有动三级缓存的结构。这其实透露了一个设计取向:循环依赖本身是代码设计层面的坏味道,缓存结构只是兜底机制,重要的不是把兜底机制做得更精简,而是保证它足够可靠。

6. 面试追问中最容易踩的坑

这个话题经常出现在技术面试里,而且追问的深度往往比表面问题要狠。整理几个我踩过、也看别人踩过的坑。

第一个坑是把“三级缓存”和“二级缓存”的关系理解成“数据结构冗余”。面试官问能不能从三级减到二级,很多人的第一反应是“当然可以,第三级不就是个工厂嘛,直接把工厂的执行结果放二级不就完了”。这个说法忽略了代理生成的时机问题。第三级缓存存的不是代理,而是“生成代理的决策能力”,这个能力在Bean完整初始化之前是无法完全确定答案的。工厂的存在意义就是把这个决策延迟到真正需要的时候。

第二个坑是搞混“早期引用”和“最终代理”。有次跟同行讨论,对方坚持认为“反正最后一级缓存里会替换成代理,B拿到原始引用也没关系”。这种想法忽略了Bean之间的引用是对象层面的直接持有,不会随容器缓存的替换而自动更新。容器里A的引用从原始对象变成代理,跟B手里的A引用是两码事。很多人被“Spring单例缓存”这个概念误导,以为改了缓存就等于改了所有人的引用,实际上引用传递发生在依赖注入那一刻,之后就固定了。

第三个坑是忽略@Lazy方案的存在。Spring其实给了另一个解决循环依赖的思路:在注入点加@Lazy,让依赖方拿到的是一个代理占位符,真正调用时才去容器里解析目标Bean。这个方案能彻底绕开循环依赖的提前暴露问题,而且对AOP是天然友好的。但它会改变Bean的创建时机,延迟了依赖的初始化,可能掩盖掉一些真实的初始化顺序问题。面试里提到这个对比,会比单纯背“三级缓存是Spring的设计智慧”显得更有分寸感。

第四个坑是拿“构造器注入的循环依赖无法解决”来否定整个三级缓存机制。构造器注入的循环依赖确实救不了,因为构造器阶段连对象实例都没创建出来,三个缓存都是空的,没有任何层次能提供“半成品”。但这不能说明三级缓存“没用”,恰恰说明循环依赖的解决有边界:它只能处理“实例化已完成、属性未填充”的中间状态。这个边界才是理解整个机制的钥匙。

7. 从源码层面再确认一下:三级缓存的实际协作路径

最后从源码路径上把整条链路再捋一遍。这段是我反复调试后确定的调用顺序,建议读者打开源码对照着看,会比只看结论理解得更深。

Bean创建入口是AbstractBeanFactory.doGetBean,首次获取时调用getSingleton(beanName)。一级缓存里没有,且当前Bean正在创建中(singletonsCurrentlyInCreation集合里存在),才会进入三级缓存的处理逻辑。

实例化完成后,Spring调用addSingletonFactory方法,把ObjectFactory注册进singletonFactories。这个工厂内部的核心逻辑是getEarlyBeanReference。addSingletonFactory是三级缓存区别于二级缓存的分水岭——它不是把对象放进去,而是把“对象工厂”放进去,工厂的执行时机被完全推迟。

当另一个Bean在填充属性时需要引用当前Bean,容器会调用getSingleton(beanName, true)。这个方法的处理顺序很讲究:先查一级缓存,命中直接返回;再查二级缓存,命中直接返回;最后从三级缓存里取出工厂,执行getObject(),得到结果后放入二级缓存,同时从三级缓存里移除。注意,三级缓存是“用一次就删”的,同一个Bean不会重复执行工厂。

B拿到A的早期引用后,如果这个引用是通过工厂生成的代理,那B持有的就是代理对象。A继续走属性填充和初始化,初始化完成后进入getSingleton的后置处理阶段,容器会检查earlySingletonObjects里是否已经存在当前Bean——如果存在,说明循环依赖阶段已经生成过代理,直接复用;如果不存在,走正常流程生成代理。

这条链路最关键的一个细节是:代理对象在“提前暴露”和“完整初始化”两个路径上,都通过earlySingletonObjects做了去重,保证同一个Bean最终只存在一份代理对象。如果去掉第三级,工厂的延迟能力没了,代理生成只能提前到实例化阶段,但实例化阶段的Bean依赖还没填充完,代理内部引用的依赖可能是空的,这就打破了Bean初始化的时序约定。

我自己的理解是,第三级缓存真正宝贵的地方不在于“多了一个Map”,而在于它等价于在Bean生命周期中插入了一个“延迟决策点”——这个决策点让容器可以在不当场创建代理的前提下,保留“有人要就当场创建”的能力。这是一种很典型的以空间换时间、以结构换灵活性的设计,砍掉之后短期内看不出问题,但只要你的项目里出现一个“AOP + 循环依赖”的组合场景,问题马上就暴露出来。

所以给一个最终结论:如果你的项目纯POJO、无任何代理逻辑,二级缓存是一个可行的简化方案,这也是我这个实验里唯一完全跑通的场景。但如果你在用Spring Boot、Spring Cloud、或者任何涉及事务和切面的技术栈,建议不要动这个结构——为了省一个Map的代码量,去赌全局代理逻辑永远不参与循环依赖,这一注的风险系数太高了。实际工作中,与其想着重构Spring的缓存结构,不如先把循环依赖从代码里清理干净,让三级缓存仅仅作为一个安全兜底存在,这才是它最开始的设计定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询