Spring循环依赖深度解析:三级缓存、AOP代理与源码链路

发布时间:2026/10/5 4:42:47
Spring循环依赖深度解析:三级缓存、AOP代理与源码链路 Spring 的循环依赖几乎每一个用 Spring 做开发的团队都会遇到。有人把它当面试题背得滚瓜烂熟有人被线上BeanCurrentlyInCreationException折腾到凌晨三点。说实话我见过太多人一看到“循环依赖”四个字就直接加Lazy把报错压下去但你要问他 Spring 到底在哪一步“发现事情不对劲”为什么加个Lazy就能活他往往答不上来。这篇文章不打算再复述一遍面试八股而是把异常抛出的准确位置、三级缓存的真实职责、AoP 代理为什么会成为关键变量、以及 getSingleton 这条源码链路完整走一遍最后结合我在真实项目里的治理经验讲讲循环依赖到底应该怎么排查、怎么在设计层面杜绝。无论你是在准备 Spring 面试还是正在查一个诡异的启动失败问题这篇应该都能给你一些不一样的东西。1. 一个“循环依赖”报错背后Spring到底在哪一步放弃的1.1 异常抛出的准确时点正在创建集合的拦截逻辑先看一个最典型的报错现场两个 Service 互相通过构造器注入对方。Component public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } } Component public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService orderService; } }启动 Spring Boot 后控制台会出现这样一句话BeanCurrentlyInCreationException: Error creating bean with name userService: Requested bean is currently in creation: Is there an unresolvable circular reference?很多人都知道这是“循环依赖”但很少有人追到DefaultSingletonBeanRegistry看一眼。容器在创建每个单例 bean 之前都会调beforeSingletonCreation把 beanName 记录到一个名为singletonsCurrentlyInCreation的集合里创建完成后再调afterSingletonCreation把它移除。这个集合的作用就是标记“谁正在被创建”。所以当你创建OrderService时它需要UserService创建UserService时它又需要OrderService。此时OrderService还在singletonsCurrentlyInCreation里躺着呢但它的成品 bean 又没有出现在一级缓存里。容器第二次向getSingleton()要OrderService时发现“你正在创建但你又来要自己而且当前能不能给早期引用在构造器注入的流程里是不能给的”于是不再继续递归直接抛出异常。换句话说Spring 并不是被无限递归搞崩的。它从一开始就通过“正在创建集合”把循环依赖识别为异常防止出现构造器互相调用时那种无解的递归。这也是循环依赖最容易被误解的点很多人以为循环依赖只是“A 引用 B、B 引用 A”这么简单实际上 Spring 判断的是“当前这个 bean 是否还在创建过程中”。1.2 哪些关系会被判为循环单例、跨Bean与假循环循环依赖不一定是 A ↔ B 这种你写我、我写你的两跳关系。A → B → C → A 这种跨越三个 bean 的链路同样会被判定为循环。对容器来说路径长短根本没有意义它只关心一件事我在创建某个单例 bean 的过程中是否又向容器索要了这个当前尚未创建完成的 bean。这里有个容易被忽略的点三级缓存的整套机制只对单例 bean 有意义。原型 bean 每次请求都是新实例没有“创建中的单例可以提前暴露给其他人”这种说法。如果 A 是原型B 依赖 AB 创建过程中又需要 ASpring 会通过prototypesCurrentlyInCreation这个线程级别集合来检测并直接抛出IllegalStateException。网上有时候有人说“把其中一个 bean 改成 prototype 就能打破循环”这个说法只对了一半——如果你把依赖链上某个环节改成非单例恰好切断了创建期的互相等待那确实能绕过但如果循环链路两边都还是原型照样炸给你看。还有一种情况叫“看起来循环但没炸”。比如在其中一个依赖上加了LazySpring 注入的是代理对象真正调用时才去容器里拿目标 bean又或者通过ObjectProviderT延迟获取。这类做法不是“消除了循环”而是把创建期的强依赖降级成了运行期的弱依赖。理解了这一点后面看到 5.2 节里Lazy的使用场景就不会觉得神奇了。2. 三级缓存到底缓的是什么三个Map的角色分工2.1 三个Map各自的职责三级缓存在DefaultSingletonBeanRegistry里是三个成员变量代码位置就在类的最上面/** 一级缓存成品单例 */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存提前暴露的早期对象 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存提前暴露所需的 ObjectFactory */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);这三个 Map 分别对应不同的生命周期阶段可以整理成一张表缓存KeyValue写入时机使用场景singletonObjectsbeanName完成所有初始化的成品单例addSingleton()bean 初始化完成后正常getBean()的最终命中earlySingletonObjectsbeanName尚未完成初始化的早期对象getSingleton()过程中由三级缓存转存而来循环依赖发生时给其他 bean 的提前引用singletonFactoriesbeanNameObjectFactory 工厂对象addSingletonFactory()bean 实例化后、属性填充前延迟决定提前暴露的对象到底是什么我的理解方式是这样的一级缓存是“成品区”二级缓存是“半成品区”三级缓存是“生产计划”。Spring 在创建 bean 的过程中并不是先把对象做好再给别人而是先把一条 ObjectFactory 记录放进三级缓存。当下一个 bean 需要引用这个尚未创建完的对象时才通过singletonFactory.getObject()真正产出“半成品引用”并把它提升到二级缓存。等整个创建流程结束再移到一级缓存。这里有一个关键设计信号三级缓存里存的是ObjectFactory不是对象本体。为什么因为 Spring 也不想在 bean 刚实例化完、还没做属性填充的时候就立刻决定“要暴露原始对象还是代理对象”。它把决策往后延等到有人真的需要这个早期引用时才通过工厂方法去生成。这就是整个三级缓存设计的灵魂——“延迟决策”。2.2 为什么三级缓存机制只对“单例Bean”有意义这个点很多人没细想过。原型 bean 每次从容器拿都是新对象Spring 无法在全局范围对它做“创建中”的跟踪与复用单例 bean 则天生满足两个条件全局唯一且容器完整掌控它的生命周期。所以只有单例 bean 才可以在“实例化完成但是初始化没完成”的中间状态被其他人提前拿走引用。等到整个创建过程走完这个引用依然有效因为最终容器中保存的还是这个实例。如果是原型提前暴露的引用和后来创建的新实例完全不是一个东西缓存它也毫无意义。另外可以注意一个细节二级缓存earlySingletonObjects的初始容量设为 16而一级缓存是 256。这也从侧面说明 Spring 认为“提前暴露”是一个少数场景的临时状态不是常态。常态的 hashCode常态的直接从一级缓存拿成品就行。3. 二级缓存方案为什么兜不住AoP代理是核心变量3.1 没有代理的简化世界二级缓存其实就够这里要回应一个很多人争论的问题网上有人说“Spring 循环依赖只需要一级缓存加二级缓存就够了三级缓存是为了 AoP 才加的”这个说法对不对在我的理解里前半句对后半句需要拆开讲。如果一个系统里完全没有动态代理没有任何BeanPostProcessor会改变 bean 的类型那么三级缓存确实可以被压缩成二级缓存。推演一下A 依赖 BB 依赖 A。A 实例化完成后把它放进二级缓存B 在创建时从二级缓存拿到 A 的原始引用B 完成A 继续完成。因为 A 从头到尾都是同一个原始对象B 手里的引用和最终一级缓存里的引用完全一致。所以纯粹从“打破创建期循环”的角度看只要允许在 bean 未完成时提前暴露一个引用二级缓存就够了。这也解释了为什么很多人吐槽三级缓存多余——在普通业务代码里你写的绝大多数 bean 根本不涉及 AoP三级缓存确实没有被使用到“必须”的程度。3.2 代理对象只能有一个三级缓存把“生成”动作延迟到了最后一刻一旦引入 AoP问题就来了。假设 A 上面加了Transactional或者被EnableAspectJAutoProxy扫描到切面表达式。Spring 在单例创建流程的initializeBean阶段会通过AbstractAutoProxyCreator给 A 生成一个代理对象并把这个代理放进一级缓存。但如果 A 和 B 有循环依赖B 可能在 A 执行到initializeBean之前就已经向容器索要 A 了。如果这里从二级缓存里拿到的是 A 的原始对象等 A 完成代理后一级缓存里存的却是代理对象那么 B 持有的引用和容器最终持有的引用就不是同一个对象。B 调 A 的方法时不会经过事务切面或者 AOP 增强更隐蔽的问题是某些对类型有要求的切点比如基于getClass()的匹配行为会完全对不上。三级缓存解决的正是这个问题。它不是提前把原始对象暴露出去而是注册一个ObjectFactory。当 B 第一次要 A 的早期引用时Spring 调用singletonFactories.get(beanName).getObject()这个工厂内部会经过SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference()。如果 A 需要代理就在这里提前生成代理如果 A 不需要代理就把原始对象返回。之后 Spring 会在earlyProxyReferences里记住“这个 bean 已经提前处理过了”后续在initializeBean阶段不再重复生成代理从而保证 B 手里拿到的 A和容器最终保存的 A是同一个代理对象而且只生成了一次。用一句话概括二级缓存解决的是“能不能提前暴露”的问题但对于有动态代理的 bean如果每次暴露都重新生成代理就会产生多个说法和对象之间的引用分裂。三级缓存的价值在于把“要不要生成代理”的决策推迟到有人真正需要早期引用的时候然后只生成一次并记住结果。4. getSingleton源码链路提前暴露一个Bean的完整时序4.1 关键源码ObjectFactory的注册时机要理解三级缓存绕不开DefaultSingletonBeanRegistry里的两个核心方法。第一个是addSingletonFactory在 bean 实例化完成后、属性填充之前注册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.registeredSingletons.add(beanName); } } }可以看到这一步只是把ObjectFactory放进了三级缓存没有真正生成对象。第二个核心方法是带着allowEarlyReference参数去读取缓存protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }这段代码的逻辑很清楚一级缓存没有看二级二级没有并且允许早期引用才从三级缓存里拿出ObjectFactory调用getObject()生成早期引用放入二级缓存同时把三级缓存里的工厂删掉。注意这里有个细节从三级缓存提升到二级缓存之后下次再有人要这个 bean就直接从二级缓存拿同一个对象不会再走一遍工厂方法。这就是“只生成一次”的保证之一。4.2 A依赖B、B又依赖A时容器内部发生了什么结合上面两个方法我们完整推演一遍A → B → A的创建过程。假设 A 和 B 都用字段注入A 需要 BB 需要 A容器创建 AsingletonsCurrentlyInCreation加入 A。A 完成构造器实例化。addSingletonFactory把 A 的ObjectFactory放入三级缓存此时 A 的对象已经存在但属性还没填充。A 进入populateBean发现自己的字段依赖 B于是执行getBean(B)。容器创建 BsingletonsCurrentlyInCreation加入 B。B 进入populateBean发现自己的字段依赖 A执行getBean(A)。这次getSingleton(A, true)里一级缓存没有 A但isSingletonCurrentlyInCreation(A)返回 true三级缓存里有 A 的ObjectFactory于是调用它生成 A 的早期引用放入二级缓存三级缓存中 A 的工厂记录被移除。B 拿到 A 的早期引用字段注入成功B 继续初始化并完成addSingleton(B)把 B 放入一级缓存。回到 A 的populateBeanB 已经存在A 补齐 B 的字段A 继续完成初始化addSingleton(A)把 A 放入一级缓存。整个过程里B 拿到的是一个半成品 A 的引用但这个半成品 A 在后续初始化完成后会被 Spring 原来的实例直接续上不会有“两份对象”的问题。对于没有 AoP 的 bean它就是同一个原始实例对于有 AoP 的 bean早期阶段已经被getEarlyBeanReference处理成代理而AbstractAutoProxyCreator在initializeBean时会检查earlyProxyReferences发现已经处理过就不再二次代理。这就是为什么循环依赖在字段注入场景下能正常工作而在构造器注入场景下必然失败构造器注入发生在addSingletonFactory之前连三级缓存都还没机会注册自然无法提前暴露。5. 循环依赖失效的坑位与现场特征5.1 构造器注入实例化前没有“提前暴露”的资格前面已经提到三级缓存的addSingletonFactory是在 bean 实例化完成之后才触发的。构造器注入发生在实例化阶段也就是说当 A 的构造器里需要 B、B 的构造器里需要 A 时两个 bean 都没有完成最基础的实例化谁都没有资格进入三级缓存。这带来一个实际开发里的重要推论使用构造器注入的代码在依赖成环时Spring Boot 启动会直接失败而且报错非常明确。这其实不是坏事反而像一个内置的依赖环检测器。很多团队强制构造器注入正是为了让循环依赖问题在编译期、启动期就暴露出来而不是等到运行期出现字段为 null 或者代理失效这种诡异问题。如果你想把循环依赖“藏”起来字段注入确实能藏一段时间但它只是把问题推迟不是解决问题。5.2 Async、Transactional等代理型注解和循环依赖的碰撞动态代理是循环依赖失效的高发区。Transactional对应的AbstractAutoProxyCreator实现了getEarlyBeanReference所以普通的事务代理在循环依赖中能走通但Async的情况要小心很多。AsyncAnnotationBeanPostProcessor在实际实现里并没有像AbstractAutoProxyCreator那样完整参与早期暴露逻辑。当 A 带Async注解并且和 B 形成循环依赖时B 可能在一开始拿到的不是 A 的异步代理对象。等到 A 初始化完成异步代理才生成并放入一级缓存但 B 手里还握着早期拿到的非代理引用。结果就是B 调用 A 的方法时完全不会走异步逻辑表现为“方法明明加了 Async 却不异步执行”或者在某些对类型有强约束的场景直接抛出类型不匹配异常。我的建议很简单写代码时不要同时依赖“循环依赖”和“动态代理”两个特性。如果服务之间确实有循环要么拆掉依赖要么用Lazy把创建期的强依赖转换为运行期的弱依赖确保 B 拿到的 A 是从容器最终状态里取出的代理对象。5.3 单例与原型混用、Spring Boot 2.6的默认开关还有一个特别容易踩的坑单例 bean 依赖原型 bean 时如果只是字段注入拿到的原型其实是“固定的一次性实例”并不会每次调用都新建。这个问题不叫循环依赖但经常和循环依赖一起出现。原型 bean 的getObject()并不会因为你在字段里注入了就每次重新生成真正要用原型作用域得靠ObjectProvider或Lookup方法。更值得关注的是 Spring Boot 2.6 之后的默认行为。从 2.6 开始Spring Boot 默认禁止循环依赖默认配置spring.main.allow-circular-references为 false。很多老项目的代码在 Spring Boot 2.5 及之前能正常启动里面大约有几种循环依赖关系字段注入的、构造器注入的、或者配置类之间形成了环路。升级到 2.6 后启动直接失败报错说的是“The dependencies of some of the beans in the application context form a cycle”。我看到过不少人为了快速上线随手就在配置文件里把spring.main.allow-circular-references改成 true这确实是最快的让旧代码跑通的办法。但我个人的经验是这个开关是给团队争取时间用的不是让你永久依赖它。开着这个开关等于告诉 Spring“让我继续用循环依赖吧”长期积累下来代码里的依赖关系只会越来越混乱。6. 循环依赖的工程化治理排查思路与设计约束6.1 拿到BeanCurrentlyInCreationException后的排查步骤如果你现在正在面对一个循环依赖报错我的建议是不要第一反应加Lazy先按下面这个顺序排查。第一步看异常堆栈。Spring 的报错信息里通常会直接告诉你哪个 bean 正在创建时被再次请求记下这个 beanName。第二步画出依赖图。把自己写的 Java 类之间的引用关系列出来尤其是异常的 bean 到底依赖哪些类型这些类型又反向依赖了谁来。跨超过两个节点的循环很常见别只盯着报错的 bean 看。第三步确认注入方式。字段注入和 setter 注入在三级缓存机制下是可能被救回来的但构造器注入一定失败。如果失败现场是构造器注入你要么真的拆掉循环要么在其中一个依赖上加Lazy。第四步看有没有 AOP 代理的介入。检查异常链路里涉及的 bean 是否加了Transactional、Async或者自定义了BeanPostProcessor、切面表达式。代理型增强和循环依赖叠加时即使是字段注入也可能会运行期行为异常。第五步再检查配置项。如果代码是从老版本 Spring Boot 升级上来的看有没有把spring.main.allow-circular-references设置成 true如果是新项目尽量别开。6.2 我在代码评审里坚持的几条硬约束聊了这么多底层的三级缓存和 AoP 逻辑最后说说治理层面的事。我接手过的几个中型项目里循环依赖大多数不是某个技术方案“必须”这么写而是开发同学为了图方便在 Service 层之间互相调来调去最后拧成了一团。代码评审阶段只要看到新增的依赖关形成环我一般直接打回去。实际操作上我会坚持下面几条第一条新代码统一用构造器注入。这样循环依赖会在启动期直接暴露字段注入虽然写着爽但它把依赖关系藏进了容器内部代码里看不出谁依赖谁隐蔽性太强。第二条禁止跨模块的 Service 互相直接依赖。如果两个服务之间有真实的数据交互需求优先考虑抽出一个中间层把公共逻辑下沉或者在调用方向单独建一个门面服务。循环依赖一旦跨了模块后续改动任何一个模块都可能牵动整条链路。第三条万不得已要用Lazy破循环时必须在加注解的地方写清楚原因和 TODO。Lazy的本质是给 Spring 一个代理对象真正调用时才去拿目标 bean它能盖住循环但没拆掉循环。我见过一个项目里Lazy到处都是后来排查线上问题根本不知道原始依赖是从哪边绕过来的所以留个注释是底线。第四条从架构层面消灭循环。很多 Service 之间互相调用的场景本质上可以通过 Spring 的事件机制或者消息队列来解耦。比如下单后需要减库存不需要订单服务在方法里直接调用库存服务直接发一个OrderCreatedEvent库存服务监听事件去处理就行。这样依赖方向从双向变成了单向天然不会成环。说起事件解耦我再补一句实际操作里的体会。Spring 的ApplicationEventPublisher在同一个进程内解决循环依赖非常顺手但它默认是同步的事件的发布和监听在同一个线程里执行性能上不是没有成本。如果业务上对响应时间很敏感可能要换成一步到位的异步事件或者消息队列。总之先让依赖方向无环再考虑通信方式。最后再分享一个小技巧如果你在编写阶段就能用 IDE 的依赖分析插件比如 IDEA 的Dependencies分析或者ArchUnit这类测试工具来检查包依赖方向效果远比事后人工排查好。我团队里现在的要求就是ArchUnit规则禁止 Service 包之间出现环状依赖发现就直接挂 CI。跑一次不费什么事但能挡住后面大部分循环依赖回归。作为一个被循环依赖折磨过很多次的人我现在的态度很明确三级缓存的机制值得研究透面试和排查都能用上但写业务代码时最好永远别让它发挥作用。