
如果你在项目里见过BeanCurrentlyInCreationException应该能想起那种场景业务还没跑起来启动日志刷了半天最后抛出一个circular reference字样。绝大多数人的第一反应是搜解决方案然后加上Lazy注解问题消失。但如果你追问一句——Spring 容器为什么能用三级缓存处理循环依赖而不是两级——能当场讲清楚的人其实不多。这篇文章决定把这个话题彻底拆开讲。我尽量绕开教科书式的定义从源码和实际场景出发把三级缓存的来龙去脉、为什么需要第三级、以及哪些场景下这个设计依然救不了你一次性说透。适合正在准备 JVM/Spring 面试的人、平时读 Spring 源码读到一半卡壳的人、以及被循环依赖问题反复折磨过想彻底弄明白的人。1. 循环依赖问题在 Spring 里的真正含义1.1 循环依赖到底指什么循环依赖说的是A 依赖 BB 又依赖 A形成环形引用。在 Spring 容器管理的对象关系里又有构造器注入循环和 setter/字段注入循环之分。举一个最典型的例子Service public class AService { Autowired private BService bService; } Service public class BService { Autowired private AService aService; }两个 bean 都是单例、都是字段注入。启动时容器先创建 AA 填充属性时发现需要 B于是触发 B 的创建B 填充属性时又发现需要 A此时 A 还没初始化完成就会形成一个僵局。这种情况在真实项目里非常常见尤其是拆分了多个业务模块之后几个核心服务互相引用几乎是常态。Spring 之所以能扛住这种僵局靠的就是DefaultSingletonBeanRegistry里的三级缓存。但注意它并不是万能的构造器注入的循环、原型作用域prototype的循环Spring 至今也处理不了后面我会专门讲。1.2 创建 Bean 的三段式流程是提前暴露的前提要理解三级缓存先要理解 Spring 创建单例 bean 的三个阶段实例化Instantiate通过构造器new出一个原始对象此时对象里的依赖属性全是空的。属性填充Populate执行Autowired、Resource等依赖注入给字段赋值。初始化Initialize执行InitializingBean#afterPropertiesSet、PostConstruct、init-method等初始化逻辑。Spring 官方文档对这三步讲得很简单但三级缓存的秘密就在第一步和第二步之间。A 完成实例化、还没填充 B 的时候它其实已经是一个可用的 Java 对象了——只是里面的属性是 null。如果在这时把这个半成品提前暴露出去让 B 拿到 A 的引用先做着等 A 把属性填充完、初始化完B 手里的引用依然指向同一个对象引用自动变成了完整品。这就是一个很反直觉的点对象还是那个对象只是在不同的时间点看起来状态不同。提前暴露一个半成品总比重启链路去等待对方完成要好得多。三级缓存的设计思路本质上就是先拿引用后补状态。2. 三级缓存结构与其完整读写链路2.1 三个 Map 各自的职责三级缓存的代码在DefaultSingletonBeanRegistry里三个 Map 定义如下// 一级缓存完整初始化之后的单例对象 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 的分工非常清晰一级缓存singletonObjects是最终形态。bean 完成了实例化、属性填充、初始化可能还被 AOP 代理过了才放进这里。getBean拿到的通常都是它。二级缓存earlySingletonObjects是半成品的早期引用。它的出现时机很短bean 实例化完成后、初始化完成前如果被其他 bean 引用过就会被塞进这里。三级缓存singletonFactories存的是工厂函数。它不是存对象而是存一个如何生成早期对象的工厂核心是延迟和决策。缓存层级对应 Map存储内容生命周期一级缓存singletonObjects完整的、可对外服务的单例对象容器启动到销毁二级缓存earlySingletonObjects提前暴露的半成品对象从被引用到 bean 创建完成三级缓存singletonFactories生成早期对象的工厂从实例化完成到对象创建完成2.2 getSingleton 的读取顺序三级缓存的读取逻辑集中在getSingleton(String beanName, boolean allowEarlyReference)这个方法里。源码逻辑大致如下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) { synchronized (this.singletonObjects) { // 加锁后再确认一级、二级 singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { // 第三步从三级缓存取工厂调用 getObject() 生成早期引用 ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); // 生成后放入二级缓存同时移除三级缓存 this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }这个读取链路的规则可以总结为六个字一级优先逐级降级。当一个 bean 在创建过程中被另一个 bean 依赖时getSingleton会先看一级缓存没有就判断这个 bean 是不是正在创建中如果是就去二级缓存找二级还没有才轮到三级缓存里的工厂去现场生成。注意一个关键细节三级缓存里的工厂getObject()被调用一次之后结果会立即被提升到二级缓存同时三级缓存里的工厂会被移除。也就是说任何一个 bean 的早期引用在整个创建周期内只会通过工厂生成一次。之后不管是哪个对象再来找它拿到的都是二级缓存里那同一个实例。这里有一个很容易问倒人的追问那如果从头到尾都没有人循环引用它呢答案是三级缓存里的工厂也会在 bean 创建完成后被正常清理不会残留无用工厂。2.3 写入三级缓存的两个条件三级缓存不是对所有 bean 无条件开放的。在AbstractAutowireCapableBeanFactory#doCreateBean方法里有一段这样的逻辑boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }earlySingletonExposure翻译过来是是否允许暴露早期单例引用它有三个条件必须是单例 bean。prototype 每次getBean都新建对象容器不缓存暴露了也没有意义。容器没有关闭循环引用。Spring Boot 2.6 默认把allow-circular-references关了这种情况下即便你是 setter 注入启动也会直接报错。当前 bean 确实在创建中。如果一个 bean 已经是完整状态了根本不会走到这一段。只有这三个条件全部满足Spring 才会把getEarlyBeanReference的 Lambda 表达式包装成ObjectFactory放到三级缓存里。顺带说一句为什么不是四级缓存的理论在源码里也有答案三级缓存已经做到了生成一次、重复引用、最终一致再往多加一层完全冗余没有信息量。理解了这一点面试时被追问Why not four levels也就不会慌。3. 为什么两级不够第三级缓存真正解决的是代理一致性3.1 两级缓存方案在 AOP 场景下的失败推演很多人读到这就会想既然二级缓存已经存了早期对象为什么还要三级缓存里的工厂直接实例化完把原始对象扔进earlySingletonObjects不就完了吗这个思路在最简单的场景下是可行的——只要不涉及 AOP 代理。但 Spring 的绝大多数 bean 都跟代理有交集Transactional需要代理Cacheable需要代理自定义切面也需要代理。假设只用两级缓存推演一下会发生什么A 实例化完成原始对象rawA被塞进二级缓存。B 填充属性时getBean(A)拿到rawA注入成功。A 继续走初始化流程走到 BeanPostProcessor 阶段时AOP 切面判断这个类需要代理于是生成代理对象proxyA放进一级缓存。问题来了B 内部持有的引用是rawA而容器最终对外暴露的是proxyA。B 调aService.doSomething()时执行的是原始方法事务、切面全部失效。这个 bug 一旦发生不是抛异常而是静默失效属于最恶性的线上事故类型。而且从 JVM 层面讲一个对象的字段一旦被写入引用就没有办法在运行时把这处引用改成另一个对象——除非用反射甚至 Unsafe 去暴力替换Spring 不可能这么干。所以二级缓存方案的问题本质是早期引用和最终对象可能会不一致。一旦牵扯到代理你让别的 bean 提前拿到的那个原始对象就成了永远都无法被修正的错误引用。3.2 ObjectFactory 延迟创建的真正价值三级缓存对上述问题的解法是不要在一开始就把对象定死而是把一个工厂放进去。当 B 真正需要 A 的时候通过singletonFactories.get(beanName)拿到工厂再调用factory.getObject()由工厂去执行getEarlyBeanReference。关键就在这个getEarlyBeanReference方法它是 Spring 判断当前这个 bean 需不需要被代理的时机。如果发现需要代理它就现场返回一个代理对象如果不需要代理返回原始对象。无论哪种情况从工厂里出来的对象就是最终会被容器持有的那个形态。于是循环引用的时序变成了这样A 实例化完成三级缓存放入singletonFactoryA。B 创建时发现需要 A调用singletonFactoryA.getObject()得到提前代理好的 A或原始 A塞进二级缓存。B 注入 A 成功。A 继续初始化BeanPostProcessor 执行时发现这个 bean 已经被代理过了不再重复代理一级缓存直接放入与二级缓存一致的对象。从这个过程可以看出第三级缓存里的工厂承担了一个路由决策点的角色它把引用到底是什么形态这个决策权从 A 的实例化时刻推迟到了 B 真正引用 A 的时刻。这个延迟就是三级比两级多出来的核心价值。3.3 两级 vs 三级差异对照为了更直观我把两种方案放到同一张表里对比对比维度两级缓存方案三级缓存方案提前暴露的内容原始对象直接放入二级缓存ObjectFactory 工厂按需生成早期引用循环依赖注入速度快拿到就用略慢工厂需要执行一次创建逻辑无 AOP 时结果可用可用有 AOP 时结果B 拿到原始对象代理生成后引用不一致切面失效B 拿到代理对象或原始对象与最终容器一致是否存在静默失效风险是否Spring 还能兜底检测实现复杂度低略高这张表也解释了另一个常见疑问既然大多数 bean 不需要 AOP为什么不为它们跳过三级缓存理论上可以但 Spring 在getBean时不知道一个 bean 后续会不会被代理也不知道引用它的 bean 会在哪一刻发起调用。为了一个统一的、可预测的对象生命周期模型它选择了让所有单例 bean 在创建期间都具备可提前暴露的能力统一走三级缓存。这是一个典型的空间换正确性、复杂度换确定性的取舍。4. getEarlyBeanReference 与 AOP 代理生成的协作细节4.1 SmartInstantiationAwareBeanPostProcessor 在早期引用中的作用三级缓存里那个工厂实际调用的方法是AbstractAutowireCapableBeanFactory#getEarlyBeanReference它的实现非常精练protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (BeanPostProcessor bp : getBeanPostProcessors()) { if (bp instanceof SmartInstantiationAwareBeanPostProcessor) { SmartInstantiationAwareBeanPostProcessor sap (SmartInstantiationAwareBeanPostProcessor) bp; exposedObject sap.getEarlyBeanReference(exposedObject, beanName); } } } return exposedObject; }Spring 会遍历容器里的所有SmartInstantiationAwareBeanPostProcessor把早期引用交给它们过一遍。正常情况下这个接口的实现者只有AbstractAutoProxyCreator及其子类——也就是 Spring AOP 的自动代理创建器。AbstractAutoProxyCreator#getEarlyBeanReference的逻辑是判断当前 bean 是否需要代理如果需要就调用wrapIfNecessary提前创建代理对象Override public Object getEarlyBeanReference(Object bean, String beanName) throws BeansException { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, Boolean.TRUE); return wrapIfNecessary(bean, beanName, cacheKey); }注意这里一个很关键的操作在决定提前代理之前先把cacheKey放进earlyProxyReferences这个集合里做标记。标记的含义是这个 bean 的代理我已经提前处理过了后面你正常初始化时不要再管它。4.2 earlyProxyReferences 怎么防止代理被创建两次如果一个 bean 在循环依赖中被提前代理了等它走完属性填充、进入正常的 BeanPostProcessor 阶段时Spring AOP 还有一次机会对它执行postProcessAfterInitialization。如果不做防重处理代理就会被创建两次第一个代理对象可能被丢弃行为完全不可预期。AbstractAutoProxyCreator的postProcessAfterInitialization是这样写的Override public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean ! null) { Object cacheKey getCacheKey(bean.getClass(), beanName); if (this.earlyProxyReferences.remove(cacheKey) ! null) { // 说明这个 bean 已经在 getEarlyBeanReference 中被处理过了 return bean; } return wrapIfNecessary(bean, beanName, cacheKey); } return bean; }这里的 trick 非常巧妙earlyProxyReferences用的是标记即移除策略。如果某个 bean 在早期已经被代理过postProcessAfterInitialization执行时就能从集合里查到标记并移除它直接返回原始 bean不再走wrapIfNecessary如果这个 bean 从没被提前代理过集合里没有标记这里就会正常执行代理逻辑。所以一个被切面命中的 bean在整个创建过程中最多被代理一次。要么在早期引用阶段被提前代理要么在初始化后阶段被正常代理两种路径最终得到的代理对象是同一个不会出现一份业务代码有两份代理的情况。4.3 初始化结束后 Spring 还会做一次引用一致性校验三级缓存机制再严谨也挡不住开发者自己写出提前拿到原始引用并保存下来的代码。比如在构造器里直接getBean(A)并把它存到成员变量里之后 A 被代理了你手里这个原始引用照样用不了切面。Spring 在 bean 初始化完成之后还有一段兜底逻辑专门应对这种我暴露了早期引用但最终对象被包装成了代理的情况。简化后的代码是if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { if (exposedObject bean) { // 早期引用没有经过代理与最终对象是同一个直接用早期引用 exposedObject earlySingletonReference; } else if (!allowRawInjectionDespiteWrapping) { // 早期引用被保存到了其他 bean 中但最终对象被包装了 // 如果存在的确依赖当前 bean 的其他 bean直接抛异常 throw new BeanCurrentlyInCreationException(beanName, Bean with name beanName has been injected into other beans [...]); } } }这段逻辑可以说是三级缓存方案的明牌校验如果发现某个 bean 的早期引用被别人拿走但最终形态却是代理对象Spring 会直接启动报错而不是让系统带病运行。启动时报错是痛苦的但相比线上事务静默失效这种痛苦是一种保护。提示在项目中遇到BeanCurrentlyInCreationException且提示信息包含 raw version 或 has been injected into other beans 时多半是有人提前持有了早期引用同时这个 bean 又被 AOP 代理了。优先考虑在持有方加Lazy或者重构依赖关系而不是试图关闭这个校验。5. 三级缓存救不了的场景与几个常见误操作5.1 构造器循环依赖为什么依然无解这是网上讨论最多、也最容易踩的坑。构造器循环依赖的例子Service public class AService { private final BService bService; public AService(BService bService) { this.bService bService; } } Service public class BService { private final AService aService; public BService(AService aService) { this.aService aService; } }这种写法在 Spring 启动时会直接报BeanCurrentlyInCreationException而且三级缓存帮不上忙。原因很简单构造器循环依赖卡在了最前面的实例化阶段。A 的构造器需要 B于是去创建 BB 的构造器需要 A又回到创建 A此时 A 连原始对象都还没new出来没有对象可以提前暴露三级缓存里自然也是空的。这就好比两个人在门口互相等对方先进门谁都迈不开第一步。解决构造器循环依赖的标准姿势是public AService(Lazy BService bService) { this.bService bService; }Lazy会为 B 注入一个代理占位对象真正调用 B 的方法时才去触发 B 的完整创建。这个机制与三级缓存是两个完全不同的维度三级缓存解决的是已实例化未初始化的循环Lazy解决的是还没开始创建的循环。5.2 非单例 Bean、Async 与 Spring Boot 2.6 的变化三级缓存对作用域是严格挑剔的以下几个场景它真的鞭长莫及1. prototype 循环依赖原型 bean 每次getBean都重新创建容器里根本没有它的缓存条目。A 实例化后希望 B 依赖它B 又回依赖 A而 A 的早期引用没有地方可放直接报错。Scope(prototype)的 bean 在设计上就不应该互相循环引用遇到就重构。2. Async 与循环依赖共存这是一个很容易被忽略的坑。Async的代理由AsyncAnnotationBeanPostProcessor创建这个后置处理器并没有实现SmartInstantiationAwareBeanPostProcessor#getEarlyBeanReference因此它不会参与提前代理逻辑。当一个Async方法与循环依赖同时出现时其他 bean 拿到的很有可能是一个未代理的原始对象异步逻辑静默失效。我在实际项目里定位过一次某个服务方法加了Async但事务切面、异步逻辑全都表现正常只有压测时发现线程没切换最后排查到就是循环依赖 异步代理的顺序问题。遇到这个场景最干净的做法是拆开循环引用或者不依赖早期引用直接通过Lazy获取。3. Spring Boot 2.6 默认关闭循环依赖Spring Boot 2.6 开始spring.main.allow-circular-references默认值是false。也就是说即使你的代码是 setter 注入只要存在循环引用启动也会失败。这是官方有意的收紧循环依赖在架构上大多是可以避免的坏味道。如果项目升级后遇到这类报错先别急着在配置里打开开关更推荐的做法是审视依赖关系看能不能通过引入中间层、拆分服务来打破环。真的需要兼容老代码时再在配置文件里显式设置spring: main: allow-circular-references: true5.3 把 Lazy 当成循环依赖万能药是一种误解技术社区里流传的说法是循环依赖加 Lazy 就行了。这句话对了一半但很多人把它用错了。Lazy的本质是让依赖方不直接引用目标 bean 的真实实例而是注入一个代理占位直到第一次调用方法时再触发目标 bean 的创建。它确实能破解构造器循环也能规避很多启动时序问题但它有个隐藏代价目标 bean 的创建时机被推迟到运行时如果延迟创建的链路里又发生极端问题错误也会从启动期推迟到线上。而且Lazy代理占位对象与真实对象在某些场景下也存在差异比如this调用失效、类型判断复杂化、以及前面提到的异步代理顺序问题。所以我的建议是如果项目里遇到循环依赖先判断它出现在哪个阶段。构造器循环用Lazy是合理的setter/字段注入循环能靠三级缓存扛住但最好还是从依赖设计上消除一类依赖关系如果反复出现说明模块边界可能画得不够清晰。另外还有个常见误操作——在循环依赖场景里为了解决问题把.xml配置或注解里改成Autowired(required false)。这不是修复是掩盖会让该注入的依赖在运行时变成 null然后线上出现一堆NullPointerException。遇到循环依赖要么Lazy要么重构不要靠吞异常来瞒天过海。回头看整个三级缓存的设计它最值得称道的地方在于既解决了循环依赖下的对象获取问题又用 ObjectFactory 保留了 AOP 代理决策的灵活性最后还通过启动期校验兜底了引用不一致的极端情况。两层做不到四层没必要三层恰好是信息量与复杂度的一个很好的平衡点。平时写业务代码时也许你用不上这些细节但一旦遇到循环依赖、代理失效、启动报错这类问题这些源码层面的认知会直接帮你把排查半径从猜缩小到定位。