Spring循环依赖与AOP代理:三级缓存机制及排查实战
前阵子在一个订单与库存联动的老系统上做改造启动时就栽了跟头容器直接报BeanCurrentlyInCreationException日志指向两个Service互相引用。更麻烦的是这两个Bean都挂着事务切面属于典型的AOP循环依赖叠加场景。很多人一听“循环依赖”就觉得是代码设计问题但加入AOP之后这里面的水比想象中深得多。这篇文章不打算只给一个“改配置”的答案我会把三级缓存、AOP代理生成时机、实际排查手段整条链路讲清楚方便你以后遇到同类问题时不靠猜。1. 循环依赖的基础认知三级缓存是怎么兜底的1.1 哪些循环依赖能救哪些救不了先下一个结论循环依赖能不能被Spring“自动解决”取决于Bean的注入方式而不是简单看“有没有环”。两个Service互相引用如果用的是构造器注入那创建A的时候必须先构造B构造B又必须先构造A此时A的实例根本还没有创建出来Spring再牛也变不出一个半成品只能抛异常。但如果用的是字段注入或者setter注入Bean的实例可以先new出来属性后面再填这就为“提前暴露”创造了条件。Spring官网对这种问题的定义是“unresolvable circular reference”异常信息里也有一句标志性的话Is there an unresolvable circular reference?。这透露出一个信号有些环能解开有些环解不开关键就落在实例化和属性填充之间那段空档。我在项目里还见过更隐蔽的环三个Bean首尾相连A依赖B、B依赖C、C依赖A排查起来比两个Bean互相引用费劲得多。这类环形依赖一旦出现不要急着怪AOP先把构造器注入的嫌疑排除掉再往下看三级缓存干了什么。1.2 三级缓存里的三个“房间”和它们的使命Spring维护单例Bean的缓存核心在DefaultSingletonBeanRegistry这个类里三级缓存实际上就是三个Map。我给它们打比方一级缓存是“上桌的菜”二级缓存是“后厨已经炒到一半的菜”三级缓存是“菜单”——菜单不会直接把菜端给你但厨师可以根据菜单决定要不要先给你炒一版。具体对应关系如下缓存级数据结构存放内容作用一级缓存singletonObjects完成所有创建、初始化、代理包装后的最终Bean正常获取Bean时优先查这里二级缓存earlySingletonObjects提前暴露的半成品Bean可能还没完成属性填充循环依赖发生时依赖方从这里拿到早期引用三级缓存singletonFactoriesObjectFactory工厂不是实例本身在需要早期引用时按需生成可触发AOP提前代理三级缓存里不直接放Bean实例而是放一个工厂。这个设计很关键只要没有任何人提前依赖这个Bean三级缓存里的工厂就永远不执行AOP的“提前代理”逻辑也永远不会触发。所以它做到了按需触发而不是所有Bean一出生就做代理。这里顺带强调一个面试常问的点为什么不能把三级缓存压缩成二级如果只有一级和二级二级缓存里存Bean实例确实能在循环依赖时让B拿到A的半成品。但AOP代理的生成时机就两难了要么在Bean实例化后立刻做代理判断所有Bean都走一遍AOP逻辑开销大且副作用明显要么等初始化完成再做代理可B拿到的是未被AOP包裹的原始A事务注解彻底失效。Spring用三级缓存存工厂把“是否提前生成代理”这个决定延迟到“真的有人需要早期引用”的那一刻相当于用一层间接性换来了精确性。1.3 一条循环依赖链路在Spring内部如何走通画一张静态图不如走一遍真实流程。假设有A和B两个ServiceA依赖BB也依赖A都是字段注入。Spring启动后逐个创建单例Bean走到创建A时内部会经历这样一串过程Spring开始创建A通过反射new出A的实例但此时A的字段还没填充。doCreateBean里会调用addSingletonFactory把一个ObjectFactory放入三级缓存这个工厂后面负责生成A的早期引用。开始给A做属性填充发现A需要注入B于是Spring转而创建B。B走同样的流程实例化后放入三级缓存接着填充属性发现需要注入A。此时Spring去查询A一级缓存没有二级缓存没有三级缓存有A的工厂。于是执行工厂逻辑getEarlyBeanReference被调用——如果A身上有切面这里就会提前生成A的代理对象。代理对象或原始A被放入二级缓存B成功拿到A的引用继续完成B的剩余初始化。B完成后进入一级缓存控制权回到AA继续填入B完成剩余初始化流程。A最终也进入一级缓存整个创建过程结束。看这个流程会发现“二级缓存里的A”和最终放进一级缓存的A其实是同一个对象。因为Spring在A创建结束时有一段earlySingletonExposure的判断如果发现二级缓存里已经有早期暴露的引用就用这个早期引用作为最终暴露对象而不是用初始实例再包一层。这也是循环依赖能维持单例一致性的核心逻辑。2. AOP代理为什么能让问题升级2.1 代理创建的默认时机在Bean生命周期的末尾没有循环依赖时Spring对一个普通单例Bean的处理顺序是实例化、属性填充、Aware回调、初始化方法、初始化后增强。AOP代理的创建就发生在最后一步——BeanPostProcessor#postProcessAfterInitialization阶段由AbstractAutoProxyCreator调用wrapIfNecessary完成。为什么要拖到这么晚因为代理对象要包装的是一个“完整的目标对象”属性填充完了Aware回调执行了PostConstruct和initMethod也跑完了。此时生成的代理调用任何方法都会正确进入切面逻辑。这个顺序在大多数应用里没有问题问题就出在一旦有循环依赖有人会提前索取引用把整个生命周期砍断。我在实际排查时经常跟同事说AOP代理不是“Bean一出生就是代理”而是“Bean长大了才被包一层”。这句话能解释很多诡异问题比如明明类上写了Transactional可方法内部自我调用时事务不生效比如注入的Service类型对不上报类型转换异常。本质上都是“代理还没生成”或者“拿到的是原始对象”导致的。2.2 循环依赖迫使代理提前生成回到循环依赖的链路里当A还没走完生命周期B就要依赖A这时候如果直接把三级缓存工厂里生成的原始实例交给BB拿到的A是一个没有任何切面能力的裸对象。等A走到初始化后阶段再生成代理时B手里持有的还是旧引用等于事务、缓存全部失效。这种问题比启动报错更隐蔽因为应用能跑起来数据却悄悄不对。Spring解决这个矛盾靠的是SmartInstantiationAwareBeanPostProcessor接口它提供了一个getEarlyBeanReference方法允许在“生成早期引用”这个时机提前做代理包装。AbstractAutoProxyCreator恰好实现了这个接口它的实现大致是Override public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }也就是说三级缓存里的ObjectFactory被调用时不是简单返回原始实例而是先问一遍所有SmartInstantiationAwareBeanPostProcessor看这个Bean是否需要提前生成代理。需要就直接包装成代理对象返回不需要就返回原始对象。B拿到的A就会是这个“提前出厂的代理”。这里的关键触发链是三级缓存存的是ObjectFactoryObjectFactory内部调用getEarlyBeanReferencegetEarlyBeanReference内部调用AbstractAutoProxyCreator的包装逻辑。所以只有真正发生循环依赖且需要提前引用时代理才会早产。2.3 earlyProxyReferences如何拦住重复代理既然代理提前生成了那么当A走完初始化流程进入正常的postProcessAfterInitialization阶段时Spring还会不会再代理一次答案是不会。因为AbstractAutoProxyCreator里有一张账本叫earlyProxyReferences。上面的代码里可以看到getEarlyBeanReference把目标Bean放进了这张账本。随后A正常走到postProcessAfterInitialization时代码会先查账本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说明早期阶段已经做过代理这次就直接返回已有对象不再执行wrapIfNecessary。这个设计避免了一个对象被切两层代理也保证了B手里拿到的引用和容器最终存放的引用是同一个。如果你在自定义BeanPostProcessor里手动替换Bean破坏了这种账本关系就会出现“代理套代理”或者切面执行两次的怪象。2.4 一个高频问题三级缓存能压缩成两级吗这个问题值得单独拿出来聊因为它能帮你真正理解ObjectFactory的价值。网上很多文章说三级缓存是为了“解决AOP循环依赖”其实更准确的说法是三级缓存是为了在“不用代理的Bean”和“用了代理的Bean”之间做区分同时尽可能延迟代理生成。如果有二级缓存就够那earlySingletonObjects里的半成品到底是什么它必须是一个已经确定下来的对象。可是A在实例化后没人知道它后面会不会被AOP代理除非把所有Bean都提前做一遍代理判断。系统里一百个Bean只有一个有事务切面却要全部提前生成一遍代理浪费内存是小事切面逻辑在早期执行带来的副作用才是大麻烦。三级缓存用ObjectFactory存“尚未决定的东西”等到有人真的需要早期引用时才执行getEarlyBeanReference做判断。所以它是“延迟决策”的产物而不是单纯多了一层Map。3. 实操四种应对方案与适用场景3.1 应急开关Spring Boot 2.6显式允许循环引用如果你在老系统升级Spring Boot时突然遇到循环依赖启动失败先看一眼版本。Spring Boot 2.6开始spring.main.allow-circular-references默认值改成了false也就是说以前能跑的老项目升级后可能直接在启动阶段被拦下来。这种情况下应急做法是在配置文件里把这个开关重新打开spring: main: allow-circular-references: true或者通过代码设置SpringApplication application new SpringApplication(Application.class); application.setAllowCircularReferences(true); application.run(args);这个方案能让应用“恢复旧行为”但只能说是续命。它只是把默认开关拨回去并没有消除循环依赖本身更不会修复AOP和循环依赖的潜在风险。我一般只在两种情况用一是老系统紧急上线没时间排期改造二是作为排查手段先确认“开关打开后能不能启动”再进一步定位哪些Bean成环。有一点要特别注意构造器注入导致的循环依赖就算打开这个开关也没用因为实例都无法创建三级缓存根本没有机会介入。3.2 局部解环Lazy延迟代理注入Lazy是解决循环依赖性价比很高的一个手段。它的原理是在注入处生成一个代理对象真正调用方法时才去创建目标Bean从而打断“创建顺序”上的环。看个例子Service public class AService { private final BService bService; public AService(Lazy BService bService) { this.bService bService; } }这里A的构造器参数上加LazySpring注入的不是真实的BService而是一个BService的代理。创建A时BService并不需要立刻被实例化等业务代码第一次调用bService的方法时代理才触发真实的Bean创建。两个Service相互依赖时只需要在其中一端加Lazy就能解环。这个方案同样适合AOP循环依赖当A和B都带事务切面且互相引用时在一处加Lazy另一处就能按正常流程创建。不过要注意Lazy让依赖关系变“虚”了第一次调用时会触发Bean初始化如果切面和初始化逻辑较复杂会有短暂卡顿。更关键的是它掩盖了设计问题能跑通不代表关系合理后期维护的人很容易被这种“看不见的延迟”坑到。3.3 快速验证字段注入绕开构造器魔咒前面强调过构造器注入的环解不开字段注入却能靠三级缓存兜底。如果你想快速验证“这个环是不是AOP造成的”把构造器注入临时改成字段注入是一个立竿见影的实验Service public class AService { Autowired private BService bService; }改成字段注入后A的实例先创建出来再填充BB创建时又发现需要A此时A已经在三级缓存里于是B能拿到A的早期引用。整个过程不需要Lazy也不需要改开关。但我不建议为了过这个坎而把大量构造器注入改成字段注入。构造器注入的优势在于依赖明确、对象不可变、便于单元测试这些是Spring更推荐的实践。如果项目里已经有测试基类依赖构造器注入改成字段注入会让测试代码跟着乱。这个方法更适合在开发环境验证理论真正上线前还是要从设计层面处理循环关系。3.4 根治思路把环打破而不是让Spring硬撑所有“让Spring硬撑过去”的方案都有代价。我见过两个Service互相调用表面上循环依赖被允许了切面也能生效可后来其中一个类加入了Async启动直接失败又或者并发量上来后某些Bean被其他线程提前拿到半成品出现属性为null的偶发问题。真正的做法是打破环。最常见的手段有三种。第一种是抽取公共依赖A和B互相调用的原因往往是它们共享一部分逻辑把这部分逻辑下沉到第三个ServiceA和B都只依赖这个新Service环就断了。第二种是用事件解耦B需要感知A的状态变化时不直接调用A而是发布一个事件由B监听处理同步调用变成异步解耦。第三种是引入中间门面两个Service都依赖一个更底层的门面对象门面负责协调两者而不是让两者互相感知。我实际操作过一个订单与库存的老模块两个Service互相引用是为了“同一个事务里既扣库存又生成订单”。最后我的做法是把库存扣减抽成独立事务组件订单服务通过门面调用它不再让库存服务反向依赖订单服务。改完之后启动快了不少也没有再出现过任何与循环依赖相关的诡异日志。4. 排查实录、断点验证与避坑清单4.1 启动报错的常见形态与定位路径循环依赖的报错形态不止一种我整理了一下常见的日志片段、原因和定位方向方便你拿到日志后快速对照。报错/日志形态常见原因定位优先级BeanCurrentlyInCreationExceptionRequested bean is currently in creation构造器循环依赖或某些框架扩展点提早触发了创建检查是否构造器注入是否有自定义BeanPostProcessorThe dependencies of some of the beans in the application context form a cycleSpring Boot 2.6默认禁止循环引用老项目升级后必现看版本、临时开关然后排期重构启动时告警但没报错应用却能跑Spring允许循环依赖时的提示通常伴随AOP不致命但建议清理运行时报“代理对象类型转换异常”AOP代理和注入类型不匹配可能和循环依赖同时出现检查注入接口类型与proxyTargetClass配置拿到异常后先分清楚是启动阶段还是运行阶段。启动阶段多看日志里的“Creating shared instance of singleton bean xx”和报错堆栈这串日志能告诉你到底是哪个Bean在创建时又被反着依赖了。4.2 断点看三级缓存一个实操记录遇到复杂的循环依赖与其看日志猜不如直接在DefaultSingletonBeanRegistry#getSingleton(String beanName, boolean allowEarlyReference)这里打断点。这个方法正好是循环依赖发生时查询三级缓存的地方断点名一看就知道。断点后重点看三样东西singletonObjects、earlySingletonObjects、singletonFactories各自的key集合。正常单例Bean创建时只会在一级缓存里看到Key发生循环依赖时能在二级缓存或三级缓存里看到那个“被提前索取”的Bean。比如singletonFactories里存在orderService说明它还在早期阶段就被别人依赖了。再看AOP代理是否提前生成第二个断点放在AbstractAutoProxyCreator#getEarlyBeanReference。如果代码进入这个方法的断点说明当前Bean正在经历“提前代理”而不是等初始化后才包装。这时候看一眼返回的对象类型如果是com.sun.proxy.$Proxy...或者CGLIB开头的类那可以确认代理确实是在循环依赖的早期阶段被创建的。这种方法比在业务代码里打日志要准确得多能直接把问题钉在“有没有代理、代理何时生成、Bean有没有被重复代理”这三个关键判断上。4.3 AOP与循环依赖搭配时的几个隐藏坑第一个隐藏坑是自调用失效。循环依赖被“解决”后B拿到的是A的代理但A内部自己调用自己的另一个方法时走的是this.method()并不会经过代理对象。比如A里有个Transactional方法A的另一个方法直接调用它事务就不会生效。如果业务代码里存在这种自我调用一旦循环依赖的AOP代理牵扯进来问题会特别难查。应急手段是用AopContext.currentProxy()((OrderService) AopContext.currentProxy()).updateStock();同时要在配置里开启暴露代理EnableAspectJAutoProxy(exposeProxy true)但这只能算绕开长期方案还是把方法拆到独立Bean里从根上规避自调用。第二个坑是多个AOP注解叠加。同一个Bean上同时挂事务和缓存切面时循环依赖会让代理提前生成AOP链路的顺序可能与正常流程有差异。你可能会遇到“缓存方法不缓存了”“事务切面执行了两次”之类的怪异表现。排查时先确认注入进来的对象是不是代理再检查是不是有自定义BeanPostProcessor在提前阶段手动替换了Bean。手动替换Bean很容易破坏earlyProxyReferences的账本机制造成重复代理。第三个坑是半成品并发可见性。循环依赖中B拿到的A可能还没填充所有属性。单线程创建时问题不大但如果在并发启动、某些框架自动刷新容器的场景下其他线程可能在这期间读到未初始化完的A。AOP和异步逻辑同时存在时这种风险会被放大。虽然这是比较边缘的情况但足以说明“允许循环依赖不等于安全”长期运行的系统还是应该远离它。最后说一点个人体会。我在处理这类问题时最大的收获是循环依赖本身不是AOP搞出来的但AOP会把代理生成时机的问题暴露得特别彻底。Spring用三级缓存解决了“既要延迟代理又要在被依赖时提前代理”的矛盾这个设计真的值得反复咀嚼。排查时我习惯先看三级缓存再改代码比对着异常信息瞎猜高效得多。那个allow-circular-references开关可以是一行配置就解决的事但我始终把它当作为老代码续命的备胎而不是推荐给新项目的常规操作。