ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring循环依赖深度解析:三级缓存原理与实战排查

2026/10/3 15:14:52 拓冰建站 浏览量
Spring循环依赖深度解析:三级缓存原理与实战排查 Spring循环依赖这个话题几乎每个面过Java岗的人都被问到过项目里只要稍微复杂一点Bean之间互相引用的场景就避免不了。很多文章喜欢一上来就丢出“三级缓存”四个字但真正落地时很多人仍然搞不清楚为什么要三级、二级行不行、什么时候Spring救不了你。这篇文章我把整个链路拆开讲包括调用过程、源码级别的关键代码、以及我在实际项目里排查循环依赖的一些心得希望能帮你把这块啃透。先说清楚这篇内容适合谁准备面试、想系统理解Spring Bean生命周期的同学以及在生产环境遇到过BeanCurrentlyInCreationException但不知道怎么排查的开发者。1. 循环依赖到底是什么鬼1.1 从一个最经典的报错场景说起先看一段最常见的代码。两个Service互相注入Service public class OrderService { private final InventoryService inventoryService; Autowired public OrderService(InventoryService inventoryService) { this.inventoryService inventoryService; } } Service public class InventoryService { private final OrderService orderService; Autowired public InventoryService(OrderService orderService) { this.orderService orderService; } }如果你用的是Spring Boot 2.6及以上版本启动大概率直接失败控制台会打出一串报错关键信息是The dependencies of some of the beans in the application context form a cycle老版本Spring Boot2.6之前通常报的是BeanCurrentlyInCreationException: Error creating bean with name orderService: Requested bean is currently in creation: Is there an unresolvable circular reference?这段报错的意思是Spring容器正在创建orderService但这个Bean已经在创建过程中了。换句话说A依赖BB依赖A两边都在等对方先完成结果谁也没法先完成形成死锁。这里有个容易被忽略的细节上面例子用的是构造器注入所以必然失败。如果改成Setter注入或者字段注入Autowired直接打在字段上同样的循环依赖在SpringBoot 2.6之前的默认配置下是能正常启动的。为什么会有这种差异我们先放到第三章讲这里先记住结论。1.2 循环依赖的几种常见形态循环依赖不一定是A和B直接互相引用更多时候是间接形成了一条环。常见的形态有三种直接循环A依赖BB依赖A。间接循环A依赖BB依赖CC又依赖A。这种链路越长越隐蔽排查起来也更麻烦因为如果不看完整依赖图很难发现关环的是谁。自我循环一个Bean的Setter注入依赖自己。少见但确实有人这么写。无论哪种形态本质上都是把“创建顺序”变成了一道无解的环。Spring解决循环依赖的核心思路其实不复杂允许某个Bean在尚未完全创建完成时先把一个“早期引用”暴露出去让依赖它的其他Bean先把这个引用拿到手等整体创建流程走完再用完整的Bean去替换掉早期引用。这个思路在日常生活中也有类似的例子。比如装修房子时你先让电工进场布线但水泥工可能等不到电工全完工就得进场砌墙。电工没法提前交房只能先把“这个位置会有一个插座”的口头承诺给水泥工水泥工按计划施工等电工干完再最终验收。Spring里的“口头承诺”就是提前暴露的早期引用。2. 三级缓存到底是怎么解决循环依赖的2.1 三个缓存各管一段循环依赖的解决方案落在DefaultSingletonBeanRegistry这个类里。这个类里有三个Map又被称为“三级缓存”它们各司其职缓存名称存放内容写入时机移除时机一级缓存singletonObjects完整的单例Bean成品Bean初始化完成后容器销毁时二级缓存earlySingletonObjects提前暴露的半成品Bean早期引用从三级缓存中的工厂拿到引用后Bean初始化完成、并入一级缓存时三级缓存singletonFactoriesObjectFactory工厂对象能生成早期引用Bean刚实例化、尚未填充属性时工厂被调用生成早期引用后用一句话概括一级缓存存成品二级缓存存半成品三级缓存存工厂。值得强调的是这三个Map在Spring源码里的定义是/** Cache of singleton objects: bean name to bean instance. */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** Cache of singleton factories: bean name to ObjectFactory. */ private final MapString, ObjectFactory? singletonFactories new HashMap(16); /** Cache of early singleton objects: bean name to bean instance. */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16);注意Spring源码中这是非代码块的Java代码片段我摘录的是真实定义但在阅读时你只需要知道三级缓存里放的不是Bean实例而是一个ObjectFactory。这个设计是整个方案的关键后面会专门展开。2.2 从getBean到提前暴露的完整链路要真正理解三级缓存怎么工作我们必须追踪一条完整的Bean创建链路。假设A和B互相依赖两者都是单例且都用字段注入创建流程大致是这样的第一步容器启动遍历所有Bean定义准备创建orderService。getSingleton(orderService)去一级缓存里找没找到。于是进入创建流程实例化OrderService得到这个对象——注意此时它只是一个裸对象字段都还是null还没有执行属性填充。第二步关键操作来了Spring把这个裸对象包装成一个ObjectFactory放进三级缓存singletonFactories。这个工厂的真正逻辑在源码里长这样protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) { exposedObject bp.getEarlyBeanReference(exposedObject, beanName); } } return exposedObject; }这段代码我们暂时不深挖先记住一个结论三级缓存里的工厂在真正被调用之前什么都不做。它可以被理解为一份“待执行的保单”。第三步继续填充orderService的属性发现它依赖inventoryService。Spring于是调用getBean(inventoryService)。一级缓存里没有inventoryServiceSpring同样去实例化它、把它也放进三级缓存、然后开始填充它的属性。第四步填充inventoryService的属性时发现它依赖orderService。于是Spring再次调用getSingleton(orderService, true)。这时候神奇的事情发生了一级缓存里没有orderService但三级缓存里有Spring会取出orderService对应的ObjectFactory调用它的getObject()方法得到那个尚未填充属性的裸OrderService对象。这个对象被放到二级缓存earlySingletonObjects里同时三级缓存里的工厂被移除。这个过程中inventoryService拿到了一个“半成品”的orderService引用成功完成自己的属性填充、初始化最终以完整形态进入一级缓存。第五步inventoryService创建完成之后控制权回到orderService。Spring手里已经拿到了完整的inventoryService引用于是继续填充orderService的另一个字段最后orderService自己也走完初始化、进入一级缓存。整个链条用文字描述很长但核心只有一句话在两个Bean互相拉扯的过程中第一个被创建的Bean通过三级缓存提前交出了自己的引用打破了僵局。所以二级缓存也不是可有可无的。它存在的价值在于如果同一个Bean被多次要求提前暴露引用Spring不需要反复调用三级缓存里的工厂只要第一次调用后把结果暂存到二级缓存后续请求直接拿现成引用即可。这样既保证了性能也保证了二次请求拿到的是同一个内存地址。2.3 “早期引用”到底早期在哪里很多人第一次接触“早期引用”这个概念时会被绕晕以为早期引用是代理对象或者以为它是原对象的一个拷贝。其实都不是。早期引用就是那个尚未完成全部初始化流程的Bean实例本身。说它“早期”是因为它已经通过了实例化构造函数已执行但还没经历属性填充、BeanPostProcessor的postProcessBeforeInitialization、afterPropertiesSet、postProcessAfterInitialization等阶段。拿生活中的例子来理解一个项目组来了新同事人事流程还没走完工牌还没办但研发团队的座位已经留好了。团队成员先跟他打了个照面知道“有这么个人了”虽然他的账号权限还没开但后续协作可以正常进行。早期引用就是那个“没办完手续但已经坐在工位上的人”。这个区分在排查问题时很重要。如果你的循环依赖涉及PostConstruct里的逻辑而PostConstruct执行前Bean就已经被别人拿走了那么别人用这个引用时PostConstruct里的初始化可能还没跑完。这是循环依赖最隐蔽的坑之一后面第四章还会再提到。3. 为什么必须是三级缓存两级不行吗3.1 没有AOP的情况下两级缓存的逻辑其实能跑通我经常被问到的一个问题是singletonObjectsearlySingletonObjects两级缓存理论上是不是也能解决循环依赖很多人的直觉是一级存成品二级存半成品给依赖方发半成品最后再转正这不就行了吗单看“解决循环依赖”这个功能两级缓存确实够了。你可以想象一下A创建时直接把这个半成品塞进二级缓存B填充属性时从二级缓存拿到A的半成品B创建完成后A再从一级缓存拿到B。逻辑上是通的。那为什么Spring非要设计第三级因为上面这个方案默认了一个前提半成品Bean和最终Bean是同一个对象。3.2 引入AOP代理后两级缓存露馅了现实世界中Spring容器里的Bean不一定是原生对象。可能被Transactional标记需要生成事务代理可能被Async标记需要生成异步代理还可能是你自己通过BeanPostProcessor做了一层包装。换句话说最终放进一级缓存里的很可能是经过一系列后置处理加工出来的代理对象它和最初那个裸对象不是同一个引用。那么问题来了。如果只有两级缓存A创建裸对象后直接放进二级缓存B从二级缓存拿到了这个裸对象。但等A走完初始化流程后Spring发现A需要被代理于是把代理对象放进了二级缓存……不对按两级方案早期引用早就在二级缓存里了。B手里拿的是A的裸对象而容器最终持有的是A的代理对象。这就出现了两个不同的引用B调A的方法时走的是裸对象的方法事务、异步这些切面逻辑全部失效。更严重的是如果B在多线程环境下持有旧引用整个Bean层面的“幂等性”都会崩掉。为了解决这个问题Spring必须保证凡是可能被提前引用的Bean在提前暴露时就要确定它最终是不是代理如果是代理提前暴露的引用必须是代理本身。这就需要有一个“时机点”在这个时机点可以检查这个Bean是否需要被代理并生成对应的代理对象。这就是第三级缓存的用武之地。三级缓存里放的不是Bean而是一个ObjectFactory。这个工厂在执行时会调用getEarlyBeanReference方法让所有SmartInstantiationAwareBeanPostProcessor有机会对这个Bean进行加工。典型的加工者是AbstractAutoProxyCreator它会在这一步通过getEarlyBeanReference生成代理并缓存起来。举个例子A被Transactional修饰B依赖A。A创建裸对象后进入三级缓存B填充属性需要A此时三级缓存的工厂被调用getEarlyBeanReference检测到A需要事务代理于是生成代理对象返回给B同时缓存到二级缓存后续A自己走完整个生命周期Spring发现A已经有早期代理了就不会再重新创建代理。最终B手里的引用和容器持有的引用是同一个代理对象事务功能正常。可以这么说第三级缓存的价值不是“解决循环依赖本身”而是让Spring在解决循环依赖的同时不破坏它对Bean生命周期的统一管控。它像一个中间仲裁者延迟了“是否创建代理”的决策直到确认真有人需要这个引用。3.3 ObjectFactory加lambda的设计巧思从上一个小节可以引申出一个很漂亮的设计三级缓存的工厂不一定每次都被调用。如果这个Bean在整个容器启动过程中压根没有被其他Bean提前引用那么这个工厂就永远不被执行代理依旧在Bean生命周期正常结束前生成。这种延迟决策的思路避免了“无脑给所有Bean提前生成代理”的性能浪费。如果只有两级缓存为了应对循环依赖你可能必须在Bean实例化后立刻判断它是否需要代理这意味着所有单例Bean都要在实例化后马上接受一次完整的代理检查与提前包装。而三级缓存把这件事变成了“按需触发”你不需要提前支付代理成本。这里的实现也很简洁在doCreateBean方法里Spring创建完Bean实例后会有一段类似的代码简化示意if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }用lambda表达式把一个方法引用封装成ObjectFactory真正执行getEarlyBeanReference的时刻被延迟到了有人调用getObject()时。这是典型的惰性计算思维。对Java熟悉的开发者看到这段代码应该能立刻理解它的优雅之处不急着做决定等真正需要的时候再做。4. 哪些循环依赖Spring救不了4.1 构造器注入的循环依赖前面已经提过构造器注入的循环依赖Spring默认是救不了的。原因是时间点问题A的构造函数还没执行完根本不存在一个“A的对象”可以被提前暴露。想象一下你需要给一个人办入职但办入职需要他本人在场签字。你不可能在他还没到公司之前就让他签字。构造器同理OrderService的构造函数需要InventoryService的实例而InventoryService的构造函数又需要OrderService的实例。两边都在等对方“先出生”但双方都要在构造函数阶段就拿到对方时间点完全错开。解决方案通常有三条路改注入方式把构造器注入改为Setter或字段注入。这是最快见效的办法但不推荐作为第一选择因为构造器注入是官方推荐风格改掉之后测试和不可变设计都会打折扣。加Lazy在构造器参数上标注Lazy让Spring注入一个代理而不是真实Bean。Lazy的原理是创建一个延迟初始化代理真正的方法调用时才触发完整初始化public OrderService(Lazy InventoryService inventoryService) { this.inventoryService inventoryService; }第三也是最重要的——重构设计。如果两个类在构造器阶段就互相必需往往说明它们职责划分有问题。后面第五章我再展开怎么重构。4.2 非单例作用域的循环依赖三级缓存只对单例Bean有效。prototype作用域的Bean默认每次getBean都会新建根本不存在“缓存中暂存早期引用”这回事所以循环依赖必然报错。此外request、session等Web作用域的Bean生命周期由外部容器管理也不在三级缓存的处理范围内。排查这类问题时报错信息往往不会直接提示“prototype循环”但你会看到BeanCurrentlyInCreationException或者是容器启动时某个Bean创建失败。经验是先确认出错Bean的Scope注解如果是非单例先把它改成单例试试如果业务上确实需要原型再考虑重构。4.3 Async引发的“二次代理”问题这条属于进阶问题很多文章不会提但实际项目中踩过的坑非常多。Async的代理是在Bean生命周期末段通过AsyncAnnotationBeanPostProcessor生成的。如果A和B循环依赖A提前暴露了早期引用而这个早期引用经过getEarlyBeanReference时已经生成了一个普通代理比如事务代理等整个生命周期结束时Async又试图生成一个新的代理。两个代理叠加轻则类型不匹配重则切面失效甚至出现CGLIB代理的ClassCastException。遇到这种情况最直接的做法是在注入处加Lazy避免提前暴露阶段生成代理。如果你确认某个Bean同时被多个后置处理器加工最好把循环依赖的Bean之一用Lazy隔离开来确保代理生成的顺序可控。4.4 Spring Boot 2.6之后默认禁止循环依赖Spring团队也在反思循环依赖这件事。从Spring Boot 2.6开始应用上下文默认禁止循环依赖。也就是说即使你的代码是Setter注入启动也会直接失败。这么做是有道理的循环依赖让Bean的初始化顺序变得难以预测也容易掩盖设计问题。如果你在升级Spring Boot版本后遇到循环依赖报错可以在配置文件中临时开启spring.main.allow-circular-referencestrue但这是权宜之计长期来看还是要消除循环依赖。我在实际项目中见过有些组为了图省事直接打开这个开关结果后面排查初始化顺序问题花了更多时间。知道这个开关可以开比不开更重要但你要知道你正在为它付出什么代价。5. 实战一个循环依赖问题从报错到修复的全过程5.1 第一步看懂异常栈里的线索有一次我们在开发环境启动一个比较老的Spring Cloud项目启动到一半挂掉了报错信息是The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderService ↑ ↓ | inventoryService └─────┘Spring Boot 2.6的报错信息其实非常友好它会直接把依赖环画出来。但如果是老版本你可能只能看到BeanCurrentlyInCreationException。这时不要慌张先做两件事。第一看异常栈顶部是哪个Bean在创建时出了问题第二在异常栈里搜索Currently in creation通常能找到一串形如-的创建线索。比如org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name aService: Requested bean is currently in creation...然后在堆栈里继续找往往能看到bService也在创建过程中A和B的创建是互相嵌套的。只要锁定这两个Bean名问题就缩小了一半。5.2 第二步打开debug日志确认创建顺序Spring的Bean创建过程日志默认是info级别不会打印得很细。如果你需要确认具体的创建顺序可以在application.yml里临时打开debug级别logging: level: org.springframework.beans.factory: debug启动后你会在日志里看到类似这样的输出Creating shared instance of singleton bean orderService Creating shared instance of singleton bean inventoryService Finished creating instance of bean inventoryService Finished creating instance of bean orderService注意顺序。inventoryService先“Finished”是因为它提前拿到了orderService的早期引用所以能先完成整个生命周期orderService最后才“Finished”。如果你把日志打开但发现循环依赖并没有马上暴露那可能是间接循环需要结合依赖图多查几层。Debug日志在线上环境不建议长期打开刷量太大只在排查时临时开个几分钟即可。5.3 第三步动手改代码按优先级来拿到具体涉及的Bean后修复方案建议按下面这个优先级来选重构依赖关系长期收益最高。把互相依赖的两个类拆分出第三层比如抽一个PriceCalculator或者把其中一个依赖下沉到仓储层。举一个我实际处理过的例子OrderService需要InventoryService减库存InventoryService需要OrderService查订单明细。后来我把“查订单明细”抽成了一个OrderQueryService让InventoryService依赖这个新服务环就断了。使用Lazy打破引用。在构造器或字段注入处加Lazy让Spring延迟初始化。这是改动最小、风险最低的方案适合对现有结构不愿意大动的情况。SpringMessaging或事件机制。如果A调用BB又回调A很多时候其实没必要让两者直接互相持有。把其中一个调用改成异步事件或者消息发布这样不仅解决循环依赖还降低了耦合。不建议一上来就开allow-circular-referencestrue也不建议为了凑合直接改成字段注入。你改起来容易后面排查的人会骂街。5.4 案例复盘间接循环依赖的排查实录再分享一个印象深刻的案例。当时是一个多模块Maven工程模块A里的ReportService依赖模块B里的TaskServiceTaskService又依赖模块C里的MetadataService而MetadataService反向依赖了ReportService。这个环跨越三个模块启动时卡在ReportService的创建报错却只提示“form a cycle”下面画出的图是A到C到A的简写根本看不出中间还绕了一大圈。那次排查花了不少时间最后是用Idea的Spring插件生成Bean依赖图配合DependsOn注解逐一确认了各Bean的创建顺序才定位到这条长链路。经验如下如果项目是多模块结构循环依赖往往不是类之间的直接循环而是跨模块的间接循环建议先画依赖图再动手。不要只搜“报错的Bean名”要把所有相关模块的Bean名都列出来看它们被谁创建、创建时又触发了谁。长链路循环最烦人的地方是报错发生在最内层的Bean上但问题根源可能在外层的某个设计。如果一开始没有头绪不妨把异常栈里所有的getSingleton调用点找出来通常能拼出完整的依赖环。6. 快速自查表与经验技巧6.1 常见问题速查场景为什么会失败推荐做法构造器注入互相依赖对象尚未创建无法提前暴露用Lazy或改为Setter注入长期建议重构同一个Bean循环依赖自己本质也是构造时间点问题加Lazy或检查设计prototype作用域互相依赖不缓存Bean无法提前暴露改为单例或通过Lookup等方案绕开Async与循环依赖叠加代理叠加导致类型不匹配注入处加Lazy或解耦异步逻辑Spring Boot 2.6启动直接报错默认禁止循环依赖短期开启allow-circular-references长期消除环间接长链路循环依赖环跨度大报错难定位用依赖图、debug日志、堆栈线索逐步还原6.2 我踩过的几个边角坑用PostConstruct配合循环依赖时要格外小心初始化顺序。被提前暴露的Bean它的PostConstruct方法可能在其他依赖方已经拿到引用之后才执行。如果你在PostConstruct里做缓存预热、权限校验之类的操作而依赖方正好在预热前调用它就会出现空指针或者脏数据。不要为了“解耦”乱加DependsOn。这个注解会强制Spring按指定顺序创建Bean但在循环依赖场景下它可能把问题从“启动报错”变成“启动成功但某个Bean初始化顺序不符合预期”后者更难排查。最后即便Spring能解决单例Setter注入的循环依赖也不意味着循环依赖是一种“可用”的设计。三级缓存是Spring为了兼容旧代码所做的兜底不是让你拿来当设计模式的。我见过有些项目Bean之间的依赖像蜘蛛网一样缠在一起最后每次启动都要调整半天改一个Bean动全身。这种情况下优先要做的不是研究三级缓存而是重新梳理模块边界。6.3 如果真要在代码层面再深入理解如果想彻底吃透三级缓存最好的办法不是背源码而是自己动手写一个极简的Bean容器。你只需要实现createBean、populateBean、getSingleton三个方法分别对应检测三级缓存、填充属性、从缓存获取Bean。当你发现“从二级缓存拿早期引用”“三级缓存工厂需要接受一个回调”这些环节在自己的小实现里跑通时Spring的源码对你来说就只剩细枝末节了。我自己当年就是这么干的。写了一个不到300行的精简容器跑通A和B的循环依赖之后再回头看DefaultSingletonBeanRegistry突然就明白为什么getSingleton方法要接收一个allowEarlyReference参数——它控制的是当前这个Bean是否允许从二级或三级缓存中获取早期引用。这个参数在各个调用点上的取值差异本质上就是Spring对“哪些属性填充过程允许提前暴露引用”的精细控制。7. 写在最后我对循环依赖的态度做了这么多年Java开发我对循环依赖的看法经历过几个阶段。最早是“看到报错就慌”后来是“能启动就不管”再后来是“必须彻底消除”。现在我的态度更务实循环依赖是业务复杂性的正常产物它不可耻但我们应该尽量把它控制在很小的范围内。如果一个项目里只有一两处循环依赖且都是Setter注入那Spring默认能处理也就没太大事。但如果循环依赖开始频繁出现说明你的服务拆分或模块边界已经需要关注了。这时候与其去研究更深的Spring源码不如先坐下来把依赖图画一遍。从面试角度来说循环依赖是一个很好的试金石它考察你对Bean生命周期的理解、对代理生成时机的认知以及能不能把设计上的“为什么”讲清楚。希望这篇文章能帮你把这些点串起来。如果你正在处理一个具体的循环依赖问题按照第五章的排查步骤走多调试几次你会比我更快地找到规律。