Spring三级缓存:循环依赖的解决边界与AOP代理原理

发布时间:2026/10/12 15:09:29
Spring三级缓存:循环依赖的解决边界与AOP代理原理 先说结论Spring 的三级缓存并不是为所有循环依赖准备的。能解决的多半是单例作用域下的 setter 注入或者字段注入遇到构造器注入该报错还是报错。这个结论我愿意放在最前面因为很多 Java 面试者一上来就背singletonObjects、earlySingletonObjects、singletonFactories这三个 Map 的名字背得滚瓜烂熟却说不清这机制边界在哪。我第一次看到循环依赖问题时也有类似困惑Spring 明明号称“帮你管理对象”结果 A 依赖 B、B 依赖 A 难道不应该报错吗后来翻源码才理解它不是靠什么魔法而是把 Bean 的“实例化”和“初始化”拆开在中间悄悄做了一次“提前暴露”。这篇文章就要把这个串起来讲清楚包括缓存里到底存了什么、哪些场景救不了以及面试被问到底应该怎么回答。1. 循环依赖到底在“循环”什么1.1 一段一眼就能看出来的循环先看最典型的场景Component public class OrderService { Autowired private UserService userService; public void order() { userService.getUser(); } } Component public class UserService { Autowired private OrderService orderService; public void getUser() { orderService.order(); } }OrderService需要UserServiceUserService又需要OrderService。如果用最直觉的方式创建先 new 一个OrderService发现缺UserService于是去 newUserService又发现缺OrderService于是再回去 newOrderService。这样无限递归创建永远结束不了。但你把这个代码放进 Spring只要默认配置没改它大概率能正常启动。为什么因为它处理“创建对象”的方式和人类直觉不一样。1.2 单例 Bean 的生命周期里藏着转机Spring 管理一个单例 Bean看起来是一个“创建”动作其实至少拆成两个阶段实例化调用构造方法new出一个 Java 对象出来此时对象还是半成品属性是 null依赖没有填充。初始化完成属性填充、执行InitializingBean、PostConstruct、各种BeanPostProcessor等。假如没有中间这个缝隙A 和 B 循环引用确实无解。但因为有“实例化”和“初始化”两个阶段就可以做到先把 A 实例化出来作为一个临时引用暴露出去再让 B 去依赖这个临时 AB 初始化完成后回过头再给 A 填充依赖 B 的属性。整个过程绕了一圈但最终所有 Bean 都能拿到完整对象。这就像装修房子你先把 A 房间的墙砌好一半就让 B 房间的人开始铺地板等 B 房间干完了再回头给 A 房间刷漆。只要房间不会塌大家最终都能住进去。1.3 面试官真正想听的点面试官问“三级缓存”往往不是只背名字。他真正想确认的是你知道 Spring 单例池里对象是什么时机进、什么时机出的你知道“循环依赖”为什么在构造器注入时无解你知道三级缓存存在的意义不只是解循环还牵扯到 AOP 代理的提前暴露问题。这些东西如果只看八股文容易背完就忘。下面我把三级缓存一层层拆开再配合源码流程讲一遍。2. 三级缓存的本质是什么2.1 三个 Map 的分工Spring 的核心实现位于DefaultSingletonBeanRegistry里面有三个最关键的 Map缓存字段类型存储内容放入时机第一级缓存MapString, Object完整单例对象Bean 初始化完成后第二级缓存MapString, Object早期暴露对象半成品Bean 实例化后、尚未完全初始化时第三级缓存MapString, ObjectFactory?对象工厂Bean 实例化后准备暴露前有人习惯把它们叫“一级、二级、三级”也有人叫“成品池、早期池、工厂池”。记住用途比记住名字更重要一级缓存singletonObjects里面放的是大家都认识的、完整可用的单例对象。Spring 启动完成后所有正常单例都在这里。二级缓存earlySingletonObjects里面放的是“已经 new 出来但还没完成属性填充”的早期对象。它的存在是为了防止重复创建也为了防止第三级缓存被重复调用。三级缓存singletonFactories里面既不是成品也不是对象而是ObjectFactory函数式接口。每次需要提前暴露对象时通过这个工厂得到“当时的引用”。2.2 getSingleton 方法中的查找顺序要理解三级缓存建议直接看getSingleton(String beanName)的逻辑我在源码注释基础上简化成下面这段顺序protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 第一级直接从成品池拿 Object singletonObject this.singletonObjects.get(beanName); // 如果成品池没有并且这个 Bean 正在创建中 if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { // 第二级从早期池拿 singletonObject this.earlySingletonObjects.get(beanName); // 第二级也没有才轮到第三级 if (singletonObject null allowEarlyReference) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { // 通过工厂生成对象并放入二级缓存 singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }整个查找顺序是singletonObjects ↓ 未命中 earlySingletonObjects ↓ 未命中 singletonFactories - 通过工厂生成早期引用这也就是很多人背的“三级缓存查询顺序”。但请注意第二级缓存并不是纯粹的过渡一旦第三级工厂被调用生成对象会被放入第二级缓存第三级缓存里对应的工厂会移除。这样下一次再有人要同一个 Bean 时就直接从第二级拿避免了工厂被反复执行。2.3 第三层为什么是 ObjectFactory 而不是直接放对象很多初学者会问既然要多级缓存那二级不行吗为什么非要用ObjectFactory这个问题背后藏着一个大坑AOP。Spring 的 AOP 代理是在 Bean 初始化完成之后通过BeanPostProcessor把原始对象替换成代理对象。比如原始UserService类最终放进单例池的可能是一个UserService$$EnhancerBySpringCGLIB代理类。假设没有第三级A 暴露给 B 的是一个纯原始对象A等 A 初始化完成后如果 A 还要被 AOP 增强最终单例池里会替换成代理A。但 B 里持有的还是原始A这就会导致 B 调用的方法没有增强效果。第三级缓存里的ObjectFactory负责解决这一点。Spring 在创建 A 的早期会往singletonFactories里放一个工厂addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, beanObject));当 B 需要提前引用 A 时调用这个工厂返回的就不一定是原始对象而可能是经过SmartInstantiationAwareBeanPostProcessor处理后的早期代理对象。这样 B 一开始拿到的就是代理最终和单例池里的对象一致。所以你可以这么理解二级缓存是“对象缓存”三级缓存是“改造对象能力的步骤缓存”。三级的存在让 AOP 代理在循环依赖场景下也能保证引用一致性。3. 能救的和救不了的循环依赖3.1 字段注入和 setter 注入为什么能解决字段注入就是写Autowired直接放在成员变量上setter 注入则是放在 set 方法上。这两种方式都允许 Spring 先实例化对象再回头填充属性。所以 A 实例化后可以立刻暴露B 依赖 A 时能拿到 A 的半成品等到 B 创建完再反过来填充 A。此时一级缓存里也有完整 A、完整 B 了循环结束。这里有一个容易被忽略的细节B 拿到“早期对象”后随后A继续完成初始化并成为成品。但 B 里的引用并不会被替换它从一开始指向的就是 A 的内存地址。只要后期没有 AOP 代理改变对象类型这个引用就一直有效。这也是为什么很多人说“Spring 解决循环依赖的核心是提前暴露引用”。3.2 构造器注入为什么死路一条再看构造器注入Component public class A { private final B b; public A(B b) { this.b b; } } Component public class B { private final A a; public B(A a) { this.a a; } }创建 A 时必须先调用new A(someB)。可是someB还没创建出来要创建B又必须先调用new B(someA)。两个对象都需要对方先构造完毕这就变成了真正的“先有鸡还是先有蛋”实例化这个环节就卡住了。因为 Spring 还没有办法在你调用构造方法前“提前暴露”一个对象给你所以构造器循环依赖一律报错报错信息通常长这样BeanCurrentlyInCreationException: Error creating bean with name a: Requested bean is currently in creation: Is there an unresolvable circular reference?面对这种问题最简单的临时办法是在某个构造参数上加Lazypublic A(Lazy B b) { this.b b; }加上Lazy后注入的不再是真正的 B而是一个代理对象直到你真要调用 B 方法时才去容器里拿这样就把不可解的构造循环变成“可延迟解析”的伪循环。但它只是权宜之计不能替代代码层面的依赖重构。3.3 原型 Bean 和异步注入场景救不了三级缓存解决循环依赖是大前提的Bean 必须是单例作用域依赖必须发生在属性填充阶段参与循环的 Bean 必须是在同一个容器生命周期内创建的。如果你把 Bean 定义成Scope(prototype)Spring 每次获取都创建新对象它根本不会把对象放进单例池也没有“正在创建中”这个状态标记三级缓存无从谈起。原型作用域下的循环依赖同样会直接抛出BeanCurrentlyInCreationException。这个知识点面试也会问你可以一句话总结只有“单例 字段/setter 注入 提前暴露创建中的对象”这个组合才能被 Spring 的三级缓存化解。3.4 Spring Boot 2.6 以后默认禁止循环依赖这点容易被老程序员忽略。Spring Boot 2.6 之前循环依赖默认是允许的从 2.6 开始官方把spring.main.allow-circular-references默认值改成了false。也就是说你从 Spring Boot 2.5 升到 2.6 后原本能启动的项目可能会直接报循环依赖异常。官方给出的理由很直接循环依赖往往意味着设计有问题。它增加理解成本也容易让 AOP 引用关系变得混乱。如果真的需要保留旧行为可以在配置里打开spring: main: allow-circular-references: true但我个人不建议你这么干。明知有循环还要硬开开关等于把设计问题藏起来。更好的办法是找到依赖环抽离公共逻辑或者用事件机制、中间层解耦。4. 实战排查与面试答题套路4.1 如何快速定位循环依赖循环依赖在实际开发中不一定写在两个类里可能绕了五六个类才回到起点。启动失败时异常栈会明确告诉你The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderController ↑ ↓ | orderService ↑ ↓ | userService └─────┘但日志有时候只显示两个 Bean真正的环可能更长。我的定位习惯是先在 IDEA 里打开依赖关系图CtrlAltShiftU查看类依赖图循环关系一眼能看出来。再逐个看Autowired字段排除被Lazy代理或Resource指定名称的情况。最后用排除法注释掉某些依赖看容器能否启动逐步缩小环的范围。有一个容易忽略的情况循环依赖有时不是“两个核心业务 Bean”之间直接产生的而是通过ConfigurationProperties、Value、BeanPostProcessor或 AOP 切面间接产生的。这种问题靠肉眼找很痛苦可以把 Spring 的 debug 日志打开搜索Created singleton和getSingleton相关日志能明显看到 Bean 创建顺序。4.2 面试时的高分回答模板面试官如果问“Spring 怎么解决循环依赖”我建议按四步回答第一步先定义问题。循环依赖是指多个 Bean 之间互相依赖形成闭环比如 A 依赖 B、B 依赖 A。第二步讲核心思路。Spring 把 Bean 创建拆成实例化和初始化两步在实例化完成之后、初始化完成之前将对象提前暴露到一个缓存中让其他 Bean 可以先引用到这个半成品。第三步说出三级缓存的名字和职责singletonObjects完整成品earlySingletonObjects提前暴露的半成品或已经生成的早期引用singletonFactories对象工厂用于延迟生成早期引用也负责处理 AOP 代理。第四步讲边界条件。这种方法只适用于单例作用域下的字段注入或 setter 注入构造器注入和原型作用域无法解决。如果涉及 AOP第三级工厂会通过getEarlyBeanReference提前生成代理对象避免代理对象与注入引用不一致。这套回答下来面试官基本能确认你读过源码而不是只背了三个名称。4.3 关于三级的几个易错点我自己也踩过要我说这类题目最容易踩的坑有三个第一个坑是死记“三个 Map 的名字”但说不清第三级的工厂到底存了什么。可以这样记一级存对象二级存早期对象三级存函数。第二个坑是以为“循环依赖被 Spring 解决所以可以随便写”。其实从 Spring Boot 2.6 开始默认已经不允许循环依赖。如果项目里出现循环优先考虑重构而不是直接调allow-circular-references。我见过有的同学把配置改回true后看着服务器起来了结果某个代理方法一直不生效就是因为早期引用和最终代理不一致。这个排查过程极其消耗时间。第三个坑是忽略 AOP 代理和循环依赖的交互。早期引用如果直接把原始对象暴露出去后期这个 Bean 被代理就可能出现“B 持有的 A 是原始类容器里的 A 是代理类”的诡异现象。理解了第三级缓存的getEarlyBeanReference机制才能解释为什么必须在三级缓存里用工厂而非直接放 Bean。4.4 一个实用的临时逃生舱Lazy我平时写代码是推荐构造器注入的因为依赖更明确也方便写单测。但构造器注入遇到循环依赖确实麻烦。在代码重构完成前可以给导致循环的构造参数加LazyService public class PayService { private final OrderService orderService; public PayService(Lazy OrderService orderService) { this.orderService orderService; } }Lazy会让 Spring 先注入一个 JDK 动态代理或 CGLIB 代理真正调用时才去容器获取目标 Bean。它能打破构造阶段的僵局但副作用是第一次调用可能会比平时慢一些因为代理要延迟初始化。它适合做临时方案不适合长期依赖。4.5 如果要从根上解决应该怎么做长期来看循环依赖通常意味着类职责划分不够清晰。比如 A 和 B 互相调用可能是因为公共逻辑放错了位置。我的习惯是把互相依赖的逻辑下沉到第三个类 C让 A 和 B 都依赖 C使用 Spring 事件机制解耦A 发布事件B 监听事件两者不直接持有对方引用把依赖注入改成方法参数传递有些跨模块调用根本不需要构造器注入。这些方式不仅解决了“能不能启动”的问题也让代码更容易测试和扩展。面试最后如果被问“你项目里遇到过循环依赖吗”你可以回答“遇到过但我重构掉了”这比背出整套源码细节更容易加分。回到最初的话题三级缓存确实是 Spring 最经典的面试题之一但比背缓存更重要的是先判断这个循环依赖属于哪类场景。能救的用缓存机制解决救不了的该重构重构该加Lazy加Lazy。把这个边界意识建立起来面试和实战都不会慌。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询