Spring 循环依赖为什么要三级缓存:两级会把 AOP 代理弄丢

上周面了三家 Java 后端,两家都问了循环依赖。

第一问几乎一样:「Spring 怎么解决循环依赖?」我把三级缓存的字段名背出来,面试官点点头。

第二问才是真的:「那为什么不是两级?第三级到底多出来干什么?」

这题我第一次挂过。后来把 Spring Framework 6.2.8 的 DefaultSingletonBeanRegistry 翻出来,才看清第三级不是为了「多一层保险」,是为了 AOP。

三级缓存到底是哪三个 Map

字段就在 DefaultSingletonBeanRegistry 里,名字比口诀直白:

java 复制代码
/** 成品:初始化完成、能直接给业务用 */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

/** 早期工厂:还没人来拿时,只存一个 ObjectFactory */
private final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(16);

/** 已经提前曝光过的对象:工厂跑过一次就放这里 */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

查找顺序也写死在 getSingleton 里:一级 → 正在创建才看二级 → 允许提前引用才跑三级工厂。

工厂跑完会从 singletonFactories 删掉,结果塞进 earlySingletonObjects。同一个 bean 的早期引用只会生产一次。

doCreateBean 里,实例化刚结束、属性还没填,就会把工厂挂上去:

java 复制代码
boolean earlySingletonExposure = (mbd.isSingleton()
        && this.allowCircularReferences
        && isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}

三件事同时成立才曝光:单例、允许循环引用(默认 true)、这个 bean 正在创建。原型 bean 进不来。构造器互相咬死也进不来,因为对象都还没 new 出来。

没有 AOP,两级其实够用

假设 AB 都是 setter / 字段注入,谁都没有切面。

容器先 new A()。A 已经是堆上的对象了,只是字段还是 null。这时把 A 的引用丢进缓存,再去创建 B。B 创建时发现依赖 A,从缓存拿到那个半成品 A,注入进去。B 初始化完,再回来把 B 填进 A。

这能成,靠的是 Java 引用语义:半成品和成品是同一个对象,后面把字段补上,B 手里那份引用跟着完整。

所以「循环依赖靠提前曝光」这件事,二级缓存就能做。网上说「必须三级」如果停在这里,其实没讲完。

第三级多出来的,是「要不要做成代理」

加一层 @Transactional 或别的 AOP 之后,容器最后交给别人的不该是原始 A,而是代理。

getEarlyBeanReference 会走 SmartInstantiationAwareBeanPostProcessor。AOP 那边 AbstractAutoProxyCreator 的实现很直接:

java 复制代码
public Object getEarlyBeanReference(Object bean, String beanName) {
    Object cacheKey = getCacheKey(bean.getClass(), beanName);
    this.earlyBeanReferences.put(cacheKey, bean);
    return wrapIfNecessary(bean, beanName, cacheKey);
}

也就是说,有人在初始化完成前就来拿 A,代理必须这一刻做出来。否则 B 注入的是原始对象,A 初始化后再包一层代理,两份引用就不是同一个东西了。

源码里这笔账是要报错的。doCreateBean 收尾如果发现「别人拿到的是 raw 版本,自己最终却 wrapped 了」,会抛 BeanCurrentlyInCreationException,原文是:

has been injected into other beans in its raw version as part of a circular reference, but has eventually been wrapped

这才是第三级存在的理由。

singletonFactories 存的不是对象,是 () -> getEarlyBeanReference(...)

没人来拿,工厂就不跑,AOP 还是按正常生命周期在 initializeBean 里做,不会无故提前包代理。

有人来拿,工厂跑一次,代理(或确认不需要代理的原始对象)放进二级缓存。后面再有人来,直接拿二级里那份,不会再 wrapIfNecessary 一次、造出第二个代理。

两级缓存如果一实例化就把原始对象塞进去,要么提前包了不该包的代理,要么循环对端拿到 raw、最终对外却是 proxy。第三级把「要不要包、包出来的那一个」推迟到真正有人来拿的时候。

构造器循环,三级缓存也救不了

官方文档写得很硬:A 的构造器要 B,B 的构造器要 A,容器会抛 BeanCurrentlyInCreationException

原因也简单。提前曝光发生在 createBeanInstance 之后。构造器还没返回,堆上没有对象,工厂没地方挂。setter / 字段注入能拆成「先 new、再填字段」;构造器做不到。

@Lazy 能绕,本质是先注入代理,真正用的时候再初始化对端。能启动,不代表设计对。Spring 自己的 javadoc 也劝:别依赖循环引用,把公共逻辑抽到第三个 bean。

我后来准备面试,不再背「三级缓存解决循环依赖」这句空的。问到就按这条说:

单例 + setter/字段注入,容器会在实例化后挂一个 ObjectFactory。循环对端来拿时,工厂调用 getEarlyBeanReference,需要 AOP 就提前生成同一个代理,放进二级缓存。构造器循环没有可曝光的实例,官方就是报错。

相关推荐
paopaokaka_luck19 分钟前
基于springboot3+vue3+uniapp的河南非遗数字图谱小程序(协同过滤算法、数字图谱展示、ECharts 图形化分析)
java·前端·spring boot·学习·小程序·uni-app·echarts
OPEN-F23 分钟前
C++进阶教程:类与对象深入
java·开发语言·c++
莫陌尛.23 分钟前
StarRocks Iceberg MinIO S3 301报错完整修复文档(Docker环境)
java·docker·容器
AI人工智能+电脑小能手25 分钟前
大白话说Java设计模式-42-访问者模式(业务实战篇)
java·spring·设计模式·访问者模式·asm·营销规则·数据结构与操作分离
观测云32 分钟前
Spring AI + Spring AI Alibaba 接入观测云最佳实践
人工智能·spring
步行cgn32 分钟前
传统 SSM 与 Spring Boot 开发对比:从配置地狱到约定优于配置
java·spring boot·后端
ttwosix39 分钟前
JavaEE初阶 多线程:单例模式 | 阻塞队列
java·开发语言
zcmodeltech1 小时前
垃圾发电厂沙盘模型控制系统设计与实现:多设备协同联动方案
java·大数据·数据库·人工智能·stm32·嵌入式硬件·制造
AI人工智能+电脑小能手1 小时前
大白话说Java设计模式-43-访问者模式(源码剖析篇)
java·设计模式·访问者模式·源码分析·elementvisitor·asm classvisitor·spring spel