尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
深入理解Spring三级缓存:循环依赖的机制、局限与排查实践
1. 循环依赖是什么以及你为什么会碰到它先聊个场景。有一次我在排查一个偶发启动失败的问题某个服务明明本地跑得好好的一上测试环境就报BeanCurrentlyInCreationException。日志堆栈指来指去最后定位到就是两个 Service 互相 new 了对方形成了循环依赖。当时第一反应是这怎么还能跑起来后面仔细追了一遍 Spring 的创建流程才彻底把三级缓存这层窗户纸捅破。所谓循环依赖用大白话讲就是BeanA在创建过程中需要注入BeanB而BeanB在创建过程中又反过来需要注入BeanA。如果容器不做任何处理这个创建工作会无限递归下去直到栈溢出或者 Spring 主动抛异常终止。Service public class AService { private final BService bService; public AService(BService bService) { this.bService bService; } } Service public class BService { private final AService aService; public BService(AService aService) { this.aService aService; } }上面这段代码如果你直接丢到 Spring 容器里启动大概率会报错。原因后面我会详细讲——构造器注入的循环依赖是 Spring 默认解决不了的。但在实际业务项目里更常见的是另一种写法Service public class OrderService { Autowired private UserService userService; } Service public class UserService { Autowired private OrderService orderService; }这种字段注入的写法Spring 默认是可以帮你兜底的。它能兜底靠的就是三级缓存机制。这篇文章就围绕这个机制展开把为什么能解决、什么情况解决不了、遇到的坑怎么排查一次说清楚。2. Spring 三级缓存到底在缓存什么2.1 三级缓存的名字和各自分工Spring 单例 Bean 的创建过程说白了是一个先实例化、再填充属性、最后初始化的三段式流程。实例化是调用构造器创建出对象填充属性是把Autowired、XML 里配置的依赖逐个 set 进去初始化是执行afterPropertiesSet、PostConstruct等回调。三级缓存就是围绕这个流程设计的三个 Map。先看它们在DefaultSingletonBeanRegistry里的定义/** 一级缓存成品 Bean 的容器 */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存提前暴露的早期 Bean 引用 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存存放 ObjectFactory可以生成早期 Bean 引用 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);简单说一级缓存singletonObjects存放已经完整走完生命周期的单例 Bean这是大家最终拿到的对象。二级缓存earlySingletonObjects存放半成品Bean即对象已经 new 出来了但属性还没填充完或者还没执行初始化回调。三级缓存singletonFactories存放一个工厂接口ObjectFactory通过它可以在需要的时候提前生成早期引用。这三层缓存的关系网上最多的形容是渐进式升级。对象先注册到三级缓存如果被循环依赖提前取走就升级到二级缓存等整个 Bean 创建完毕再升级到一级缓存。只要品完整个流程你就能理解为什么getSingleton方法要一层一层地查。2.2 一个正常 Bean 的创建过程缓存怎么流转咱们用最经典的场景举例AService依赖BServiceBService也依赖AService都是单例都是字段注入。当 Spring 开始创建AService时会发生这些事第一步doCreateBean里通过构造器反射创建了AService的原始对象。注意这个对象此刻只是个空壳里面的bService还是 null。第二步在属性填充之前Spring 调用了addSingletonFactory方法把AService的ObjectFactory放进了三级缓存。这一步极其关键等于提前告诉容器AService已经有了原始对象谁要用可以先用着。第三步Spring 开始填充AService的属性发现需要一个BService。于是调用getSingleton(bService)一级缓存没有就转向二级缓存还是没有于是开始创建BService。第四步创建BService同样走上述流程实例化后把它的ObjectFactory放进三级缓存然后填充属性。这时候发现需要一个AService于是getSingleton(aService)。一级缓存没有二级缓存没有但是三级缓存里找到了AService的ObjectFactory。调用它的getObject()方法拿到AService的早期引用。这个引用同时被放进了二级缓存三级缓存里的工厂就被移除了。第五步BService拿到了AService的早期引用完成属性填充、初始化和后续的 BeanPostProcessor 处理整个创建完成被放进一级缓存。第六步控制权回到AService的创建流程。它从getSingleton(bService)中拿到的是已经创建完成的BService填充进来然后执行自己的初始化回调最终也进入一级缓存。整个过程就是你等我、我等你但因为 Spring 允许在半成品阶段就把对象引用抛出来这个循环就被打破了。核心就是先放行引用后补全属性。2.3 为什么需要三级缓存二级不行吗很多人第一次看完三级缓存都会问既然二级缓存存早期引用就能解决循环依赖那为什么还要三级缓存这个问题得结合 Spring 的 AOP 代理机制来回答。还是用上面的例子假设AService被 AOP 切面代理了。在创建过程中AOP 代理是在BeanPostProcessor的postProcessAfterInitialization阶段生成的也就是 Bean 初始化之后。如果在三级缓存里直接存AService的原始对象只有二级缓存那么BService拿到的就是原始对象而不是代理对象。等AService初始化完Spring 生成代理对象放进一级缓存问题就来了BService持有的是原始引用容器里存的是代理引用同一个名字的 Bean 出现了两份不同的对象。这种场景下如果AService上有事务方法通过BService调用时事务会失效因为实际执行的是原始对象而不是代理对象。Spring 用三级缓存的目的就是为了在BeanPostProcessor介入之前给SmartInstantiationAwareBeanPostProcessor一个机会去生成提前代理。protected Object getEarlyBeanReference(Object bean, String beanName) { Object exposedObject bean; for (BeanPostProcessor processor : this.beanPostProcessors) { if (processor instanceof SmartInstantiationAwareBeanPostProcessor) { exposedObject ((SmartInstantiationAwareBeanPostProcessor) processor) .getEarlyBeanReference(exposedObject, beanName); } } return exposedObject; }这段逻辑就在AbstractAutoProxyCreator里如果发现有循环依赖并且当前 Bean 匹配切面规则就会在这里提前创建代理对象。所以三级缓存里存的是ObjectFactory而不是普通对象——直到真正发生循环依赖时才调用工厂判断是否需要提前代理。没有循环依赖时这个工厂不会被调用也就不会白白生成代理保证了正常流程下的性能。这也是为什么三级缓存是设计上的必然选择的答案二级缓存做不到按需提前代理它只有结果三级缓存保留了延迟决策的能力。3. 哪些场景下 Spring 也救不了循环依赖3.1 构造器注入的循环依赖为什么必挂前面提到构造器注入的循环依赖是死局。原因其实一句话就能说明白构造器注入发生在实例化阶段此时对象还没有出生三级缓存里根本还没有这个 Bean 的工厂。你想给 A 构造器传入 B必须先把 B 创建出来而 B 的构造器反过来需要 A可 A 还在构造中没有任何缓存可查只能继续递归。实际报错长这样The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | aService ↑ ↓ | bService └─────┘Spring 在启动阶段会做依赖分析isConfigurationCycle会提前发现这种构造器循环并拒绝启动。这是一件好事因为这种循环本身暴露了设计问题——两个对象强耦合到连创建顺序都无法独立完成后面维护会非常痛苦。3.2 原型作用域的循环依赖为什么必挂单例 Bean 的循环依赖能靠缓存解决是因为整个容器生命周期内这个 Bean 只有一份。原型Scope(prototype)每次获取都创建新对象缓存没有意义。Component Scope(prototype) public class PrototypeA { Autowired private PrototypeB b; } Component Scope(prototype) public class PrototypeB { Autowired private PrototypeA a; }这种配置启动时不会立刻报错因为原型 Bean 默认不预创建。但一旦某个单例 Bean 注入它、触发创建流程就会直接抛BeanCurrentlyInCreationException。原型作用域没有缓存体系不存在提前暴露一说无限递归到栈溢出之前就被 Spring 拦住了。在实践中我几乎不会在原型 Bean 里设计互相依赖的字段。原型 Bean 之间如果需要协作建议通过方法参数传递或者把公共依赖抽出去作为单例。3.3 Async、Transactional 等代理类场景的坑AOP 回调本身已经够复杂叠加循环依赖时更棘手。最典型的就是带Async的 Bean 发生循环依赖。Async的代理生成时机比较特殊它依赖AsyncAnnotationBeanPostProcessor。如果你有个 Bean同时被多个 Bean 循环依赖而且这个 Bean 自身还带Async方法字段注入模式下 Spring 会用三级缓存提前生成代理。这能解决循环依赖但副作用是代理生成时机提前到了初始化之前。此时PostConstruct还没执行某些基于InitializingBean的资源初始化可能还没完成。如果提前代理的对象在初始化方法里访问了未就绪的依赖就会拿到 null。另一个坑是提前代理后后续 BeanPostProcessor 不会再对这个 Bean 做额外包装某些只在完全初始化后生效的增强逻辑也会错失。我实际调试过一个项目Async方法偶尔不生效查到最后就是循环依赖提前代理导致顺序乱了。对这种场景最干净的做法是打破循环依赖而不是指望三级缓存兜底。用表格总结一下各类场景的结局场景能否解决原因单例 字段/setter 注入能解决三级缓存提前暴露原始引用单例 构造器注入不能解决实例化阶段无缓存可用原型 任意注入不能解决无缓存体系单例 字段注入 AOP代理能解决但有前提需要SmartInstantiationAwareBeanPostProcessor提前代理单例 字段注入 Async表面解决但隐患多代理时机提前初始化顺序受影响4. 项目里的排查思路和最佳实践4.1 报错信息应该怎么读循环依赖相关的报错最容易迷惑人的地方在于Spring 输出的堆栈往往很长直接飘到很深的地方一眼看去不知道是谁依赖了谁。我习惯的做法是只看BeanCurrentlyInCreationException附近的核心提示也就是Currently in creation这一段。org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name aService: Requested bean is currently in creation: Is there an unresolvable circular reference?它只是告诉你 A 正在创建过程中又被请求了。真正的依赖链条要看下一段The dependencies of some of the beans...部分Spring 会画出一个干净的依赖图。先读图再定位代码比翻堆栈快得多。如果项目用了 Lombok 的RequiredArgsConstructor配合Service很容易写出构造器注入的循环依赖因为它默认把final字段都收进构造器。我见过好几个团队代码 review 时没人发现问题一启动就炸。排查时先全局搜一下有没有互相依赖的构造器参数比追堆栈更高效。4.2 打破循环依赖的几个具体方案打破循环依赖没有银弹核心原则是消灭双向依赖或者把依赖关系降级为间接依赖。我按优先级排一下在实际项目中验证过的做法。方案一重构把双向依赖改成单向依赖。最常见的模式是抽出一个新的Service或者Component把两边的公共逻辑下沉。比如AService需要BService的数据BService也需要AService的数据与其互相持有不如抽出CommonService统一管理这两块数据。Service public class CommonService { // 公共数据访问逻辑 }然后AService只依赖CommonServiceBService也只依赖CommonService。方向从环形变成星形循环自然消失。这种方式改动量稍大但最健康值得做。方案二用Lazy打破循环。这是最快的救火手段用一个简单的注解就能让 Spring 延迟注入依赖从而避免创建时的循环等待。需要提醒的是Lazy注入的是一个代理对象真正的方法调用发生在业务运行时。这种行为对透明性的伤害比较大如果依赖在事务方法里被调用代理不会自动开启新事务很容易出现事务不生效的问题。我一般只把它用作临时方案。Service public class AService { private final BService bService; public AService(Lazy BService bService) { this.bService bService; } }方案三用ObjectProvider延迟获取。这个方案比较实用把创建时依赖变成使用时依赖。AService不再持有BService实例而是持有ObjectProviderBService等真正需要的时候再去拿。Service public class AService { private final ObjectProviderBService bServiceProvider; public AService(ObjectProviderBService bServiceProvider) { this.bServiceProvider bServiceProvider; } public void doSomething() { BService b bServiceProvider.getIfAvailable(); // 业务逻辑 } }方案四用Autowired加 setter 注入替代构造器注入。这是最小改动方案可以让 Spring 的缓存机制帮你兜底。但我不主张主动制造这种代码因为在代码审查阶段循环依赖本身就是一个需要被消除的坏味道。setter 注入只是技术上的可行解不代表设计上的合理解。4.3 在代码评审阶段提前拦截循环依赖循环依赖真正的成本不在启动时而在它隐藏的耦合度两个本应独立的组件被迫同生共死改一个的初始化逻辑可能影响另一个。所以我更建议在开发流程里前置拦截。一个用到比较多的小技巧是在测试类里写一个简单的ApplicationContext启动用例让它提前暴露循环依赖问题。SpringBootTest class CircularDependencyDetectionTest { Test void contextLoads() { // 如果存在构造器循环依赖这里会直接失败 } }这个用例的价值在持续集成里尤为明显。任何成员在合并代码前都会自动触发启动校验而不是等部署到环境后才发现问题。同理也可以用WebMvcTest等切片测试做局部校验。另外我个人的经验是在项目里约定优先构造器注入。虽然构造器注入会暴露循环依赖问题但这正是它的优势——让问题在启动期就暴露并且方向上强制你写更干净的代码。用字段注入掩盖问题其实是把设计债一直背下去。5. 实际排查实录一个 Async 引发的循环依赖疑难杂症5.1 现象描述偶发控制流异常之前在一个交易系统里遇到一个问题。接口偶发超时最后定位到一个内部服务类的异步方法没有真正异步执行而是同步阻塞了。盯着日志看半天发现异常只出现在某些实例上其他实例正常。顺着调用链查发现AsyncService被两个 Service 循环依赖了。OrderService注入了AsyncServiceAsyncService又注入了InventoryService而InventoryService反过头来注入了OrderService。于是AsyncService在创建过程中被提前暴露Async的代理生成时机被提前到 Bean 初始化前。这直接导致AsyncService里的Async方法没有走AsyncAnnotationBeanPostProcessor创建的代理链路而是被三级缓存提前代理了。由于Async的处理器和 AOP 代理的处理器执行顺序不一样提前生成的代理对象不包含异步拦截器。5.2 定位过程从现象反推创建顺序我的排查步骤是第一步打开debug日志找到 Bean 创建的顺序输出确认三个 Bean 的创建轨迹。这一步能看到getEarlyBeanReference是否被调用是确认是否走了提前代理的关键日志。第二步在每个候选 Bean 的构造器和PostConstruct方法里临时打日志观察执行顺序。如果PostConstruct在代理生成之后才执行说明这个 Bean 就是被提前处理的对象。第三步把依赖关系画下来去掉中间那层InventoryService只保留OrderService和AsyncService的依赖图确认循环路径。排查结果证实了我的猜测AsyncService确实被提前代理了。为了快速恢复线上可用我临时采用了ObjectProvider方案把直接注入改成延迟获取让AsyncService不再参与早期的循环创建。稳定之后又对代码做了进一步重构抽出了两个 Service 依赖的公共组件最终彻底消除了这段环。5.3 这个案例沉淀下来的避坑经验这个案例给我的教训可以浓缩成下面几条之后排查同类问题我都照着这个思路走第一看到Async不生效不要第一反应去查配置。先看一下有没有循环依赖再考虑代理顺序。Async实际上是初始化后代理的典型场景它和循环依赖的兼容性天生就差。第二日志里出现getEarlyBeanReference就要警惕。它代表这个 Bean 走过了循环依赖链路当前拿到的可能是半成品代理。如果你在一个复杂系统里看到大量 Bean 都走了这个路径说明整个项目的依赖设计已经处于非常脆弱的边缘。第三无论字段注入能否解决循环依赖都不建议依赖它。缓存机制是兜底用的不是设计标准。一个项目循环依赖越多生命周期越混乱越是到后期就越难维护。我在多次处理过循环依赖问题后最深的感觉是Spring 三级缓存设计得再精巧也只是个保险丝熔断报警了你应该去修电路而不是把保险丝换粗了继续跑。最近在几个项目里试过用静态代码扫描工具在提交前检查循环依赖点效果确实比人工 review 稳定得多。相比出了问题再拿着堆栈满世界找日志你更值得拥有一个在代码合并前就亮红灯的检查机制——那才是真正省下救火时间的做法。
RELATED

相关推荐

用编程思维打造个人知识体系:IoC、依赖注入与响应式学习实操指南

用编程思维打造个人知识体系:IoC、依赖注入与响应式学习实操指南

我前两年一度陷入一个很常见的循环:买了不少课、收藏了一堆文章、笔记记了好几本,可三个月后发现,真正能讲清楚、能上手用起来的,其实没几个。后来想明白一个问题——我不是懒,也不是笨,而是把学习做成了“…

📅 2026/10/10 7:39:32
联邦学习赋能大模型WAF:破解数据孤岛与攻击变异难题

联邦学习赋能大模型WAF:破解数据孤岛与攻击变异难题

1. 项目概述:当大模型撞上WAF,不是堆算力,而是重新定义“看见攻击”的方式最近在某高校实验室做安全方向的模拟项目X时,团队里一位做NLP的同事随手把一份Web攻击日志喂给刚微调好的小规模语言模型,结果模型不仅标出了S…

📅 2026/10/10 7:34:32
2026版Java架构师面试指南拆解:考点逻辑与12周备战策略

2026版Java架构师面试指南拆解:考点逻辑与12周备战策略

最近技术群里被一份文档刷了屏:某头部互联网公司2026版Java架构师面试参考指南全网首次公开。我陆陆续续翻了不下五遍,也看着不少朋友把它存进网盘,转头又去焦虑“到底背不背得完”。作为常年接触架构师面试的从业者,我想给一句直…

📅 2026/10/10 7:34:31
MORE NEWS

更多资讯

📰

风储VSG并网Simulink仿真:虚拟同步发电机建模与参数整定详解

整套风储并网系统搭下来之前,我一直以为“并网仿真难在电力电子器件选型和拓扑上”,直到把虚拟同步发电机控制加进去,才发现真正的核心其实是“怎么让那台没有转子的逆变器,表现得像一台有转子的同步发电机”。最近终于完成了这套…

📰

Pulse v6 自托管商业 GA 一致性:付费运行时分发与 Docker 镜像策略演进记录

可观测性运维后端 【免费下载链接】Pulse Real-time monitoring dashboard for Proxmox VE, PBS, Docker, Kubernetes, TrueNAS and vSphere. Self-hosted, with smart alerts and AI patrols that catch silent failures. 项目地址: https://gitcode.com/gh_mirror…

📰

算法通关手册题解:LeetCode 0801「使序列递增的最小交换次数」双数组交换的二维状态动态规划

教程文档知识库 【免费下载链接】AlgoNote ⛽️「算法通关手册」:从零开始的「算法与数据结构」学习教程,200 道「算法面试热门题目」,1000 道「LeetCode 题目解析」,持续更新中! 项目地址: https://gitcod…

📰

ts-morph 对象字面量表达式(Object Literal Expressions)操作指南:属性、访问器与方法的新增与删除

开发工具 【免费下载链接】ts-morph TypeScript Compiler API wrapper for static analysis and programmatic code changes. 项目地址: https://gitcode.com/gh_mirrors/ts/ts-morph 点击查看 免费下载 导读 对象字面量(Object Literal)是…

📰

三菱FX2N PLC与组态王洗衣机控制系统开发实践

做自动化项目这么多年,经常有人问我:想入门PLC和上位机监控,第一步该做什么项目?我一般会推荐做一个带完整流程控制的设备,而洗衣机控制系统恰好是最合适的那种——它规模不大,逻辑却不简单,正好…

📰

为 @pierre/diffs 注册自定义 Shiki 语言与主题:完整实战指南

【免费下载链接】pierre pierre’s open source code 项目地址: https://gitcode.com/gh_mirrors/pi/pierre 点击查看 免费下载 pierre/diffs 是 pierre 仓库中负责文件、Diff、补丁与 CodeView 评审界面渲染的代码包,其语法高亮基于 Shiki 引擎。当内置…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