☰
Spring循环依赖与AOP代理:三级缓存机制及排查实战
2026/10/20 8:55:32 网站建设 项目流程

前阵子在一个订单与库存联动的老系统上做改造,启动时就栽了跟头:容器直接报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两个Service,A依赖B,B也依赖A,都是字段注入。Spring启动后逐个创建单例Bean,走到创建A时,内部会经历这样一串过程:

  1. Spring开始创建A,通过反射new出A的实例,但此时A的字段还没填充。
  2. doCreateBean里会调用addSingletonFactory,把一个ObjectFactory放入三级缓存,这个工厂后面负责生成A的早期引用。
  3. 开始给A做属性填充,发现A需要注入B,于是Spring转而创建B。
  4. B走同样的流程:实例化后放入三级缓存,接着填充属性,发现需要注入A。
  5. 此时Spring去查询A:一级缓存没有,二级缓存没有,三级缓存有A的工厂。于是执行工厂逻辑,getEarlyBeanReference被调用——如果A身上有切面,这里就会提前生成A的代理对象。
  6. 代理对象(或原始A)被放入二级缓存,B成功拿到A的引用,继续完成B的剩余初始化。
  7. B完成后进入一级缓存,控制权回到A,A继续填入B,完成剩余初始化流程。
  8. 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,这时候如果直接把三级缓存工厂里生成的原始实例交给B,B拿到的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,就会是这个“提前出厂的代理”。

这里的关键触发链是:三级缓存存的是ObjectFactory,ObjectFactory内部调用getEarlyBeanReference,getEarlyBeanReference内部调用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的构造器参数上加@Lazy,Spring注入的不是真实的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的实例先创建出来,再填充B;B创建时又发现需要A,此时A已经在三级缓存里,于是B能拿到A的早期引用。整个过程不需要@Lazy,也不需要改开关。

但我不建议为了过这个坎而把大量构造器注入改成字段注入。构造器注入的优势在于依赖明确、对象不可变、便于单元测试,这些是Spring更推荐的实践。如果项目里已经有测试基类依赖构造器注入,改成字段注入会让测试代码跟着乱。这个方法更适合在开发环境验证理论,真正上线前还是要从设计层面处理循环关系。

3.4 根治思路:把环打破而不是让Spring硬撑

所有“让Spring硬撑过去”的方案都有代价。我见过两个Service互相调用,表面上循环依赖被允许了,切面也能生效,可后来其中一个类加入了@Async,启动直接失败;又或者并发量上来后,某些Bean被其他线程提前拿到半成品,出现属性为null的偶发问题。真正的做法是打破环。

最常见的手段有三种。第一种是抽取公共依赖:A和B互相调用的原因往往是它们共享一部分逻辑,把这部分逻辑下沉到第三个Service,A和B都只依赖这个新Service,环就断了。第二种是用事件解耦:B需要感知A的状态变化时,不直接调用A,而是发布一个事件,由B监听处理,同步调用变成异步解耦。第三种是引入中间门面:两个Service都依赖一个更底层的门面对象,门面负责协调两者,而不是让两者互相感知。

我实际操作过一个订单与库存的老模块,两个Service互相引用是为了“同一个事务里既扣库存又生成订单”。最后我的做法是把库存扣减抽成独立事务组件,订单服务通过门面调用它,不再让库存服务反向依赖订单服务。改完之后启动快了不少,也没有再出现过任何与循环依赖相关的诡异日志。

4. 排查实录、断点验证与避坑清单

4.1 启动报错的常见形态与定位路径

循环依赖的报错形态不止一种,我整理了一下常见的日志片段、原因和定位方向,方便你拿到日志后快速对照。

报错/日志形态常见原因定位优先级
BeanCurrentlyInCreationException+Requested bean is currently in creation构造器循环依赖,或某些框架扩展点提早触发了创建检查是否构造器注入,是否有自定义BeanPostProcessor
The 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开关可以是一行配置就解决的事,但我始终把它当作为老代码续命的备胎,而不是推荐给新项目的常规操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询