SpringBoot循环依赖:原理、配置与重构策略详解

📅 2026/7/30 8:28:10 👁️ 阅读次数 📝 编程学习
SpringBoot循环依赖:原理、配置与重构策略详解

1. 项目缘起:一个看似简单却暗藏玄机的配置

最近在做一个SpringBoot项目,为了快速实现一个功能,我在两个Service类里互相注入了对方。本地启动时一切正常,但项目一打包部署到测试环境,启动日志里就赫然出现了一行刺眼的警告:“There is a circular dependency between beans”。虽然应用最终还是跑起来了,但这个警告像一根刺,时刻提醒着我架构设计上存在瑕疵。更让我警惕的是,在一些对启动性能和内存有严格要求的场景下,这种循环依赖可能会导致不可预知的问题,比如Bean创建顺序混乱、甚至启动失败。

于是,我开始研究如何在SpringBoot中“允许”循环依赖。注意,这里的“允许”是带引号的。Spring框架本身从设计上就不鼓励循环依赖,因为它破坏了依赖注入(DI)所倡导的单向依赖原则,是代码“坏味道”的一种体现。SpringBoot作为Spring的“脚手架”,其默认配置是严格禁止循环依赖的,目的就是为了在项目初期就暴露这种设计问题。所以,我们首先要明确一个核心观点:开启循环依赖支持,本质上是一种妥协和临时方案,绝非最佳实践。它的正确使用场景,应该是在重构遗留代码、进行短期技术债务处理,或者在某些特定框架集成(如某些旧版第三方SDK强制要求)时,作为一个过渡手段。

那么,SpringBoot是如何控制这个开关的呢?答案就在其自动配置的核心机制里。当我们使用@SpringBootApplication注解启动应用时,它会创建一个AnnotationConfigApplicationContext。在这个上下文的创建过程中,会调用AbstractAutowireCapableBeanFactorysetAllowCircularReferences方法。而SpringBoot通过SpringApplication的运行时环境,默认为我们设置了这个值为false。我们的任务,就是找到并修改这个开关。

2. 循环依赖的三种类型与Spring的解决机制

在动手修改配置之前,我们必须理解我们面对的是什么。循环依赖并非只有一种形式,Spring能处理的其实只是其中一种特定情况。理解这一点,能避免我们错误地认为开启了开关就万事大吉。

2.1 构造器循环依赖(Constructor Circular Dependency)

这是最严重、Spring完全无法解决的一种类型。假设有两个类A和B:

@Component public class A { private final B b; public A(B b) { // A的构造器需要B this.b = b; } } @Component public class B { private final A a; public B(A a) { // B的构造器需要A this.a = a; } }

这种情况下,Spring IoC容器在启动时就会直接抛出BeanCurrentlyInCreationException。原因很简单:要创建A,必须先有B的实例;但要创建B,又必须先有A的实例。这是一个死锁,即使将allowCircularReferences设置为true也无济于事。构造器注入的循环依赖是无解的,必须通过代码重构来打破循环。

2.2 属性(Setter)/字段循环依赖

这是我们最常见,也是Spring在开启支持后能够自动处理的情况。它指的是通过@Autowired注解在字段上,或者通过setter方法进行注入。

@Component public class ServiceA { @Autowired private ServiceB serviceB; // 字段注入 } @Component public class ServiceB { @Autowired private ServiceA serviceA; // 字段注入 }

Spring解决这种依赖的机制非常巧妙,它采用了一种“提前暴露”的解决方案。我们以单例(Singleton)作用域的Bean为例,看看Spring的Bean创建流程:

  1. 实例化(Instantiate):Spring首先调用构造器,创建ServiceA的原始对象。此时对象内的serviceB字段还是null。注意,这个对象虽然还未填充属性(即“半成品”),但已经被创建出来了。
  2. 提前暴露引用(Expose Early Reference):Spring将这个“半成品”的ServiceA对象引用放入一个叫做“早期暴露对象缓存”(earlySingletonObjects)的Map中。此时,其他Bean就可以引用到这个ServiceA对象了,尽管它还不完整。
  3. 属性填充(Populate Properties):Spring开始为ServiceA注入属性。它发现需要注入ServiceB,于是去容器中查找ServiceB
  4. 创建ServiceB:容器开始创建ServiceB,同样经过实例化、提前暴露引用。
  5. 为ServiceB注入属性:当Spring要为ServiceB注入ServiceA时,它不会再去从头创建ServiceA,而是直接从“早期暴露对象缓存”中拿到那个ServiceA的半成品引用,注入给ServiceB。至此,ServiceB创建完成。
  6. 完成ServiceA的创建ServiceB创建完成后,被注入到ServiceA中,ServiceA的属性填充完成,成为一个完整的Bean。

这个过程的关键在于**“提前暴露引用”** 和“三级缓存”机制。三级缓存是Spring解决循环依赖的核心数据结构:

