
1. 项目概述当SpringBoot告诉你“Bean们陷入了循环”如果你正在开发一个SpringBoot应用突然在启动时看到控制台抛出这么一段红字“The dependencies of some of the beans in the application context form a cycle”心里多半会咯噔一下。这个错误翻译过来就是“应用上下文中一些Bean的依赖关系形成了一个循环”。说白了就是你的Bean A依赖Bean BBean B又依赖Bean A或者依赖链更长最终形成了一个闭环Spring的IoC容器在创建这些Bean时陷入了“先有鸡还是先有蛋”的困境无法决定谁该先被实例化最终只能报错罢工。这个错误在SpringBoot项目中并不少见尤其是随着业务逻辑变得复杂模块拆分更细开发者一不小心就容易设计出这种循环依赖的结构。它不仅仅是启动失败那么简单更深层次地它暴露了代码结构设计上的缺陷——高耦合。即使通过某些技巧绕过了启动报错这种结构也会让代码变得难以理解、测试和维护。因此理解循环依赖的成因、Spring的解决机制以及如何从根本上避免它是每一位SpringBoot开发者必须掌握的技能。本文将从一个资深开发者的视角带你彻底拆解这个报错不仅告诉你如何“救火”解决眼前的问题更会深入原理分享如何从设计上“防火”构建更健壮的应用。2. 循环依赖的根源与Spring的“三级缓存”机制要解决问题首先要理解问题是如何产生的以及Spring框架本身为此做了哪些努力。2.1 循环依赖是如何发生的让我们从一个最简单的场景开始。假设我们有一个UserService它内部需要调用OrderService的方法同时OrderService也需要查询用户信息因此也注入了UserService。Service public class UserService { Autowired private OrderService orderService; // UserService 依赖 OrderService // ... 其他方法 } Service public class OrderService { Autowired private UserService userService; // OrderService 反过来依赖 UserService // ... 其他方法 }这就是一个典型的双向循环依赖。在Spring IoC容器启动扫描并创建Bean的过程中它会尝试构建完整的依赖关系图。当它开始创建UserService时发现它依赖OrderService于是暂停UserService的创建转而去创建OrderService。而在创建OrderService时又发现它依赖UserService。此时UserService正在创建中尚未完成初始化但又不是一个完全可用的状态。于是容器陷入了死锁要完成UserService需要先有OrderService要完成OrderService又需要先有UserService。在默认配置下Spring无法处理这种局面于是抛出我们看到的循环依赖异常。2.2 Spring的破局之道三级缓存那么Spring是不是完全没办法处理循环依赖呢并不是。对于最常见的单例Singleton作用域且通过属性注入Field Injection或Setter方法注入的BeanSpring提供了一套巧妙的解决方案其核心就是三级缓存。这里需要先理解Spring创建一个单例Bean的粗略生命周期实例化Instantiate调用构造函数创建一个“原始对象”。此时对象属性都是默认值null, 0等。属性填充Populate通过反射为对象注入其依赖的其他Bean。初始化Initialize调用PostConstruct方法、执行InitializingBean.afterPropertiesSet()等。放入单例池完成后的Bean被放入“单例池”一级缓存供后续使用。三级缓存就是在上述过程中插入的“临时寄存处”目的是将实例化但未初始化的“早期引用”暴露出去打破循环。这三层缓存分别是一级缓存singletonObjects存放已经完全初始化好的Bean。这就是我们常说的“单例池”。一旦Bean在这里就表示它随时可用。二级缓存earlySingletonObjects存放“早期暴露”的Bean。这些Bean已经完成了实例化但可能还没有完成属性填充和初始化。它的存在是为了解决通过AOP代理产生的循环依赖。三级缓存singletonFactories存放Bean的ObjectFactory对象工厂。当Bean实例化完成后Spring会将其包装成一个ObjectFactory放入此缓存。这个工厂能在必要时比如检测到循环依赖提前生成代理对象或原始对象。解决循环依赖的关键流程以A、B互相依赖为例开始创建A实例化A一个原始对象然后将能生成A的ObjectFactory放入三级缓存。准备为A填充属性发现它依赖B。于是暂停A的创建转去创建B。开始创建B实例化B同样将B的ObjectFactory放入三级缓存。准备为B填充属性发现它依赖A。此时B会依次去查找一级缓存没有A二级缓存没有A三级缓存找到了A的ObjectFactory通过A的ObjectFactory获取到A的“早期引用”可能是原始对象也可能是AOP代理对象。将这个早期引用放入二级缓存同时从三级缓存移除A的工厂。B成功获得了A的早期引用完成了属性填充和初始化变成一个完整的Bean放入一级缓存。流程回到A的创建。此时A可以从一级缓存中拿到已经初始化好的B完成自己的属性填充和初始化最终也放入一级缓存。注意Spring默认只支持单例Bean且通过Setter或属性注入的循环依赖。如果使用构造器注入Constructor Injection循环依赖将无法解决因为Bean在实例化调用构造函数的那一刻就需要所有依赖项就绪此时依赖的Bean可能连“早期引用”都还没有生成死锁必然发生。这也是为什么社区普遍推荐使用构造器注入的原因之一——它能在编译期就暴露出循环依赖的设计问题。2.3 为什么需要三级缓存二级不够吗这是一个经典的面试题。关键在于AOP代理。 如果Bean不需要被AOP代理比如没有Transactional,Async等注解那么二级缓存确实可能够用实例化后直接把原始对象扔进二级缓存。但当Bean需要被代理时问题来了最终放入单例池的必须是代理对象而不是原始对象。三级缓存中的ObjectFactory是一个“智能工厂”。它被调用的时机即发生循环依赖时决定了它要生产什么如果没有循环依赖Bean按部就班地走完生命周期在初始化后由BeanPostProcessor如AbstractAutoProxyCreator统一生成代理对象然后放入一级缓存。如果发生了循环依赖ObjectFactory会被提前触发。它需要判断如果这个Bean最终需要被代理那么我现在就生成代理对象并返回如果不需要就返回原始对象。这个提前生成的代理对象会被放入二级缓存。二级缓存earlySingletonObjects的角色是“早期暴露对象的缓存”。它缓存的就是ObjectFactory生产出来的结果可能是原始对象也可能是代理对象。如果没有三级缓存我们无法实现这种“按需、提前”生成代理的逻辑。直接放原始对象到二级缓存万一后面需要代理就产生了不一致单例池是代理对象二级缓存是原始对象。直接放代理对象到二级缓存如果这个Bean根本没有循环依赖那就过早地创建了代理可能影响一些生命周期回调的执行顺序。所以三级缓存的核心价值在于将“实例化”与“代理生成”的时机解耦并提供了一个懒加载的机制仅在真正发生循环依赖时才提前生成代理保证了行为的一致性和性能。3. 实战诊断与解决循环依赖报错当你的应用启动失败并抛出循环依赖异常时不要慌张。Spring的错误信息通常非常详细是我们排查问题的第一手资料。3.1 解读错误信息与依赖链条错误信息通常会打印出完整的依赖循环路径。例如The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | userService defined in file [/.../UserService.class] ↑ ↓ | orderService defined in file [/.../OrderService.class] └─────┘这个ASCII艺术图非常直观地展示了userService和orderService之间形成了闭环。对于更复杂的循环路径也会更长。第一步就是仔细阅读这个路径找到参与循环的所有Bean。3.2 常用解决方案与实操步骤根据不同的场景和根本原因我们可以采取不同的解决策略。3.2.1 方案一使用Lazy注解治标不治本但快速Lazy注解可以延迟依赖的加载。将它加在注入点字段、构造参数、方法参数或Bean方法上告诉Spring“先给我一个代理等真正用到这个Bean的方法时再去初始化它。”应用场景快速解决启动问题适用于依赖并非启动就必须的场景或者你明确知道循环依赖是设计上的特例。Service public class UserService { Autowired Lazy // 关键在这里延迟加载OrderService private OrderService orderService; // ... }或者用在另一个Bean上Service public class OrderService { Autowired Lazy // 延迟加载UserService private UserService userService; // ... }实操心得通常只需要在循环依赖的其中一方的注入点上加Lazy即可打破循环。加在哪一方一个简单的原则是加在理论上可以后初始化的那一方。例如订单服务需要立即调用用户服务但用户服务在启动时不一定需要订单服务那么就把Lazy加在UserService对OrderService的依赖上。Lazy会引入一个代理可能对调试和某些极端情况下的性能有细微影响但对于大多数业务来说可以接受。3.2.2 方案二改用Setter方法或字段注入如果当前是构造器注入如前所述Spring默认无法解决构造器注入的循环依赖。如果你的循环依赖是由于构造器注入引起的一个快速的修改方案是将其改为Setter注入或字段注入。// 改造前构造器注入循环依赖报错 Service public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { // 构造器注入 this.orderService orderService; } } // 改造后Setter注入 Service public class UserService { private OrderService orderService; Autowired // 或使用 Setter(onMethod __(Autowired)) (Lombok) public void setOrderService(OrderService orderService) { this.orderService orderService; } }注意事项这只是一个临时绕过方案。它掩盖了设计问题并且放弃了构造器注入带来的好处如不可变字段、明确的依赖关系、易于测试。强烈建议在解决启动问题后立即着手进行方案三重构设计。3.2.3 方案三重构设计从根本上消除循环依赖推荐这是最彻底、最优雅的解决方案。循环依赖通常是职责划分不清的征兆。我们可以通过以下几种模式来解耦提取公共逻辑到第三个类Service 如果A和B互相调用是因为它们有共同的业务逻辑可以将这部分逻辑提取到一个新的C例如CommonService中让A和B都依赖C而A和B之间不再直接依赖。// 重构前A - B // 重构后A - C - B Service public class CommonService { public void sharedBusinessLogic() { /* ... */ } } Service public class ServiceA { Autowired private CommonService commonService; // 不再直接依赖ServiceB } Service public class ServiceB { Autowired private CommonService commonService; // 不再直接依赖ServiceA }使用事件驱动ApplicationEvent 如果A的动作需要触发B的更新而B的更新又需要通知A这种“互相通知”容易导致循环。可以引入Spring的事件机制让它们都去监听一个第三方事件从而解耦。// 定义事件 public class DataUpdatedEvent extends ApplicationEvent { /* ... */ } // A和B都监听事件而不是互相调用 Service public class ServiceA { EventListener public void handleEvent(DataUpdatedEvent event) { /* ... */ } } Service public class ServiceB { public void someMethod() { // 执行操作... applicationEventPublisher.publishEvent(new DataUpdatedEvent(this)); // 不再直接调用ServiceA } }使用回调接口或模板方法模式 如果依赖关系是单向的但设计成了双向可以考虑将其中一个方向改为接口回调。// 定义回调接口 public interface OperationCallback { void doAfterOperation(); } Service public class ServiceA implements OperationCallback { Autowired private ServiceB serviceB; public void process() { serviceB.doSomething(this); // 将自身作为回调传入 } Override public void doAfterOperation() { // ServiceB完成后回调这里 } } Service public class ServiceB { public void doSomething(OperationCallback callback) { // ... 执行操作 callback.doAfterOperation(); // 回调 } }这样ServiceA依赖ServiceB但ServiceB不再依赖ServiceA的具体实现而是依赖一个抽象的接口循环被打破。3.2.4 方案四调整Bean的加载顺序DependsOnDependsOn注解可以显式指定Bean的创建顺序。虽然它不能直接解决循环依赖但可以通过强制让某个Bean先初始化来避免某些因顺序问题暴露的循环。Service DependsOn(orderService) // 明确告诉Spring先初始化orderService public class UserService { Autowired private OrderService orderService; }使用场景有限这只在循环是因为某些非直接注入的依赖如PostConstruct方法中的调用、实现了特定接口等导致时才可能有效。对于直接的属性注入循环它无能为力。3.3 终极武器allowCircularReferences配置与风险Spring Boot 2.6 版本开始默认禁用了循环依赖spring.main.allow-circular-referencesfalse。如果你使用的是旧版本项目升级上来或者想临时允许循环依赖可以在application.properties中配置spring.main.allow-circular-referencestrue强烈警告这只是一个全局的开关它允许Spring用三级缓存机制去解决它所能解决的循环依赖单例、非构造器注入。它并不能解决所有循环依赖比如构造器注入的。更重要的是打开这个开关意味着你向糟糕的设计妥协了。它掩盖了问题让代码潜伏着更高的耦合风险不利于长期维护。这个配置应该仅作为临时调试手段或者在你完全理解风险且确有特殊原因的遗留系统中使用。在新项目中永远不要默认打开它。4. 深入排查当常规方法失效时有时候循环依赖的链条可能很长、很隐蔽或者涉及一些特殊的Bean如Configuration配置类、Bean方法、AOP切面等。这时需要更系统的排查方法。4.1 利用Spring Boot Actuator的beans端点如果你的项目引入了spring-boot-starter-actuator依赖并暴露了beans端点可以通过HTTP请求查看所有Bean的依赖关系图。添加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency配置application.propertiesmanagement.endpoints.web.exposure.includebeans,health management.endpoint.beans.enabledtrue启动应用如果因为循环依赖启动失败此方法可能不适用需先通过Lazy等方式临时启动。访问http://localhost:8080/actuator/beans。你会得到一个巨大的JSON其中包含了每个Bean的详细信息包括它的依赖dependencies。搜索报错中提到的Bean名仔细查看它们的依赖列表可以帮你可视化复杂的依赖网。4.2 分析Configuration类与Bean方法在配置类中通过Bean方法手动声明Bean也可能意外引入循环依赖。Configuration public class AppConfig { Bean public ServiceA serviceA(ServiceB serviceB) { // 方法参数注入 return new ServiceA(serviceB); } Bean public ServiceB serviceB(ServiceA serviceA) { // 循环依赖 return new ServiceB(serviceA); } }这种情况的报错信息可能不那么直接。排查时要仔细检查所有Configuration类看Bean方法是否存在相互引用。解决方法同样是重构或者将其中一个Bean方法的依赖改为从ApplicationContext中Autowired并在方法内部通过context.getBean()获取不推荐应优先考虑重构设计。4.3 关注AOP代理与Async,Transactional等注解被Transactional,Async,Cacheable等注解标记的Bean会被Spring动态代理CGLIB或JDK Proxy。这有时会干扰循环依赖的解决尤其是在配合Lazy使用时。一个常见的坑是在同一个类内部一个Transactional方法调用另一个Transactional方法由于代理机制内部的调用不会经过代理导致事务不生效。虽然这不直接导致循环依赖报错但原理相关。当循环依赖涉及代理Bean时确保你理解三级缓存中提前暴露的是代理对象。如果遇到奇怪的问题可以尝试在类上添加Scope(proxyMode ScopedProxyMode.TARGET_CLASS)或检查CGLIB代理的生成情况。5. 设计预防将循环依赖扼杀在摇篮里最好的解决方式是不让它发生。以下是一些在项目初期和开发过程中就应该遵循的最佳实践优先使用构造器注入这是Spring团队官方推荐的方式。它强制你在编译期就声明所有必需的依赖使得类是不可变的final字段并且更容易进行单元测试无需Spring容器直接new。更重要的是构造器注入会让循环依赖在应用启动时立即失败迫使你早期就发现并修复设计缺陷。使用Lombok的RequiredArgsConstructor可以大大简化代码。Service RequiredArgsConstructor // 为所有final字段生成构造函数 public class UserService { private final OrderService orderService; private final AnotherService anotherService; // 没有setter依赖通过构造器明确注入 }遵循单一职责原则SRP一个类只做一件事。如果UserService既管用户又管订单那它自然容易和自己“循环”。清晰地划分服务边界是避免循环依赖的基石。依赖倒置原则DIP高层模块不应依赖低层模块二者都应依赖抽象。多使用接口。ServiceA依赖IServiceB接口ServiceB实现IServiceB。这样即使ServiceB需要反向通信也可以通过接口回调、事件等方式而不是直接依赖ServiceA的具体类降低了耦合度。进行依赖关系审查在团队开发中定期如在代码评审时审视新增的依赖注入。画一个简单的模块依赖图可以快速发现潜在的循环苗头。一些IDE插件或静态分析工具也能帮助检测循环依赖。分层架构清晰严格遵循Controller - Service - Repository/Mapper的分层。禁止层内循环如Service A 调用 Service B Service B 又调用 Service A也禁止反向依赖如Service直接注入Controller。让依赖关系始终保持单向流动。循环依赖报错不是一个需要惧怕的“黑盒”错误。它更像是一个严厉的架构师在提醒你的代码结构出现了不良的耦合。理解其背后的三级缓存机制掌握快速定位和临时解决的方法并最终通过重构走向更清晰的设计是每一位SpringBoot开发者成长的必经之路。下次再遇到这个错误时希望你能从容地把它看作一次优化代码结构的机会。