Spring循环依赖与AOP代理:三级缓存原理及排查实战
开头你们有没有遇到过这种情况项目里两个Service互相调用结果Spring启动的时候直接给你甩一个BeanCurrentlyInCreationException然后你查了一圈才发现这两个Bean在构造函数或字段注入上形成了循环依赖。更麻烦的是如果其中一个Bean还被AOP切面代理过循环依赖的报错信息会更绕可能会让你怀疑是不是切面表达式写错了实际上根本不是那么回事。Spring的循环依赖问题尤其是AOP场景下的循环依赖是面试高频题也是实际开发中非常容易踩坑的点。很多朋友对Spring三级缓存的原理能背得滚瓜烂熟但一放到AOP代理的场景里就解释不清了为什么普通循环依赖能解决一加AOP就报错为什么第三级缓存存的是ObjectFactory而不是直接存一个对象为什么有的循环依赖在Spring Boot 2.6之后的版本里直接就拒绝启动了这篇文章我会从三级缓存的工作原理讲起重点拆解AOP和循环依赖之间的博弈关系再结合源码走向、实际报错场景和排查思路把这块内容彻底讲透。无论你是准备面试还是正在被循环依赖折磨这篇文章应该都能帮到你。1. 先搞清楚循环依赖到底是什么1.1 循环依赖的定义与出现场景循环依赖在Spring里指的不是什么高深概念就是Bean A的创建过程中需要用到Bean B而Bean B的创建过程中又需要用到Bean A双方互相等待形成死锁。打个比方你和朋友约饭你等他确定地点他等你确定时间俩人都坐在家里干等着谁都动不了。在Spring容器中循环依赖主要有三种表现形式构造器注入循环依赖A的构造器需要BB的构造器需要A。这种情况Spring无法解决启动直接抛BeanCurrentlyInCreationException原因是构造器在Bean创建的最早期执行此时A和B都还未完成实例化谁都没法提前暴露自己。字段注入或Setter注入循环依赖A的属性需要BB的属性需要A。这种情况Spring可以解决因为Bean的属性注入发生在实例化之后实例化完成的对象可以提前暴露出去给另一方使用。AOP与循环依赖结合A需要BB需要A同时A或B还被切面代理。这种情况能不能解决要看代理创建时机和缓存设计是否匹配这也是本文的重点。先说结论字段注入和Setter注入的循环依赖Spring能解构造器注入的循环依赖Spring解不了只能改代码。这里我就不绕弯了直接告诉大家正确的处理姿势。注意Spring Boot 2.6.0之后官方默认把spring.main.allow-circular-references设为false也就是说即使你写的是字段注入循环依赖默认情况下Spring也会直接拒绝启动。这个我们在后面的章节专门展开这里先知道有这回事。1.2 三级缓存的结构与核心作用Spring解决循环依赖的关键不是某个单一机制而是一套三级缓存组合它们在DefaultSingletonBeanRegistry里被定义为三个Map。我先把结构摆出来再逐一说明。// 一级缓存存放已经完全创建好的单例Bean private final MapString, Object singletonObjects new ConcurrentHashMap(256); // 二级缓存存放早期暴露的Bean已实例化但属性未完成注入 private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); // 三级缓存存放创建Bean的ObjectFactory用来生成早期暴露的对象 private final MapString, ObjectFactory? singletonFactories new HashMap(16);一级缓存singletonObjects很好理解就是Spring最核心的单例池我们平时通过getBean()拿到的成品Bean都在这。二级缓存earlySingletonObjects存放的是早期Bean也就是已经实例化、但属性还没注入完成的半成品这个Map存在的意义是提前把Bean暴露出去供循环依赖的另一方使用。三级缓存singletonFactories存放的是ObjectFactory它像一个延迟执行的工厂能在需要的时候生成早期Bean。那一级缓存足够存成品Bean了为什么还需要二级和三级这个问题要结合Bean的生命周期来看实例化new出对象 - 属性填充注入依赖 - 初始化处理Aware接口、执行init方法、创建AOP代理等。属性填充在这个顺序里发生在初始化之前如果A和B循环依赖A在填充属性时发现需要B于是转去创建BB在填充属性时发现需要A这时候A还没有走完初始化流程没有被放进一级缓存但A已经实例化完毕可以被提前暴露。二级缓存干的就是这事。三级缓存存在的核心原因就是AOP代理。如果Bean没有被代理那么二级缓存其实就够了A把实例化好的原始对象放进二级缓存B拿去用完事。但如果A还要被切面代理而AOP代理是在初始化阶段才创建的那么B在属性填充阶段拿到的A到底是原始对象还是代理对象如果B拿到的是原始对象而A最终以代理对象的形式放进一级缓存那B内部持有的A就不是最终的单例对象程序跑起来会出现行为不一致。Spring解决这个问题的思路很巧妙属性填充阶段不直接暴露原始对象而是暴露一个ObjectFactory等B真正需要A的时候再通过这个工厂决定返回原始对象还是代理对象。这个“按需决策”的机制就是第三级缓存存在的意义。2. AOP代理与循环依赖如何互相影响2.1 AOP代理的创建时机正常无循环依赖的流程中AOP代理是什么时候创建的很多人以为是Bean实例化后立刻就代理了其实不是。AOP代理的创建发生在Bean初始化阶段的BeanPostProcessor回调里具体来说是AbstractAutoProxyCreator这个类充当了关键角色。AbstractAutoProxyCreator实现了SmartInstantiationAwareBeanPostProcessor接口它会在Bean的实例化阶段提前介入一些决策在初始化阶段完成代理。用代码来理解的话Spring的Bean生命周期可以简化成下面几条线实例化 - 属性填充 - 初始化(AOP代理在此阶段创建) - 放入一级缓存如果不存在循环依赖一个被AOP切面命中的Bean的完整流程是Spring通过构造器new出原始对象然后填充属性接着执行各类初始化逻辑最后在初始化后期通过AbstractAutoProxyCreator.postProcessAfterInitialization()判断当前Bean是否需要被代理如果需要就生成代理对象并让一级缓存中保存的是代理对象而不是原始对象。要注意的是这里有一个关键术语叫“代理对象”。它包装了原始Bean在调用方法时先经过切面逻辑再调用原始方法。如果没有循环依赖这个代理过程在幕后悄悄完成使用方根本感知不到可一旦存在循环依赖另一个Bean在填充属性时就需要提前获取这个还没完成代理的对象问题就来了。2.2 为什么普通的循环依赖坏不了事为了说清楚AOP在循环依赖中的特殊地位我先讲一个最简单的普通循环依赖场景A和B互相依赖两者都没有被任何切面代理。整个过程中Spring是怎么处理的我把它展开成步骤逐步看。Spring开始创建A调用A的构造器完成实例化得到一个原始对象aOriginal。Spring检测到当前创建流程属于单例Bean把aOriginal包装成ObjectFactory放入三级缓存也就是singletonFactories。继续处理A的属性填充发现A依赖B于是调用getBean(B)。Spring发现B尚未创建开始创建B实例化得到bOriginal同样把B的工厂放入三级缓存。处理B的属性填充发现B依赖A于是调用getBean(A)。此时A已经在一级缓存找不到还没创建完成但在三级缓存中找到了A的工厂调用工厂的getObject()因为A没有代理需求直接返回原始对象aOriginal并把这个对象提升到二级缓存earlySingletonObjects。B顺利拿到A的“早期引用”完成属性填充和初始化最终把自己放入一级缓存。回到A的创建流程B已经创建完毕A成功完成属性填充和初始化把自己放入一级缓存。整个过程的核心在于第2步即使A还没有完成属性填充Spring也允许先把“半成品”的工厂暴露出去让B可以拿到A。这相当于你还没开始装修房子就先把钥匙给了朋友让他先进屋坐一坐不完美的点在于屋里还没装好灯但至少人能进去了。2.3 AOP循环依赖为什么会特殊普通循环依赖中把原始对象暴露出去完全没问题因为最终放到一级缓存的就是这个原始对象本身。但加了AOP就不同了Spring希望最终放在一级缓存的是代理对象这样所有从容器里获取的Bean都是统一走代理逻辑的。问题在于代理对象的创建按标准流程发生在初始化阶段而循环依赖中另一方在属性填充阶段就需要Bean了此时代理还没创建。如果直接把原始对象暴露出去B持有的A就是原始对象而容器中最终保存的A是代理对象那么B内部持有的A和通过getBean(A)拿到的根本不是同一个引用AOP通知对B内部持有的A完全不生效。Spring的第三级缓存就是为了解决这个时序矛盾。三级缓存中存的不是Bean本身而是一个ObjectFactory这个工厂可以返回一个“早期引用”在早期引用生成时会检查Bean是否需要被代理如果需要就提前创建代理对象。注意这里的关键词是“提前”两个字。当B在填充属性时触发getBean(A)Spring从三级缓存中拿到A的ObjectFactory调用它的getObject()方法。AbstractAutoProxyCreator中有一段特殊逻辑它会通过getEarlyBeanReference()方法提前创建AOP代理并返回。这样一来B拿到的就不是原始对象而是已经提前生成的代理对象。AOP和循环依赖的博弈核心概括成一句话在有循环依赖的Bean上AOP代理的创建时机从“初始化阶段”提前到了“提前引用暴露阶段”。这是一个很有趣的工程决策Spring为了不让B拿到错误的对象宁可把代理创建的时机往前提。代价也有就是那些依赖初始化完成状态才能织入的切面在循环依赖场景下可能会表现异常。这块我会在第4章详细说。3. 源码怎么走从ObjectFactory到getEarlyBeanReference3.1 addSingletonFactory对象是怎么被提前暴露的看源码最能说明问题。Spring的doCreateBean()方法里有一段代码负责把刚实例化完的Bean暴露到三级缓存中。我摘出核心逻辑// AbstractAutowireCapableBeanFactory#doCreateBean boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }这段代码的逻辑很清晰如果当前Bean是单例、容器允许循环引用并且该Bean正在创建中就调用addSingletonFactory。注意传入的Lambda表达式它引用了getEarlyBeanReference方法而这个方法只有在二级缓存或三级缓存被真正获取时才会执行达到了“延迟决策”的目的。addSingletonFactory本身做了什么看一下DefaultSingletonBeanRegistry的实现protected void addSingletonFactory(String beanName, ObjectFactory? singletonFactory) { synchronized (this.singletonObjects) { if (!this.singletonObjects.containsKey(beanName)) { this.singletonFactories.put(beanName, singletonFactory); this.earlySingletonObjects.remove(beanName); this.registerSingleton(beanName, singletonFactory); } } }它只是把工厂放进了singletonFactories这个Map里同时删掉二级缓存中可能存在的旧引用并做了一次注册。这里还没有真正创建代理真正的魔法在getEarlyBeanReference里。3.2 getEarlyBeanReference提前创建代理的关键方法getEarlyBeanReference定义在AbstractAutowireCapableBeanFactory中它的职责是让SmartInstantiationAwareBeanPostProcessor有机会提前包装这个Beanprotected 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; }真正实现getEarlyBeanReference方法并且与AOP强相关的是AbstractAutoProxyCreatorOverride public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }wrapIfNecessary是创建代理的核心方法它会检查当前Bean是否匹配任何切面表达式如果匹配就创建代理对象如果不匹配就返回原对象。这里有个细节getEarlyBeanReference会把Bean先存到earlyProxyReferences这个Map里标记为“这个Bean已经进行过提前代理判断了”避免后面重复代理。3.3 earlyProxyReferences如何防止重复代理再说一个重要细节就是earlyProxyReferences的查重机制。如果A在循环依赖中被提前代理了一次等到A走完初始化流程进入postProcessAfterInitialization时Spring需要保证不能再次创建代理否则会从“代理对象”变成“代理对象套代理对象”切面会被执行两次这在实践中会和事务切面叠加时产生很隐蔽的Bug。AbstractAutoProxyCreator.postProcessAfterInitialization是这样写的Override public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean ! null) { Object cacheKey getCacheKey(bean.getClass(), beanName); if (this.earlyProxyReferences.remove(cacheKey) ! bean) { return wrapIfNecessary(bean, beanName, cacheKey); } } return bean; }看到没它先尝试从earlyProxyReferences中移除当前Bean如果移除的值和当前Bean是同一个对象说明提前代理已经做过了直接返回原Bean不再代理。如果移除的值不是同一个说明这个Bean在提前代理阶段没被处理过才在初始化完成后执行标准代理逻辑。这套机制保证了“既然早期已经给了别人代理对象最终容器里保存的也必须是同一个代理对象”不会出现两个不同的代理实例。这是循环依赖场景下AOP能够正确工作的重要保障也是面试里很容易被追问的细节。4. AOP循环依赖涉及的关键问题排查4.1 构造器注入的AOP循环依赖为什么解不了前面说过构造器注入的循环依赖Spring解不了但在AOP场景下有些同学可能会疑惑第三级缓存不是可以提前创建代理吗为什么构造器循环依赖还是不行原因在于时序。Spring创建Bean的第一步就是实例化也就是调用构造器。在这个时间点Bean还没有完成任何对象的创建Spring并没有一个可以被暴露的半成品。你可以理解为人还没出生怎么能把身份证先发出去三级缓存中的ObjectFactory需要包装一个已经实例化出来的对象而构造器阶段这个对象还不存在所以无从暴露。假设A和B都是构造器注入对方创建A - 调用A构造器 - 发现需要B - 创建B - 调用B构造器 - 发现需要A - 获取A - A还未实例化三级缓存里什么都没有 - 直接报错。中间没有任何回旋余地。应对方案有两个。一是把某个Bean的构造器注入改成字段注入或Setter注入这样实例化可以先完成后面的属性填充阶段就能利用三级缓存解决循环依赖。二是用Lazy注解懒加载其中一个依赖让Spring注入一个代理占位符真正调用时才初始化目标Bean。第二种在构造器循环依赖里是最常用的解法。4.2 Spring Boot 2.6之后为什么默认拒绝循环依赖我们项目之前升级Spring Boot时就遇到过一个很有意思的情况代码一行没改升级完之后启动直接报错提示The dependencies of some of the beans in the application context form a cycle。原因就是Spring Boot 2.6.0开始官方将spring.main.allow-circular-references默认值改成了false。官方为什么做这个调整核心原因是循环依赖本身就是一种设计上的坏味道它让Bean之间的耦合变高也让依赖关系变得模糊不清。Spring官方认为与其让开发者依赖循环依赖“能跑就行”不如直接从启动阶段就暴露问题促使开发者重构代码。这有点像编译器把警告升级为错误逼着你处理。如果项目确实有历史遗留的循环依赖又一时半会改不完可以通过配置文件临时开启spring.main.allow-circular-referencestrue用YAML写法也行spring: main: allow-circular-references: true但我的建议是这个开关只适合作为过渡手段长远来说还是要消除循环依赖。因为AOP和代理的组合下循环依赖带来的隐患往往比普通场景更严重后面我会举具体例子。4.3 Async与循环依赖的经典坑Async注解配上循环依赖是实际开发中比较高发的坑报错信息也非常有辨识度BeanCurrentlyInCreationException: Error creating bean with name asyncService: Bean with name asyncService has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean.为什么会这样因为Async的代理是由AsyncAnnotationBeanPostProcessor创建的但这个处理器没有实现getEarlyBeanReference的提前代理逻辑。通俗说它只进行了“延迟代理”也就是在初始化完成后才创建代理对象并不会在早期暴露阶段为循环依赖另一方提前返回代理。于是出现了一个诡异的情形B在属性填充阶段从三级缓存拿到的A是原始对象A初始化完成后被Async处理器包装成了代理对象并放入一级缓存。B持有的A是原始对象容器里最终是代理对象两者不一致。Spring检测到这个情况后为了防止运行时出现诡异问题直接抛出了异常并告诉你“beans do not use the final version of the bean”。解决办法有两个方向。一是去掉循环依赖这是根本解法二是对该Bean去除Async换用别的方式实现异步或者把异步调用抽到一个独立的Bean里。4.4 Transactional循环依赖为什么有时候正常有时候不正常Transactional和循环依赖的情况就比Async微妙一些因为事务代理仍然通过AbstractAutoProxyCreator这条路径创建所以getEarlyBeanReference是支持提前代理的。这意味着当需要提前暴露时事务切面也会被应用到早期代理中B一般能拿到最终的代理对象不会报错。但要注意如果切面表达式依赖于初始化阶段才能确定的信息那提前代理就可能导致切面没有生效或者生效不完整。比如某个切面在postProcessAfterInitialization阶段会读取缓存属性或某些初始化后状态来决定是否执行通知而提前代理发生在初始化之前这些状态可能还没准备好。这类问题不会让你启动失败但会让AOP行为变得难以预测排查起来非常花时间。还有一种典型情况是方法内部调用。假设A类里有个方法被Transactional标注A依赖BB又依赖A然后在一个事务上下文里A的方法调用了自己类的另一个方法。由于代理对象内部调用时不经过代理事务通知可能就失效了。这和循环依赖不直接相关但循环依赖的存在会让人更难发现问题的根源。所以说能不用循环依赖就别用这是降低心智负担的最好办法。5. 实战构造一个带AOP的循环依赖并定位问题5.1 示例代码复现AOP循环依赖讲了这么多原理我们直接上代码来复现一遍。下面这个示例里两个Service互相依赖并且其中一个被AOP切面代理我以纯注解配置的方式搭一个最简场景。先定义一个简单的切面打印方法调用的日志Aspect Component public class LogAspect { Around(execution(* com.example.service.OrderService.*(..))) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { System.out.println(切面前置逻辑 joinPoint.getSignature().getName()); Object result joinPoint.proceed(); System.out.println(切面后置逻辑); return result; } }再写两个互相依赖的ServiceOrderService会被切面命中Service public class OrderService { Autowired private UserService userService; public void createOrder() { System.out.println(创建订单); } }Service public class UserService { Autowired private OrderService orderService; public void getUser() { System.out.println(获取用户); } }注意OrderService的createOrder()方法匹配了切面的切点表达式所以它会被AOP代理。这时候直接启动Spring Boot项目按默认配置运行你会看到两种可能的输出如果你的Spring Boot版本低于2.6.0或者显式开启了allow-circular-referencestrue项目能正常启动。如果你的Spring Boot版本是2.6.0以上且没有额外配置启动就会直接报循环依赖错误。不管哪种情况OrderServiceBean内部真正引用的UserService以及UserService内部真正持有的OrderService它们是否指向同一个代理对象是值得怀疑的这就是这类问题的隐患所在。5.2 报错信息怎么看定位问题的通用方法一旦循环依赖报错Spring的堆栈信息其实给出了很明确的指引。我以最常见的错误为例信息一般长这样APPLICATION FAILED TO START Description: The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderService (field userService) ↑ ↓ | userService (field orderService) └─────┘这已经非常直白了它告诉你orderService和userService通过字段互相引用形成了环。如果你还看不到是哪个Bean的哪个字段导致的就顺着这个环找到对应的类检查它们的注入方式。如果报的是类似4.3节那种“has been injected into other beans in its raw version”的错误则说明问题不是简单的循环依赖而是“循环依赖 代理创建时机不匹配”的复合问题。这时候核心排查思路是看谁在循环依赖中被代理了那个代理处理器是否支持提前代理。5.3 排查AOP循环依赖的实操清单我把排查AOP循环依赖的步骤整理成一个清单方便实际项目里按图索骥第一步确认循环依赖是否存在。看启动日志的循环链路或者用IDEA插件/DependsOn定位依赖关系理清环上的每个Bean。第二步确认环上有没有AOP来凑热闹。检查每个Bean上是否有Transactional、Async、自定义切面等环上的Bean被代理得越多问题越复杂。第三步确认Spring Boot版本和allow-circular-references配置。2.6以上默认关闭循环依赖遇到报错先看这个配置。第四步判断代理类型。Transactional走AbstractAutoProxyCreator通常支持早期代理Async走AsyncAnnotationBeanPostProcessor不支持早期代理。第五步根据结论决定方案。能重构就重构不能重构就用Lazy实在不行再开allow-circular-references作为临时方案。这套流程我实测过很多次排查成功率非常高最核心的点是不要上来就怀疑切面配置先理清依赖链路再分析代理时机。6. 从设计层面避免AOP循环依赖的思路6.1 为什么循环依赖经常成为“坏味道”在代码设计上循环依赖往往是分层不够清晰的表现。理想情况下Service层应该只依赖下一层的Repository或外部的接口A Service依赖B Service通常意味着它们之间有较强的业务耦合如果这个耦合还是双向的那基本可以断定这两个Service的职责边界有问题。打个比方你和同事同时负责一个模块你写的代码需要调用他的方法他的代码也需要调用你的方法。第一天大家觉得方便配合默契等项目变大你们发现谁都不敢改自己的接口因为一改就影响对方。最后只好互相约定都不动代码质量就开始变差了。这和Bean之间的循环依赖是一个道理短期能用长期是负担。6.2 常见的重构方案与取舍要消除循环依赖常用的方案有几种按优先级排序的话我建议这样做提取公共依赖把A和B共同依赖的逻辑提取到独立的Service或组件里让A和B各自只依赖第三方打破双向环。引入事件机制如果A调用B仅仅是为了通知B做某件事可以改成发事件由事件监听器去触发B的逻辑彻底解耦。使用Lazy延迟注入在依赖的一方加上Lazy注解让Spring注入一个代理占位符实际第一次调用时才创建真正的Bean。它能解决循环依赖问题但是是把“问题延后”不是真正的解耦。重构调用方向分析A依赖B的真正原因看能不能改成B依赖A或者让调用的发起方改为C把双向依赖变成单向链条。其中提取公共依赖和引入事件机制是我用得最多的两种方案。它们不仅解决了循环依赖往往还顺便提升了代码的可测试性和可维护性。6.3 我个人的实操体会我在实际项目里的习惯是新写的代码一律不允许出现循环依赖Code Review阶段看到循环依赖的代码直接打回。旧代码里的循环依赖会排期逐步重构不会因为“能跑”就无限期放着。有个很典型的案例之前维护的一个老项目订单模块和用户模块互相调用形成了循环依赖。由于订单服务被Transactional和自定义切面双重代理虽然启动没报错但偶尔出现事务不生效的情况排查了整整一天。最后定位到根因是早期代理导致代理对象和容器最终对象混乱虽然那次问题不是循环依赖直接触发的但循环依赖放大了AOP行为的不可预测性。后来我们把两个Service公共的部分抽出来做成了一个订单用户聚合服务循环依赖消除后相关的诡异问题再没出现过。这也是我建议所有人在处理AOP循环依赖时要有的心态不要满足于“能启动”要追究“为什么能启动”和“有没有隐患”。Spring的三级缓存解决的是循环依赖的可用性问题但AOP的介入会让这个“可用”打折扣只有从设计层面避开循环依赖才能让AOP切面行为变得可控。结尾最后再分享一个小技巧如果你被AOP循环依赖折磨得够呛又一时改不完代码可以先在配置里临时打开allow-circular-references把项目跑起来同时用Lazy对环上的关键依赖做延迟注入先把问题缓解住。但一定要记得在代码里加个TODO注释标记清楚这里存在循环依赖等后面有时间了再彻底重构。踩过几次坑之后我个人最大的体会是Spring的三级缓存和getEarlyBeanReference机制设计得非常精巧但再精巧的机制也替代不了清晰的设计。依赖关系一旦乱掉再怎么靠框架能力兜底都会留下系统性风险。AOP代理、事务切面、异步增强这些高级特性叠加在循环依赖之上时出问题的概率会成倍上升。如果你能把代码设计成无环依赖那AOP的一切行为都会回到最可控的状态你也就不需要再为这些边界情况头疼了。