  • 一级缓存(singletonObjects:存放完全初始化好的单例Bean。
  • 二级缓存(earlySingletonObjects:存放提前暴露的、尚未完成属性填充的“半成品”Bean。
  • 三级缓存(singletonFactories:存放创建Bean的工厂对象(ObjectFactory),用于在需要时生成提前暴露的引用。

allowCircularReferences设置为false时,Spring不会将Bean工厂放入三级缓存,也就无法提供早期引用,因此在遇到属性循环依赖时会直接报错。

2.3 原型(Prototype)作用域的循环依赖

对于作用域为prototype的Bean,Spring是完全不支持循环依赖的,无论是否是属性注入。因为原型Bean每次请求都会创建一个新的实例,Spring无法像处理单例Bean那样,通过缓存一个“半成品”引用来解决循环问题。如果存在原型Bean的循环依赖,Spring会直接抛出BeanCurrentlyInCreationException

重要提示:即使你通过配置开启了循环依赖支持,它也只能解决单例作用域下的属性/Setter注入循环依赖。对于构造器注入和原型Bean的循环依赖,配置开关是无效的。

3. 配置允许循环依赖的四种方法及其原理

理解了Spring的处理机制,我们就可以来看如何打开这个开关了。方法有多种,适用于不同的场景和SpringBoot版本。

3.1 方法一:在application.propertiesapplication.yml中配置(推荐)

这是最直接、最“SpringBoot”的方式。对于.properties文件:

spring.main.allow-circular-references=true

对于.yml文件:

spring: main: allow-circular-references: true

原理剖析:这个配置项是在SpringBoot 2.6.0版本中引入的。当你设置这个属性时,SpringBoot的SpringApplication在运行时会读取它,并在创建ApplicationContext之前,通过SpringApplication#setAllowCircularReferences方法,将标志位传递到底层的AbstractAutowireCapableBeanFactory。这是最干净、声明式的配置方式,与SpringBoot的配置哲学一致。

3.2 方法二:通过SpringApplicationAPI编程式设置

如果你需要在代码中动态控制,或者你的启动类有特殊的初始化逻辑,可以使用这种方法。

@SpringBootApplication public class MyApplication { public static void main(String[] args) { SpringApplication app = new SpringApplication(MyApplication.class); // 关键设置:允许循环引用 app.setAllowCircularReferences(true); app.run(args); } }

原理与适用场景:这种方式在SpringApplication实例化后、上下文刷新前设置标志位。它比配置文件更早生效,适合需要根据某些条件(如环境变量、外部配置中心的值)来决定是否开启循环依赖的场景。但通常来说,静态配置更易于维护。

3.3 方法三:自定义BeanFactoryPostProcessor(较底层,慎用)

这是一种更底层、更灵活但也更复杂的方式。你可以通过实现BeanFactoryPostProcessor接口,在Bean工厂标准初始化之后、任何Bean实例化之前,来修改工厂的内部配置。

@Component public class CircularDependencyBeanFactoryPostProcessor implements BeanFactoryPostProcessor { @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { if (beanFactory instanceof DefaultListableBeanFactory) { DefaultListableBeanFactory listableBeanFactory = (DefaultListableBeanFactory) beanFactory; // 直接设置底层Bean工厂的允许循环引用属性 listableBeanFactory.setAllowCircularReferences(true); } } }

原理与风险BeanFactoryPostProcessor是Spring容器扩展机制的一部分,它允许你读取和修改Bean的定义(BeanDefinition),甚至直接操作BeanFactory。这里我们直接获取到DefaultListableBeanFactory并设置其属性。这种方法风险较高,因为它干预了容器的核心初始化流程,如果使用不当(例如,在错误的时机修改了其他属性),可能会导致难以排查的启动问题。除非你有非常特殊的定制化需求(例如,只对某个特定的Bean定义开启循环依赖),否则不建议使用。

3.4 方法四:升级或降级SpringBoot版本(历史版本兼容性)

在SpringBoot 2.6.0之前,并没有spring.main.allow-circular-references这个配置项。在2.6.0版本中,Spring团队为了推动更好的代码实践,默认将此值改为了false。这就是为什么很多项目在升级到2.6.x版本后,突然开始报循环依赖警告的原因。

  • SpringBoot 2.6.0之前:默认是允许循环依赖的(true)。
  • SpringBoot 2.6.0及之后:默认禁止循环依赖(false)。

因此,如果你的项目是一个遗留项目,暂时无法处理大量的循环依赖,一个临时的应对方案是将SpringBoot版本降级到2.5.x或更早。但这绝对是一个下策,因为你会因此错过新版本的性能提升、安全补丁和新特性。正确的做法是将其视为一个升级后的必改项,逐步重构代码消除循环依赖,然后再将配置显式设为false或升级版本。

4. 开启循环依赖后的副作用与长期重构策略

打开了这个开关,警告消失了,世界清净了。但这只是把问题从台前转移到了幕后。我们必须清醒地认识到允许循环依赖带来的潜在风险。

4.1 可能引发的副作用

  1. 启动顺序的不确定性增强:虽然Spring解决了依赖注入,但Bean的@PostConstruct初始化方法、InitializingBean接口的afterPropertiesSet方法,其执行顺序在循环依赖存在时会变得难以预测。如果A和B的初始化方法互相依赖对方的某些状态,可能会导致初始化失败或状态不一致。
  2. 内存泄漏风险:由于Bean之间持有彼此的强引用,在复杂的循环依赖链中,可能会影响垃圾回收(GC)。特别是当你想替换或重新加载某个Bean时,可能会因为引用无法释放而导致内存泄漏或类加载器泄漏。
  3. 单元测试困难:循环依赖使得Bean之间的耦合度极高,难以进行隔离测试。你无法轻松地模拟(Mock)其中一个Bean来测试另一个,因为它们紧密地绑在一起。
  4. 代码理解与维护成本剧增:循环依赖模糊了模块之间的边界和职责。新接手项目的开发者很难理清数据流和控制流,任何修改都可能产生意想不到的连锁反应,使得代码变得脆弱。

4.2 如何识别和重构循环依赖

允许循环依赖应该是暂时的。长期目标必须是重构代码,消除它们。以下是一些实用的重构策略,按推荐程度排序:

策略一:使用Setter/方法注入替代字段注入(治标不治本)虽然Setter注入也能被Spring处理,但它只是将问题从字段层面转移到了方法层面,并没有降低耦合度。它更多是作为一种重构的中间步骤,为引入接口做准备。

策略二:提取公共逻辑到第三个类(引入中介者)这是最常用且有效的方案。如果A和B互相调用是因为它们有共同的业务逻辑,那么就把这部分逻辑抽离出来,形成一个独立的ServiceC。让A和B都依赖于C,从而将A-B的循环依赖,拆解成A->C和B->C的两个单向依赖。

// 重构前 @Service public class OrderService { @Autowired private UserService userService; public void processOrder(Long userId) { User user = userService.getUser(userId); // ... 订单逻辑,其中调用了userService的某些方法 userService.updateUserOrderStats(user); // 反过来调用UserService } } @Service public class UserService { @Autowired private OrderService orderService; public void updateUserOrderStats(User user) { // ... 用户统计逻辑,其中需要查询订单信息 List<Order> orders = orderService.getOrdersByUser(user.getId()); // 反过来调用OrderService } } // 重构后:提取统计逻辑到新服务 @Service public class OrderService { @Autowired private UserService userService; @Autowired private StatisticsService statsService; // 引入新服务 public void processOrder(Long userId) { User user = userService.getUser(userId); // ... 订单逻辑 statsService.updateOrderStatsForUser(user); // 调用中介服务 } } @Service public class UserService { @Autowired private StatisticsService statsService; // 引入新服务 // ... 移除了对OrderService的依赖 } @Service public class StatisticsService { // 专门负责订单和用户的统计逻辑 public void updateOrderStatsForUser(User user) { // 这里可以整合原先分散在两个Service中的统计逻辑 } }

策略三:使用事件驱动(Event-Driven)解耦如果A和B的互相调用并非即时必需,而是“做完某事后通知对方”,那么可以使用Spring的事件机制。A发布一个事件,B监听这个事件并做出响应。这样A完全不需要知道B的存在。

// A发布事件 @Service public class ServiceA { @Autowired private ApplicationEventPublisher publisher; public void doSomething() { // ... 业务逻辑 publisher.publishEvent(new SomethingDoneEvent(this, data)); } } // B监听事件 @Service public class ServiceB { @EventListener public void handleSomethingDone(SomethingDoneEvent event) { // 处理事件,执行相应逻辑 } }

策略四:使用@Lazy注解进行延迟注入(临时解决方案)在注入点添加@Lazy注解,告诉Spring延迟初始化被注入的Bean,或者至少延迟注入代理。这可以解决一部分因启动顺序导致的循环依赖问题,但并没有消除依赖关系本身,只是把问题推迟到了第一次使用时。

@Service public class ServiceA { @Autowired @Lazy // 延迟注入ServiceB private ServiceB serviceB; }

使用@Lazy的注意事项:它创建的是一个代理对象。如果ServiceB不是接口,Spring会使用CGLIB创建子类代理。这可能会影响final方法、private方法,并且在某些调试场景下会增加复杂性。

策略五:面向接口编程,结合@Primary@Qualifier如果循环依赖是因为依赖于具体实现,可以考虑提取接口。让ServiceA依赖于IServiceB接口,而ServiceB实现这个接口。同时,ServiceB也可以依赖于IServiceA接口。这样虽然依赖关系还在,但通过接口进行了解耦,为未来替换实现或引入动态代理(如AOP)提供了可能。在存在多个实现时,配合@Primary(指定首选Bean)或@Qualifier(按名称注入)来明确注入目标。

5. 实战排查:当允许循环依赖后依然报错怎么办?

即使你配置了spring.main.allow-circular-references=true,有时依然会遇到启动错误。这时候就需要进行系统性的排查。

5.1 排查步骤流程图(文字描述)

  1. 确认错误类型:首先看异常栈信息。是BeanCurrentlyInCreationException还是其他错误?
  2. 检查Bean的作用域:如果错误信息里涉及到的Bean是prototype,那么立即可以确定,循环依赖不支持原型Bean,必须重构代码。
  3. 检查注入方式:查看报错Bean的依赖注入方式。如果是构造器注入(即类只有一个构造器,并且参数需要被注入),那么循环依赖是无解的,必须改为属性/Setter注入或重构。
  4. 验证配置是否生效:在应用启动的早期,添加一个调试断点或打印日志,检查AbstractAutowireCapableBeanFactoryallowCircularReferences属性是否为true。有时配置可能因为优先级问题被覆盖。
  5. 检查是否存在间接循环依赖:循环依赖可能不是简单的A->B->A,而是更长的链,比如A->B->C->A。Spring处理长链循环依赖的能力是有限的,尤其是在结合了@Async@Transactional等AOP代理时,可能会因为代理创建顺序问题而失败。
  6. 检查是否有BeanPostProcessor介入:一些自定义的或第三方库的BeanPostProcessor可能会在Bean创建过程中进行额外处理,如果它们对Bean的创建顺序有严格要求,可能会与循环依赖解决机制冲突。
  7. 使用Spring Boot Actuator的/beans端点:如果应用能部分启动,可以通过Actuator查看所有Bean的依赖关系图,直观地定位循环链。

5.2 常见疑难场景分析

场景一:结合@Async@Transactional当一个Bean被@Async@Transactional注解时,Spring会为其创建AOP代理。代理的创建时机可能与原始Bean的创建时机不同,这可能会干扰到基于“早期暴露引用”的循环依赖解决机制。一个典型的错误是:Bean with name ‘xxx‘ has been injected into other beans […] in its raw version as part of a circular reference解决方案:尝试对循环依赖链上的Bean都使用接口,并确保@Async@Transactional注解在接口方法上。或者,将异步或事务操作抽取到另一个独立的Bean中,打破循环链。

场景二:多模块项目中的配置类循环依赖如果使用@Configuration类,并且这些配置类之间通过@Bean方法互相引用,也可能形成循环依赖。虽然配置类本身是单例,但@Bean方法的调用顺序在循环依赖下可能出错。解决方案:将相关的@Bean定义合并到同一个配置类中,或者使用@DependsOn注解显式指定Bean的创建顺序(但这是一种脆弱的解决方案)。

场景三:使用ObjectProvider进行延迟查找(推荐模式)这是Spring官方推荐的一种避免循环依赖和降低耦合度的方式。ObjectProvider允许你延迟获取Bean,它本身不参与循环依赖的解决,而是绕过了问题。

@Service public class ServiceA { // 不直接注入ServiceB,而是注入ObjectProvider @Autowired private ObjectProvider<ServiceB> serviceBProvider; public void doWork() { // 在需要的时候才获取Bean ServiceB serviceB = serviceBProvider.getIfAvailable(); if (serviceB != null) { serviceB.someMethod(); } } }

使用ObjectProviderServiceA在启动时就不再强依赖ServiceB的完整实例,从而打破了循环。这是一种比@Lazy更灵活、意图更明确的方式。

允许循环依赖是一个便捷的逃生舱口,但绝不是舒适的长期居所。每一次打开这个开关,都应该伴随着一个明确的TODO注释和重构计划。通过理解其原理、掌握配置方法、并积极运用重构策略,我们才能逐步构建出更清晰、更健壮、更易于维护的SpringBoot应用架构。最理想的状态是,你的应用在spring.main.allow-circular-references=false的严格模式下,依然能够顺利启动。