ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot循环依赖:三级缓存原理、解决与预防实战

SpringBoot循环依赖:三级缓存原理、解决与预防实战 1. 项目概述当SpringBoot告诉你“你的Bean在谈恋爱”“The dependencies of some of the beans in the application context form a cycle.” 如果你在启动SpringBoot应用时控制台突然抛出这么一句看似文雅、实则令人头疼的异常恭喜你你遇到了Spring框架中一个经典且棘手的问题——循环依赖。这行报错翻译过来就是“应用上下文中某些Bean的依赖关系形成了一个循环。” 说白了就是你的Bean们陷入了“你中有我我中有你”的死锁状态Spring的IoC容器在创建它们时彻底懵了不知道从谁开始。这个问题在中小型项目中可能不常遇到但随着业务复杂度的提升尤其是在多人协作、模块划分不够清晰或者架构设计存在瑕疵时它就像一个定时炸弹随时可能在项目启动时引爆。我见过不少团队在项目后期为了快速实现功能随意地在Service之间相互注入最终导致启动失败排查起来费时费力。今天我们就来彻底拆解这个“循环依赖”难题不仅告诉你如何快速解决眼前的报错更要从原理上理解Spring是如何处理以及为何有时无法处理循环依赖的并分享一套从编码习惯到架构设计上避免此类问题的实战经验。无论你是刚接触SpringBoot的新手还是已经踩过几次坑的老鸟这篇文章都能帮你建立起清晰的问题解决思路。2. 循环依赖的本质与Spring的“三级缓存”机制要解决问题必须先理解问题是如何产生的。循环依赖用最直白的代码表示就是下面这种情况Service public class ServiceA { Autowired private ServiceB serviceB; } Service public class ServiceB { Autowired private ServiceA serviceA; }ServiceA依赖ServiceB而ServiceB又反过来依赖ServiceA。这形成了一个闭环。在普通的Java对象创建中这会导致无限递归最终栈溢出。Spring作为容器其核心职责之一就是管理Bean的生命周期和依赖关系它必须有一套机制来尝试打破这个闭环。2.1 Bean的生命周期简析要理解循环依赖的解决必须对Bean的创建过程有个基本概念。Spring创建一个Bean以单例为例大致经历以下几个关键步骤实例化通过反射调用构造函数创建一个“半成品”对象此时属性均为null。属性填充进行依赖注入如Autowired为这个“半成品”对象的属性赋值。初始化调用初始化方法如PostConstruct标注的方法、InitializingBean接口的afterPropertiesSet方法。放入单例池完成后的Bean被放入“单例池”即一级缓存singletonObjects后续直接从池中获取。循环依赖的症结就出现在第1步和第2步之间。如果A依赖B那么A在属性填充时需要去获取B的实例。如果B还没被创建容器就会去创建B而如果B又依赖A那么B在属性填充时又会去获取A的实例……这就陷入了死循环。2.2 三级缓存破局之道Spring解决单例Bean的Setter方法注入或字段注入Autowired造成的循环依赖其核心秘密在于“三级缓存”。这是面试中的高频考点也是理解整个机制的关键。这三级缓存定义在DefaultSingletonBeanRegistry类中一级缓存singletonObjectsConcurrentHashMap存放已经完全初始化好的Bean实例。我们平时从Spring容器Autowired进来的就是这里的Bean。它是最终形态。二级缓存earlySingletonObjectsHashMap存放提前暴露的、早期的Bean引用。这些Bean已经实例化但尚未完成属性填充和初始化是个“半成品”。它的存在是为了解决循环依赖过程中可能出现的代理对象问题如AOP。三级缓存singletonFactoriesHashMap存放Bean的工厂对象ObjectFactory。这个工厂对象能产生该Bean的早期引用可能是原始对象也可能是代理对象。2.3 解决循环依赖的推演流程让我们结合上面的A、B两个Service推演一下Spring三级缓存的工作流程开始创建ASpring准备创建ServiceA在实例化之后调用构造函数得到一个a new ServiceA()立即将这个“半成品”A包装成一个ObjectFactory工厂放入三级缓存singletonFactories中。此时A的属性serviceB还是null。填充A的属性发现需要BSpring开始为A进行属性填充发现它依赖ServiceB。于是去一级缓存找B没有去二级缓存找也没有去三级缓存找还是没有因为B还没开始创建。转而创建B容器决定先去创建B。同样实例化B之后将“半成品”B的工厂对象放入三级缓存。填充B的属性发现需要ASpring开始为B进行属性填充发现它依赖ServiceA。于是开始查找一级缓存无。二级缓存无。三级缓存有找到了之前存放的A的工厂对象。获取A的早期引用通过三级缓存中的工厂对象获取到A的早期引用这个引用可能已经是AOP代理对象并将这个早期引用从三级缓存升级到二级缓存同时从三级缓存移除。然后将这个早期引用注入给B。B完成创建B成功完成了属性填充注入了A的早期引用和初始化变成一个“完全体”Bean被放入一级缓存。同时清理二级和三级缓存中关于B的条目。A完成创建此时流程回到第2步A在等待B。现在一级缓存里已经有了B的“完全体”Spring将其取出注入到A中。接着A完成后续的初始化也变成一个“完全体”Bean放入一级缓存。至此循环依赖被成功解决A和B都成为了可用的Bean。关键理解三级缓存的核心思想是“提前暴露引用”。在对象刚实例化、还是个“空壳”的时候就把它的引用工厂存起来供其他依赖它的Bean使用从而打破“鸡生蛋、蛋生鸡”的僵局。二级缓存主要是一个中间缓存用于性能优化和避免重复执行工厂逻辑。2.4 为何需要三级缓存两级不行吗这是一个经典的深度面试题。很多人会问既然最终目的是拿到早期引用为什么不直接用二级缓存一个放成品一个放半成品关键在于AOP代理。如果Bean被AOP切面代理比如事务Transactional那么最终放入容器、被其他Bean依赖的应该是代理对象而不是原始对象。这个代理对象的创建时机是在初始化之后。但在循环依赖的场景下B在属性填充时就需要A此时A的代理对象还没生成因为A还没初始化完。三级缓存中的ObjectFactory就是为了处理这个延迟决策。当B通过工厂获取A的早期引用时这个工厂会执行一个getEarlyBeanReference方法。在这个方法里Spring会检查A是否需要被代理。如果需要就在这里提前生成代理对象并返回如果不需要就返回原始对象。这个逻辑被封装在工厂里确保了无论何时获取都能得到正确的对象原始或代理。如果只有二级缓存那么半成品池里存放的就是实例化后的原始对象。当A需要被代理时B拿到的就是原始对象而最终放入一级缓存的却是代理对象这就造成了不一致B依赖的A和最终容器里的A不是同一个对象这会导致严重的问题。所以三级缓存的核心价值在于解耦“实例化”、“代理生成”和“暴露引用”的时机以支持循环依赖下的AOP。3. 哪些情况Spring也无法解决循环依赖了解了解决机制更要明白它的局限性。Spring的三级缓存不是万能的在以下几种情况下循环依赖会直接导致启动失败报出文章开头的错误。3.1 构造器注入导致的循环依赖这是最常见且Spring无法自动解决的情况。Service public class ServiceA { private final ServiceB serviceB; // 构造器注入 public ServiceA(ServiceB serviceB) { this.serviceB serviceB; } } Service public class ServiceB { private final ServiceA serviceA; // 构造器注入 public ServiceB(ServiceA serviceA) { this.serviceA serviceA; } }为什么无法解决回忆一下Bean的创建步骤实例化 → 属性填充 → 初始化。构造器注入发生在实例化阶段。要实例化A必须首先有B的实例作为参数。但B的实例化又需要A的实例作为参数。这就成了一个“先有鸡还是先有蛋”的经典死锁在对象实例化的第一步就卡住了根本没有机会走到“提前暴露引用”三级缓存那一步。因为实例化都完成不了自然没有“半成品”可以暴露。3.2Async异步方法导致的循环依赖Async注解的原理也是通过AOP生成代理对象。但它的问题更隐蔽。假设A有一个Async方法B依赖A。Service public class ServiceA { Async public void asyncMethod() {...} } Service public class ServiceB { Autowired private ServiceA serviceA; // 注入的应该是A的代理对象 }如果A也依赖B就可能出问题。因为Async代理的创建通常发生在Bean生命周期的后期通过BeanPostProcessor在解决循环依赖的早期引用暴露阶段负责创建Async代理的处理器可能还未执行导致工厂无法生成正确的代理对象从而引发异常。3.3 多例Prototype作用域的Bean循环依赖Spring默认不处理原型Bean的循环依赖。因为对于原型Bean每次getBean()都会创建一个新的实例。如果支持循环依赖缓存中将会充斥着大量不完整的原型Bean实例导致内存泄漏和逻辑混乱。因此Spring在检测到原型Bean的循环依赖时会直接抛出BeanCurrentlyInCreationException。3.4 其他特殊情况PostConstruct方法中相互调用即使在Setter注入下解决了循环依赖如果两个Bean在PostConstruct初始化方法中互相调用对方的方法也可能因为状态未完全准备好而引发运行时异常。复杂的间接循环依赖A依赖BB依赖CC依赖A。这种多层的循环依赖虽然Spring有可能解决但极大地增加了设计的复杂性和理解成本是糟糕设计的信号。4. 实战诊断与解决循环依赖报错当你的应用启动失败看到循环依赖报错时不要慌张。按照以下步骤可以高效地定位和解决问题。4.1 解读错误信息SpringBoot 2.6 版本对循环依赖的报错信息非常友好。错误日志通常会包含一个类似下面的依赖关系链条The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | serviceA defined in file [xxx/ServiceA.class] ↑ ↓ | serviceB defined in file [xxx/ServiceB.class] └─────┘这个ASCII艺术图清晰地展示了循环路径serviceA → serviceB → serviceA。你需要重点关注箭头指向的两个Bean类。4.2 使用IDE工具辅助分析现代IDE如IntelliJ IDEA是强大的帮手。依赖关系图在IDEA中右键点击类名选择Diagrams - Show Dependencies。它可以可视化展示类之间的依赖关系帮助你快速发现循环。查找用法在报错的Bean类上使用Find Usages(AltF7) 功能查看它被哪些类注入以及它又注入了哪些类。顺藤摸瓜往往能找到循环链。4.3 解决方案一重构设计治本之策这是最推荐、最根本的解决方案。循环依赖通常是职责划分不清或架构层次混乱的表现。提取公共逻辑到第三个类检查A和B看是否有一部分业务逻辑是它们都需要的。将这部分逻辑抽取到一个新的ServiceC或Component中让A和B都去依赖C从而打破A和B之间的直接循环。使用接口与依赖倒置定义接口让高层模块依赖接口而非具体实现。有时循环依赖发生在具体实现类之间通过面向接口编程可以将依赖关系梳理得更清晰。应用领域驱动设计DDD或清晰分层严格遵循Controller - Service - Repository的分层禁止同层之间相互注入特别是Service层。如果两个Service需要协作考虑是否应该有一个更上层的Service来协调它们或者将部分功能下沉到领域模型或工具类中。4.4 解决方案二使用Setter/字段注入替代构造器注入治标之法如果循环依赖无法通过重构立即消除且是由构造器注入引起的可以临时将其改为Setter注入或字段注入。Service public class ServiceA { private ServiceB serviceB; // 改为Setter注入 Autowired public void setServiceB(ServiceB serviceB) { this.serviceB serviceB; } }为什么这样可行如前所述Setter注入发生在属性填充阶段此时Bean的实例半成品已经创建并提前暴露到了三级缓存因此有机会解决循环。重要提示这只是一个临时解决方案。从设计模式和代码质量的角度看构造器注入是更推荐的方式因为它能明确声明不可变的依赖便于测试并能保证Bean在构造完成后就处于完全初始化的状态。改用Setter注入掩盖了设计问题应尽快用方案一进行重构。4.5 解决方案三使用Lazy注解缓兵之计Lazy注解可以延迟依赖的初始化。Service public class ServiceA { private final ServiceB serviceB; // 在构造器参数上使用 Lazy public ServiceA(Lazy ServiceB serviceB) { this.serviceB serviceB; } }或者用在字段/Setter上Service public class ServiceA { Lazy Autowired private ServiceB serviceB; }工作原理Lazy告诉Spring不要立即注入一个真实的ServiceB实例而是先注入一个代理对象。当第一次真正调用serviceB的方法时代理才会去触发真实Bean的创建和初始化。这样就打破了启动时的即时依赖循环。适用场景与风险Lazy适用于循环依赖链条中不那么关键、或者不需要在启动时就完全初始化的依赖。但它会带来一些副作用问题被推迟到运行时可能使一些初始化错误更难发现。增加了代理开销。可能掩盖了更深层次的设计缺陷。因此它也应被视为一种临时手段。4.6 解决方案四调整SpringBoot配置不推荐在SpringBoot 2.6版本之前默认允许单例Bean的循环依赖。但从2.6版本开始为了鼓励更好的设计SpringBoot默认禁止了循环依赖。如果你不得不暂时容忍循环依赖例如在迁移旧项目时可以在配置文件中将其打开# application.properties spring.main.allow-circular-referencestrue强烈不建议这样做这个配置相当于关掉了编译器的所有警告让一个糟糕的设计继续运行会给项目埋下巨大的技术债。它只能解决Setter/字段注入的循环依赖对构造器注入依然无效。5. 编码规范与架构设计预防循环依赖最好的解决方式是不让它发生。在团队中建立良好的编码规范至关重要。5.1 依赖注入方式的选择建议强制使用构造器注入对于必需依赖这能迫使开发者思考每个Bean的必需依赖是什么并且能立即暴露出循环依赖问题启动就失败而不是将其隐藏到运行时。可选依赖使用Setter注入对于一些可选的、或配置类的依赖可以使用Setter注入并配合Autowired(required false)。避免字段注入虽然字段注入写起来简洁但它有几个缺点无法声明依赖为final不可变、不利于单元测试必须通过反射注入、隐藏了依赖关系。许多团队规范已明确禁止使用字段注入。5.2 模块与包结构设计清晰的单向依赖层次设计一个严格的依赖规则比如Web层 → 业务层 → 数据层。使用IDE或架构守护工具如ArchUnit来检查并禁止反向依赖和循环依赖。依赖注入框架Dagger, Guice的启示这些框架通常对循环依赖有更严格的检查。学习它们的思想在设计时就将组件视为有向无环图DAG中的节点。定期进行架构复审在代码评审中除了看业务逻辑也要关注类之间的依赖关系图。利用SonarQube等静态代码分析工具可以设置规则来检测循环依赖。5.3 利用Spring的特性进行优化DependsOn注解如果一个Bean的初始化需要在另一个Bean之后可以使用DependsOn来显式声明但这不应用于解决循环依赖而是用于定义明确的初始化顺序。事件驱动解耦对于某些需要协作的场景可以考虑使用Spring的事件发布/订阅机制ApplicationEventPublisher。Bean A完成某工作后发布一个事件Bean B监听该事件并做出响应从而避免直接的依赖调用。6. 高级场景在复杂项目中定位深层循环依赖在大型微服务或遗留系统中循环依赖链可能很长跨越多个模块。此时需要更系统的排查方法。6.1 使用Spring Actuator的Beans端点如果应用能部分启动比如因为某个非关键Bean的循环依赖可以启用Actuator访问/actuator/beans端点。这个端点会以JSON形式返回所有Bean的定义及其依赖关系数据非常详细可以帮你梳理复杂的依赖网。6.2 编写单元测试暴露问题为疑似存在循环依赖的模块编写简单的集成测试。SpringBootTest class CircularDependencyTest { Autowired private ApplicationContext applicationContext; Test void contextLoads() { // 如果存在无法解决的循环依赖测试启动时就会失败 } }在持续集成CI流程中加入这个测试可以防止新增代码引入循环依赖。6.3 分析工具JDepend与Structure101对于遗留系统的技术债清理可以使用专门的架构分析工具JDepend可以生成包级别的依赖度量报告识别循环依赖包。Structure101功能更强大可以可视化代码结构设置架构规则如“不允许循环依赖”并在构建时检查违规。这些工具能从更高维度帮你发现架构层面的循环依赖而不仅仅是类级别的。7. 总结与个人实践心得循环依赖报错是SpringBoot开发中的一个“富贵病”往往出现在业务快速发展、代码量激增的阶段。它像一个设计上的警报器提醒我们是时候停下来审视一下代码结构了。我的经验是不要轻易使用spring.main.allow-circular-referencestrue或过度依赖Lazy。前者是掩耳盗铃后者虽然有用但就像给代码打上“此处有坑”的标记应作为临时过渡并附上清晰的注释说明计划何时重构。坚持构造器注入能让问题在最早期的编译或启动阶段就暴露出来这远比在运行时因为某个Bean状态不对而调试半天要高效得多。在团队中推行这个规范初期可能会遇到阻力觉得写起来麻烦但长期来看它对代码的可维护性和可测试性带来的好处是巨大的。最后理解“三级缓存”的原理不仅仅是为了应付面试更是为了在遇到复杂问题时能清晰地知道Spring容器内部在做什么从而做出正确的判断和决策。当你再看到 “The dependencies of some of the beans in the application context form a cycle” 时希望你的第一反应不是去搜索如何关闭检查而是能自信地说“让我看看是哪里设计得不合理。”
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进