咱们直接进入正题。做Spring开发的老哥们,特别是项目一复杂就疯狂堆@Service的团队,八成都在启动日志里见过类似“BeanCurrentlyInCreationException”或者“The dependencies of some of the beans in the application context form a cycle”这种报错。当这个报错再叠加AOP(比如事务、异步、自定义注解切面),排查难度直接翻倍,网上资料又一堆互相抄,看得人头大。今天这篇就把Spring中AOP循环依赖这件事从底层原理到实操解法完整梳理一遍,把坑提前踩平。1. 先看真实场景:AOP和循环依赖撞车到底长什么样1.1 一个典型的循环引用案例假设我们有两个Service,业务上很常见:@Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } @Transactional public void createOrder(String userId) { // 业务逻辑 userService.deductBalance(userId, 100); } } @Service public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService = orderService; } public void deductBalance(String userId, int amount) { // 扣余额逻辑 } }如果你在Spring Boot 2.6之前的版本跑这段代码,并且两个Service都加了事务注解,大概率会在启动或者首次调用时出问题。但有意思的是,单纯下面的代码(不加AOP)在Spring Boot 2.6之前是能正常启动的,因为Spring容器内部其实支持单例bean的循环依赖处理,只有构造器注入这种“硬依赖”才会直接卡死。真正让你崩溃的是把@Transactional一层加上去之后。因为事务要创建代理对象,而代理对象的创建时机和循环依赖的“早期引用”时机一旦对不上,容器就不知道该给你原始对象还是代理对象——这个点就是本文的核心矛盾。1.2 循环依赖本质:容器创建bean时的“先有鸡还是先有蛋”循环依赖可以简单理解为:A的创建需要B,B的创建又需要A。放在Spring里,创建bean的过程大致分三步:实例化(new出来)、填充属性(注入依赖)、初始化(执行init方法、AOP代理等)。实例化:调用构造器,生成了一个“半成品”对象,此时属性都是空的。填充属性:把依赖的其他bean注入进来。初始化:执行BeanPostProcessor的扩展逻辑,比如生成AOP代理。问题来了:A实例化后要填充B,于是去创建B;B实例化后要填充A,但A还没走完填充和初始化,它只是个半成品。如果没有特殊机制,这里就死锁了。Spring为了解决单例bean的setter注入循环依赖,设计了三级缓存。但注意,这个方案原本是给“普通bean”设计的,AOP代理的出现会把水搅浑。因为代理对象的生成通常发生在“初始化后置处理”阶段,而循环依赖要求A在“填充属性”阶段就把自己(哪怕是个半成品)提前暴露给别人,这就产生了冲突。2. 解耦前先补基础:Spring三级缓存到底干了什么2.1 三级缓存的结构和各自作用很多人背过三级缓存,但真正懂应用场景的不多。直接看源码定义,Spring在DefaultSingletonBeanRegistry里维护了三个Map:缓存等级数据结构存放内容作用一级缓存singletonObjects完整的单例bean(属性填充完、初始化完、代理也生成完)最终对外提供使用的对象二级缓存earlySingletonObjects早期暴露的bean引用(可能还是半成品,也可能已经是提前生成的代理对象)解决“提前暴露但还没走完完整初始化”的中间状态三级缓存singletonFactories存放ObjectFactory函数式工厂接口真正打破循环依赖的关键,也承担了延迟生成代理对象的责任需要特别强调的是,三级缓存里存的不是一个bean实例,而是一个ObjectFactory。这个工厂接口就一个方法getObject(),它在需要时才会去执行,返回当前bean的早期引用。如果是普通bean,工厂直接返回原始实例;如果bean需要AOP代理,工厂会在这一时刻提前触发代理生成逻辑。2.2 AOP代理和三级缓存之间的微妙平衡为什么非要用ObjectFactory而不是直接存早期对象?因为代理生成的时机太关键了。正常情况下,一个bean经过完整生命周期后,会在初始化阶段由AbstractAutoProxyCreator(Spring AOP的核心处理器)创建代理对象,然后这个代理对象被放进一级缓存。一级缓存里的就是代理对象,你从容器里拿到的、注入到其他bean里的,都是这个代理对象。但循环依赖的场景下,A在填充B的时候就已经要把自己暴露给B了,不可能等A走完初始化阶段。Spring的妥协方案是:在二级缓存里先放一个原始半成品或者“忽如其来”的代理对象,保证B能拿到一个引用。具体流程拆解如下:A开始创建,createBean→ 实例化得到原始A对象 → 把ObjectFactory放入三级缓存。A填充属性时发现需要B,开始创建B实例。B实例化完成后,填充属性时发现需要A,此时从三级缓存里拿到A的ObjectFactory,调用getObject()。关键点:此刻如果A需要AOP代理,getObject()内部会触发getEarlyBeanReference(),提前生成一个A的代理对象,放进二级缓存。B拿到A的引用(可能是代理对象),B后续初始化完成,进入一级缓存。A继续自己的初始化流程,初始化完成后,发现自己在二级缓存已经有对象了(提前生成的代理),于是直接把这个代理对象升级到一级缓存,不再重复创建代理。所以,Spring AOP与循环依赖的兼容,本质上就是“在需要提前暴露的时候,就把代理也提前做好”。3. 既然能兼容,为什么还会出问题3.1 代理对象不一致的隐患虽然上述流程听着顺畅,但也埋了雷:A在早期提前生成了代理对象,后续A自己的初始化阶段又该怎么处理?Spring的AbstractAutoProxyCreator内部维护了一个earlyProxyReferences集合。getEarlyBeanReference()提前创建代理时,会把原始bean名记录到这个集合里。等A走完初始化,postProcessAfterInitialization()执行时,发现自己在早期已经生成过代理,就直接返回早期代理,不再重新代理。这样就保证了容器里最终只有一个代理对象,不会出现“两个代理”的情况。这个机制在Spring AOP传统代理下是自洽的,但某些扩展点会翻车。比如@Async注解就不是走传统AOP的AbstractAutoProxyCreator,它走的是AsyncAnnotationBeanPostProcessor。这个处理器并没有实现getEarlyBeanReference()方法——也就是说,如果循环依赖发生在@Async代理下,Spring无法在早期阶段创建@Async代理,那B拿到的就只是原始A对象,而不是带异步增强的代理对象。结果是:你调A.someAsyncMethod(),方法直接同步执行了,异步注解形同虚设。而且因为这个bug,Spring官方在某个版本后直接禁止了@Async与循环依赖共存,启动直接报错,防止你带病运行。3.2 构造器注入循环依赖:真·死结三级缓存只能救setter注入和字段注入(比如@Autowired字段注入)的循环依赖,对构造器注入完全无能为力。原因是:构造器注入发生在实例化阶段,也就是对象还没new出来之前你就要求注入依赖,此时连“半成品对象”都不存在,三级缓存里根本没有可用的引用。@Service public class A { private final B b; public A(B b) { this.b = b; } } @Service public class B { private final A a; public B(A a) { this.a = a; } }这种代码启动时必报BeanCurrentlyInCreationException,因为构造器的需求太早,Spring找不到任何缓存的早期引用。所以,循环依赖问题最常见的“解锁密码”是setter注入或字段注入。构造器注入虽然官方推荐,但必须配合@Lazy才能解开循环。3.3 Spring Boot 2.6之后的“一刀切”变化Spring Boot 2.6版本做了一个大刀阔斧的改动:默认禁止循环依赖,spring.main.allow-circular-references属性默认设为false。这意味着以前靠setter注入勉强能跑的循环依赖代码,升到2.6以后直接启动失败。我当时接手过这种项目,升级后几十个bean报循环依赖,排查起来非常酸爽。Spring Boot 这个决定本质上是想逼开发者直面循环依赖这个“坏味道”,因为从架构上讲,循环依赖往往意味着设计上耦合过重,应该重构而不是依赖容器兜底。对我们的实操指导意义是:如果项目还在用Spring Boot 2.6以下,可以在配置里开启spring.main.allow-circular-references=true临时续命,但建议把重构循环依赖列入技术债。如果项目已经是2.6及以上,默认就不要指望循环依赖能跑通了,直接按“必须打破循环”的思路去设计。4. 实践中真正有效的四个解法4.1 解法一:重构拆分,消除循环依赖(首选)这是从根上解决问题的方案。循环依赖多半是设计上职责不清导致的。A依赖B、B又依赖A,很多场景可以拆出一个C来承载公共逻辑或者事件解耦。比如前面订单和用户余额的例子,可以引入事件机制或者独立服务:@Service public class OrderService { private final UserQueryService userQueryService; public OrderService(UserQueryService userQueryService) { this.userQueryService = userQueryService; } @Transactional public void createOrder(String userId) { // 创建订单 } }UserService就不再反向依赖OrderService,而是通过发布领域事件让订单侧响应。这种设计干净且符合单一职责,后续扩展也不容易再踩循环依赖。4.2 解法二:利用@Lazy在构造器注入中打破循环当重构代价太大,或者代码已经沉淀很久不想动,@Lazy是性价比最高的解法。它在注入时塞进去的是一个代理占位符,真正调用时才去容器获取目标bean。@Service public class A { private final B b; public A(@Lazy B b) { this.b = b; } } @Service public class B { private final A a; public B(A a) { this.a = a; } }注意@Lazy加在参数上,只对B这个代理生效。A的创建过程中不需要真正实例化B,而是拿到B的一个代理对象;B的创建过程中需要A时,A已经在三级缓存里有早期引用了,可以正常获取。这样两个构造器都能顺利完成。@Lazy代理的关键限制是:B的方法通过代理调用时会触发拦截,如果B的bean尚未创建完成,代理会等容器完成创建后再调用。这在绝大多数业务场景下没问题,但如果你在A的构造方法里直接调用了B的方法,还是会出问题,因为构造阶段B可能还没就绪。4.3 解法三:setter注入/字段注入配合@Autowired在Spring Boot 2.6之前,把构造器注入改成setter注入,很多循环依赖就直接解了。因为setter注入发生在实例化之后,对象已经是半成品,能被B提前引用。@Service public class A { private B b; @Autowired public void setB(B b) { this.b = b; } }字段注入同理:@Service public class A { @Autowired private B b; }这种写法确实省事,但问题也不少:字段注入让依赖变得隐式化,单元测试时不好构造实例;而且没法加final,依赖可能被外部修改。所以就算用field注入解了循环依赖,也建议后续逐步改成构造器注入加@Lazy的组合。4.4 解法四:针对AOP代理的专项调整如果循环依赖已经发生且必须保留,同时AOP代理又是不可或缺的,有几个专项调整手段:调整AOP代理暴露方式:Spring AOP默认是JDK动态代理。在某些情况下,换成CGLIB代理(spring.aop.proxy-target-class=true,其实Spring Boot默认就是CGLIB)可能让早期代理的兼容性更好。一般来说Spring Boot环境不需要手动设置,但如果你的项目是传统Spring XML配置,可以用<aop:aspectj-autoproxy proxy-target-class="true"/>强制启用CGLIB。特殊处理@Async循环依赖:如果循环依赖的对象之间涉及@Async方法,不要指望三级缓存能解决异步代理的早期暴露问题。最快的处理方式是打破循环,其次是把@Async方法挪到单独的bean里,再注入进来。自定义@Aspect切面的循环依赖:我们自己写@Aspect切面时一般不会碰循环依赖问题,因为切面本身是独立bean,切入的目标bean的循环依赖还是得靠Spring的三级缓存来兜底。但注意:切面注入目标bean时也会形成依赖关系,如果切面里注入了被切对象,同样可能出现循环。举个例子:@Aspect @Component public class LogAspect { private final OrderService orderService; public LogAspect(OrderService orderService) { this.orderService = orderService; } @Around("execution(* com.example.OrderService.*(..))") public Object log(ProceedingJoinPoint pjp) throws Throwable { return pjp.proceed(); } }这里LogAspect依赖OrderService,而OrderService又被切面代理,实质上构成AOP切面与目标bean之间的循环依赖。遇到这种情况,优先解除切面对目标bean的依赖,比如通过ApplicationContext延迟获取,或者使用ObjectProvider注入。5. 踩坑实录:AOP循环依赖问题排查与避坑清单5.1 错误信息对照速查表实际开发中,你可能遇到各种各样的启动报错。我整理了几个典型现象和对应原因:报错/现象原因解决方向BeanCurrentlyInCreationException构造器注入形成的循环依赖@Lazy打破,或重构The dependencies of some beans form a cycleSpring Boot 2.6+禁用了循环依赖开启allow-circular-references,或拆循环@Async方法执行不异步,方法同步执行@Async代理无法提前暴露拆循环,或把异步方法独立成beanBeanPostProcessor循环依赖特有报错某些BeanPostProcessor本身需要被代理的对象给BeanPostProcessor加@Lazy,或拆散依赖启动正常,但事务不生效AOP代理对象没被正确注入,注入了原始对象用CGLIB代理,或调整配置强制代理5.2 日志分析:从启动顺序定位循环依赖链条遇到循环依赖报错时,启动日志往往没有把整个引用链完整列出,需要自己分析。我常用的方法是:先看报错是从哪个bean名字开始,找到Currently in creation字样前后的bean链。在application.properties中开启DEBUG日志(logging.level.org.springframework.beans.factory=DEBUG),观察bean创建的顺序。如果项目基于Spring Boot,可以自定义一个BeanPostProcessor打印bean的创建顺序,或者在@Autowired的setter方法里加断点,实际调试时看堆栈调用。例如日志里的线索:org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference?这是典型的因为B创建时又回头找A,但A还没完成整个生命周期。此时直接看orderService和userService的依赖关系,就能快速锁定是哪一对对象在循环。5.3 一个容易忽视的坑:ObjectProvider并不能解决AOP循环依赖有些文章推荐使用ObjectProvider延迟获取依赖,有一定作用但不万能。@Component public class A { private final ObjectProvider<B> bProvider; public A(ObjectProvider<B> bProvider) { this.bProvider = bProvider; } public void doSomething() { B b = bProvider.getIfAvailable(); // ... } }ObjectProvider确实能在构造阶段延迟解析B,但当你真正调用getIfAvailable()时,容器仍然需要完成B的创建。如果B又依赖A,循环依赖依然成立。它更适合解决“可选依赖”或者“无法确定依赖实例化顺序”的复杂场景,而不是循环依赖的银弹。5.4 排查工具和技巧日常排查循环依赖,我推荐几种辅助手段:使用@DependsOn显式声明顺序:这个方法能解决部分启动顺序问题,但它控制的是bean的初始化顺序,不是创建顺序。循环依赖不会因为@DependsOn而消失,反而可能加剧启动失败。通过ApplicationContext.getBeanProvider(Class)在运行时延迟获取依赖,避免bean创建阶段的强依赖,进而解除启动期的循环。把单例改为@Scope("prototype"):原型bean不参与三级缓存,循环依赖会直接创建多份实例,但要注意每次注入都是新对象,业务语义可能改变,别乱用。这些手段的核心思路是:不要让自己在bean创建阶段必须拿到一个尚未初始化的bean。5.5 扩展知识:BeanPostProcessor自身触发循环依赖的诡异问题有一种循环依赖非常隐蔽,与普通业务bean无关,发生在BeanPostProcessor和业务bean之间。Spring在早期阶段就要提前实例化和注册所有BeanPostProcessor,如果我们自定义的BeanPostProcessor恰好注入了某个业务bean,而这个业务bean又依赖另一个业务bean,极端情况下可能触发非常诡异的循环依赖报错。比如:@Component public class MyBeanPostProcessor implements BeanPostProcessor { @Autowired private A a; // 这里注入业务Bean @Override public Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; } }这种设计极其不推荐,因为BeanPostProcessor是容器早期初始化的组件,它一上来就依赖业务bean,容易把创建顺序搅乱。如果业务bean又依赖其他组件,很容易形成环形依赖。正确做法是BeanPostProcessor中不要注入具体业务bean,需要使用时通过ApplicationContext延迟获取,而且只在业务方法里取,不要在处理器初始化阶段强取。6. 最后再给一条走心的建议循环依赖+AOP的问题,本质上是一个“设计缺陷被框架兼容”的麻烦事。三级缓存机制确实优秀,但它在兼容AOP时是小心翼翼打补丁,而不是一种值得信任的常规设计。我经手过几个老项目,很多循环依赖代码早期跑得挺欢,一升级依赖版本或者加了切面就冒出各种毛刺。处理这类问题我现在的习惯是:小循环直接用@Lazy,大循环坚定不移重构,构造器注入和@Async场景绝不硬凑。写完代码后顺手在启动配置里把spring.main.allow-circular-references关掉,让容器强制提醒你是否存在循环依赖,用这种“主动防御”的方式逼自己写出更干净的依赖结构。这样虽然前期改代码有点痛,但后期稳定性真不是靠缓存兜底的项目能比的。
咱们直接进入正题。做Spring开发的老哥们,特别是项目一复杂就疯狂堆@Service的团队,八成都在启动日志里见过类似“BeanCurrentlyInCreationException”或者“The dependencies of some of the beans in the application context form a cycle”这种报错。当这个报错再叠加AOP(比如事务、异步、自定义注解切面),排查难度直接翻倍,网上资料又一堆互相抄,看得人头大。今天这篇就把Spring中AOP循环依赖这件事从底层原理到实操解法完整梳理一遍,把坑提前踩平。1. 先看真实场景:AOP和循环依赖撞车到底长什么样1.1 一个典型的循环引用案例假设我们有两个Service,业务上很常见:@Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } @Transactional public void createOrder(String userId) { // 业务逻辑 userService.deductBalance(userId, 100); } } @Service public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService = orderService; } public void deductBalance(String userId, int amount) { // 扣余额逻辑 } }如果你在Spring Boot 2.6之前的版本跑这段代码,并且两个Service都加了事务注解,大概率会在启动或者首次调用时出问题。但有意思的是,单纯下面的代码(不加AOP)在Spring Boot 2.6之前是能正常启动的,因为Spring容器内部其实支持单例bean的循环依赖处理,只有构造器注入这种“硬依赖”才会直接卡死。真正让你崩溃的是把@Transactional一层加上去之后。因为事务要创建代理对象,而代理对象的创建时机和循环依赖的“早期引用”时机一旦对不上,容器就不知道该给你原始对象还是代理对象——这个点就是本文的核心矛盾。1.2 循环依赖本质:容器创建bean时的“先有鸡还是先有蛋”循环依赖可以简单理解为:A的创建需要B,B的创建又需要A。放在Spring里,创建bean的过程大致分三步:实例化(new出来)、填充属性(注入依赖)、初始化(执行init方法、AOP代理等)。实例化:调用构造器,生成了一个“半成品”对象,此时属性都是空的。填充属性:把依赖的其他bean注入进来。初始化:执行BeanPostProcessor的扩展逻辑,比如生成AOP代理。问题来了:A实例化后要填充B,于是去创建B;B实例化后要填充A,但A还没走完填充和初始化,它只是个半成品。如果没有特殊机制,这里就死锁了。Spring为了解决单例bean的setter注入循环依赖,设计了三级缓存。但注意,这个方案原本是给“普通bean”设计的,AOP代理的出现会把水搅浑。因为代理对象的生成通常发生在“初始化后置处理”阶段,而循环依赖要求A在“填充属性”阶段就把自己(哪怕是个半成品)提前暴露给别人,这就产生了冲突。2. 解耦前先补基础:Spring三级缓存到底干了什么2.1 三级缓存的结构和各自作用很多人背过三级缓存,但真正懂应用场景的不多。直接看源码定义,Spring在DefaultSingletonBeanRegistry里维护了三个Map:缓存等级数据结构存放内容作用一级缓存singletonObjects完整的单例bean(属性填充完、初始化完、代理也生成完)最终对外提供使用的对象二级缓存earlySingletonObjects早期暴露的bean引用(可能还是半成品,也可能已经是提前生成的代理对象)解决“提前暴露但还没走完完整初始化”的中间状态三级缓存singletonFactories存放ObjectFactory函数式工厂接口真正打破循环依赖的关键,也承担了延迟生成代理对象的责任需要特别强调的是,三级缓存里存的不是一个bean实例,而是一个ObjectFactory。这个工厂接口就一个方法getObject(),它在需要时才会去执行,返回当前bean的早期引用。如果是普通bean,工厂直接返回原始实例;如果bean需要AOP代理,工厂会在这一时刻提前触发代理生成逻辑。2.2 AOP代理和三级缓存之间的微妙平衡为什么非要用ObjectFactory而不是直接存早期对象?因为代理生成的时机太关键了。正常情况下,一个bean经过完整生命周期后,会在初始化阶段由AbstractAutoProxyCreator(Spring AOP的核心处理器)创建代理对象,然后这个代理对象被放进一级缓存。一级缓存里的就是代理对象,你从容器里拿到的、注入到其他bean里的,都是这个代理对象。但循环依赖的场景下,A在填充B的时候就已经要把自己暴露给B了,不可能等A走完初始化阶段。Spring的妥协方案是:在二级缓存里先放一个原始半成品或者“忽如其来”的代理对象,保证B能拿到一个引用。具体流程拆解如下:A开始创建,createBean→ 实例化得到原始A对象 → 把ObjectFactory放入三级缓存。A填充属性时发现需要B,开始创建B实例。B实例化完成后,填充属性时发现需要A,此时从三级缓存里拿到A的ObjectFactory,调用getObject()。关键点:此刻如果A需要AOP代理,getObject()内部会触发getEarlyBeanReference(),提前生成一个A的代理对象,放进二级缓存。B拿到A的引用(可能是代理对象),B后续初始化完成,进入一级缓存。A继续自己的初始化流程,初始化完成后,发现自己在二级缓存已经有对象了(提前生成的代理),于是直接把这个代理对象升级到一级缓存,不再重复创建代理。所以,Spring AOP与循环依赖的兼容,本质上就是“在需要提前暴露的时候,就把代理也提前做好”。3. 既然能兼容,为什么还会出问题3.1 代理对象不一致的隐患虽然上述流程听着顺畅,但也埋了雷:A在早期提前生成了代理对象,后续A自己的初始化阶段又该怎么处理?Spring的AbstractAutoProxyCreator内部维护了一个earlyProxyReferences集合。getEarlyBeanReference()提前创建代理时,会把原始bean名记录到这个集合里。等A走完初始化,postProcessAfterInitialization()执行时,发现自己在早期已经生成过代理,就直接返回早期代理,不再重新代理。这样就保证了容器里最终只有一个代理对象,不会出现“两个代理”的情况。这个机制在Spring AOP传统代理下是自洽的,但某些扩展点会翻车。比如@Async注解就不是走传统AOP的AbstractAutoProxyCreator,它走的是AsyncAnnotationBeanPostProcessor。这个处理器并没有实现getEarlyBeanReference()方法——也就是说,如果循环依赖发生在@Async代理下,Spring无法在早期阶段创建@Async代理,那B拿到的就只是原始A对象,而不是带异步增强的代理对象。结果是:你调A.someAsyncMethod(),方法直接同步执行了,异步注解形同虚设。而且因为这个bug,Spring官方在某个版本后直接禁止了@Async与循环依赖共存,启动直接报错,防止你带病运行。3.2 构造器注入循环依赖:真·死结三级缓存只能救setter注入和字段注入(比如@Autowired字段注入)的循环依赖,对构造器注入完全无能为力。原因是:构造器注入发生在实例化阶段,也就是对象还没new出来之前你就要求注入依赖,此时连“半成品对象”都不存在,三级缓存里根本没有可用的引用。@Service public class A { private final B b; public A(B b) { this.b = b; } } @Service public class B { private final A a; public B(A a) { this.a = a; } }这种代码启动时必报BeanCurrentlyInCreationException,因为构造器的需求太早,Spring找不到任何缓存的早期引用。所以,循环依赖问题最常见的“解锁密码”是setter注入或字段注入。构造器注入虽然官方推荐,但必须配合@Lazy才能解开循环。3.3 Spring Boot 2.6之后的“一刀切”变化Spring Boot 2.6版本做了一个大刀阔斧的改动:默认禁止循环依赖,spring.main.allow-circular-references属性默认设为false。这意味着以前靠setter注入勉强能跑的循环依赖代码,升到2.6以后直接启动失败。我当时接手过这种项目,升级后几十个bean报循环依赖,排查起来非常酸爽。Spring Boot 这个决定本质上是想逼开发者直面循环依赖这个“坏味道”,因为从架构上讲,循环依赖往往意味着设计上耦合过重,应该重构而不是依赖容器兜底。对我们的实操指导意义是:如果项目还在用Spring Boot 2.6以下,可以在配置里开启spring.main.allow-circular-references=true临时续命,但建议把重构循环依赖列入技术债。如果项目已经是2.6及以上,默认就不要指望循环依赖能跑通了,直接按“必须打破循环”的思路去设计。4. 实践中真正有效的四个解法4.1 解法一:重构拆分,消除循环依赖(首选)这是从根上解决问题的方案。循环依赖多半是设计上职责不清导致的。A依赖B、B又依赖A,很多场景可以拆出一个C来承载公共逻辑或者事件解耦。比如前面订单和用户余额的例子,可以引入事件机制或者独立服务:@Service public class OrderService { private final UserQueryService userQueryService; public OrderService(UserQueryService userQueryService) { this.userQueryService = userQueryService; } @Transactional public void createOrder(String userId) { // 创建订单 } }UserService就不再反向依赖OrderService,而是通过发布领域事件让订单侧响应。这种设计干净且符合单一职责,后续扩展也不容易再踩循环依赖。4.2 解法二:利用@Lazy在构造器注入中打破循环当重构代价太大,或者代码已经沉淀很久不想动,@Lazy是性价比最高的解法。它在注入时塞进去的是一个代理占位符,真正调用时才去容器获取目标bean。@Service public class A { private final B b; public A(@Lazy B b) { this.b = b; } } @Service public class B { private final A a; public B(A a) { this.a = a; } }注意@Lazy加在参数上,只对B这个代理生效。A的创建过程中不需要真正实例化B,而是拿到B的一个代理对象;B的创建过程中需要A时,A已经在三级缓存里有早期引用了,可以正常获取。这样两个构造器都能顺利完成。@Lazy代理的关键限制是:B的方法通过代理调用时会触发拦截,如果B的bean尚未创建完成,代理会等容器完成创建后再调用。这在绝大多数业务场景下没问题,但如果你在A的构造方法里直接调用了B的方法,还是会出问题,因为构造阶段B可能还没就绪。4.3 解法三:setter注入/字段注入配合@Autowired在Spring Boot 2.6之前,把构造器注入改成setter注入,很多循环依赖就直接解了。因为setter注入发生在实例化之后,对象已经是半成品,能被B提前引用。@Service public class A { private B b; @Autowired public void setB(B b) { this.b = b; } }字段注入同理:@Service public class A { @Autowired private B b; }这种写法确实省事,但问题也不少:字段注入让依赖变得隐式化,单元测试时不好构造实例;而且没法加final,依赖可能被外部修改。所以就算用field注入解了循环依赖,也建议后续逐步改成构造器注入加@Lazy的组合。4.4 解法四:针对AOP代理的专项调整如果循环依赖已经发生且必须保留,同时AOP代理又是不可或缺的,有几个专项调整手段:调整AOP代理暴露方式:Spring AOP默认是JDK动态代理。在某些情况下,换成CGLIB代理(spring.aop.proxy-target-class=true,其实Spring Boot默认就是CGLIB)可能让早期代理的兼容性更好。一般来说Spring Boot环境不需要手动设置,但如果你的项目是传统Spring XML配置,可以用<aop:aspectj-autoproxy proxy-target-class="true"/>强制启用CGLIB。特殊处理@Async循环依赖:如果循环依赖的对象之间涉及@Async方法,不要指望三级缓存能解决异步代理的早期暴露问题。最快的处理方式是打破循环,其次是把@Async方法挪到单独的bean里,再注入进来。自定义@Aspect切面的循环依赖:我们自己写@Aspect切面时一般不会碰循环依赖问题,因为切面本身是独立bean,切入的目标bean的循环依赖还是得靠Spring的三级缓存来兜底。但注意:切面注入目标bean时也会形成依赖关系,如果切面里注入了被切对象,同样可能出现循环。举个例子:@Aspect @Component public class LogAspect { private final OrderService orderService; public LogAspect(OrderService orderService) { this.orderService = orderService; } @Around("execution(* com.example.OrderService.*(..))") public Object log(ProceedingJoinPoint pjp) throws Throwable { return pjp.proceed(); } }这里LogAspect依赖OrderService,而OrderService又被切面代理,实质上构成AOP切面与目标bean之间的循环依赖。遇到这种情况,优先解除切面对目标bean的依赖,比如通过ApplicationContext延迟获取,或者使用ObjectProvider注入。5. 踩坑实录:AOP循环依赖问题排查与避坑清单5.1 错误信息对照速查表实际开发中,你可能遇到各种各样的启动报错。我整理了几个典型现象和对应原因:报错/现象原因解决方向BeanCurrentlyInCreationException构造器注入形成的循环依赖@Lazy打破,或重构The dependencies of some beans form a cycleSpring Boot 2.6+禁用了循环依赖开启allow-circular-references,或拆循环@Async方法执行不异步,方法同步执行@Async代理无法提前暴露拆循环,或把异步方法独立成beanBeanPostProcessor循环依赖特有报错某些BeanPostProcessor本身需要被代理的对象给BeanPostProcessor加@Lazy,或拆散依赖启动正常,但事务不生效AOP代理对象没被正确注入,注入了原始对象用CGLIB代理,或调整配置强制代理5.2 日志分析:从启动顺序定位循环依赖链条遇到循环依赖报错时,启动日志往往没有把整个引用链完整列出,需要自己分析。我常用的方法是:先看报错是从哪个bean名字开始,找到Currently in creation字样前后的bean链。在application.properties中开启DEBUG日志(logging.level.org.springframework.beans.factory=DEBUG),观察bean创建的顺序。如果项目基于Spring Boot,可以自定义一个BeanPostProcessor打印bean的创建顺序,或者在@Autowired的setter方法里加断点,实际调试时看堆栈调用。例如日志里的线索:org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference?这是典型的因为B创建时又回头找A,但A还没完成整个生命周期。此时直接看orderService和userService的依赖关系,就能快速锁定是哪一对对象在循环。5.3 一个容易忽视的坑:ObjectProvider并不能解决AOP循环依赖有些文章推荐使用ObjectProvider延迟获取依赖,有一定作用但不万能。@Component public class A { private final ObjectProvider<B> bProvider; public A(ObjectProvider<B> bProvider) { this.bProvider = bProvider; } public void doSomething() { B b = bProvider.getIfAvailable(); // ... } }ObjectProvider确实能在构造阶段延迟解析B,但当你真正调用getIfAvailable()时,容器仍然需要完成B的创建。如果B又依赖A,循环依赖依然成立。它更适合解决“可选依赖”或者“无法确定依赖实例化顺序”的复杂场景,而不是循环依赖的银弹。5.4 排查工具和技巧日常排查循环依赖,我推荐几种辅助手段:使用@DependsOn显式声明顺序:这个方法能解决部分启动顺序问题,但它控制的是bean的初始化顺序,不是创建顺序。循环依赖不会因为@DependsOn而消失,反而可能加剧启动失败。通过ApplicationContext.getBeanProvider(Class)在运行时延迟获取依赖,避免bean创建阶段的强依赖,进而解除启动期的循环。把单例改为@Scope("prototype"):原型bean不参与三级缓存,循环依赖会直接创建多份实例,但要注意每次注入都是新对象,业务语义可能改变,别乱用。这些手段的核心思路是:不要让自己在bean创建阶段必须拿到一个尚未初始化的bean。5.5 扩展知识:BeanPostProcessor自身触发循环依赖的诡异问题有一种循环依赖非常隐蔽,与普通业务bean无关,发生在BeanPostProcessor和业务bean之间。Spring在早期阶段就要提前实例化和注册所有BeanPostProcessor,如果我们自定义的BeanPostProcessor恰好注入了某个业务bean,而这个业务bean又依赖另一个业务bean,极端情况下可能触发非常诡异的循环依赖报错。比如:@Component public class MyBeanPostProcessor implements BeanPostProcessor { @Autowired private A a; // 这里注入业务Bean @Override public Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; } }这种设计极其不推荐,因为BeanPostProcessor是容器早期初始化的组件,它一上来就依赖业务bean,容易把创建顺序搅乱。如果业务bean又依赖其他组件,很容易形成环形依赖。正确做法是BeanPostProcessor中不要注入具体业务bean,需要使用时通过ApplicationContext延迟获取,而且只在业务方法里取,不要在处理器初始化阶段强取。6. 最后再给一条走心的建议循环依赖+AOP的问题,本质上是一个“设计缺陷被框架兼容”的麻烦事。三级缓存机制确实优秀,但它在兼容AOP时是小心翼翼打补丁,而不是一种值得信任的常规设计。我经手过几个老项目,很多循环依赖代码早期跑得挺欢,一升级依赖版本或者加了切面就冒出各种毛刺。处理这类问题我现在的习惯是:小循环直接用@Lazy,大循环坚定不移重构,构造器注入和@Async场景绝不硬凑。写完代码后顺手在启动配置里把spring.main.allow-circular-references关掉,让容器强制提醒你是否存在循环依赖,用这种“主动防御”的方式逼自己写出更干净的依赖结构。这样虽然前期改代码有点痛,但后期稳定性真不是靠缓存兜底的项目能比的。