Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 09Spring IOC 与循环依赖

「Spring 怎么解决循环依赖」是八股里背得最完整、答得最虚的一道题。 几乎所有人都能说出「三级缓存」,但接着问「哪三级、每一级里到底存了什么、 为什么两级不行、哪种情况它救不了」,答得上来的不到两成。

这一章不背结论。我们用反射把三个缓存的内容实时打印出来, 在循环依赖发生的那几十毫秒里,一格一格看它在哪个 map 里进、哪个 map 里出; 再把构造器注入、原型、代理包装、@Lazy 四种场景各跑一遍, 看它在哪一步炸、报什么错。所有输出都是本机跑出来的原文。

学习目标
能画出三级缓存的进出时序,说出哪四种循环依赖容器救不了
前置要求
用过 @Autowired;知道 Bean 和单例是什么
本章产出
11 组实测 + 3 份真实异常原文 + 5 张速记卡
建议学时
3–4 小时(重点是实验 1 那份缓存快照)

9.1面试官问 Spring 时,真正在问什么

Spring 是唯一一门「用得最多、说得最浅」的技术。日常开发里你只需要会写 @Service / @Autowired,但面试要的是容器内部的因果链: 为什么这么设计、不这么设计会怎样、出问题时报什么错。

你听到的问题它其实在问说错的代价
「说说 Spring 的 IOC」BeanDefinition 是图纸,容器按图施工只会说「控制反转,把创建对象的权力交给容器」
「Bean 的生命周期」十个回调的确切顺序把 afterPropertiesSet 说在 @PostConstruct 之前
「怎么解决循环依赖」三级缓存各存什么背得出三个名字,说不出第二级为什么要提前暴露
「构造器注入能解决吗」不能,且原因在时机上答「能,加个 @Lazy 就行」——那已经不是构造器注入了
「@Async / @Transactional 的坑」代理包装 + 循环依赖 = 启动失败只会说「同类调用不生效」
「单例 Bean 线程安全吗」容器只保证唯一,不保证无状态答「安全」→ 追问一个成员变量就崩
实验环境(重要,先看清):本章不用 Spring Boot,直接依赖 spring-context 裸容器。因为要讲的是 DefaultSingletonBeanRegistry 里的三个 Map,Boot 只是把它们藏得更深。
实测版本:Spring Framework 7.0.9(即 Boot 4.1 的内核)+ JDK 17.0.12; 涉及版本差异的地方会附 6.1.13(Boot 3.x 内核)的对照输出。
源码是单文件 IocDemo.java,一个 main 按参数分发: java IocDemo cycle 只跑实验 1,all 跑全部十一组。
关于输出的两个约定:① 长行快照(比如三种缓存那一行)为了看清结构做了拆行, 内容一字未改;② 行尾以 <-- 开头的是我加的批注,不是程序打印的。

9.2容器替你做的三件事:读图纸、造对象、塞依赖

先看不用容器时,一个典型的三层调用要写多少废话:

// 手工装配:每一层都得自己 new,还得自己保证"只 new 一次"
UserDao dao = new UserDaoImpl(new DataSource(...));
UserService service = new UserServiceImpl(dao, new MailSender(...));
OrderService order = new OrderServiceImpl(service, dao);
// 明天 DataSource 要换连接池?—— 上面每一行都得改

容器做的事可以一句话概括:把「对象图」从代码里搬到注解/配置里,由容器按图施工。 关键在「按图」两个字——容器不是随手 new,它先把所有声明解析成 BeanDefinition(图纸),再照着图纸造。

BeanDefinition 上的字段来自哪里实测能观察到的差异
beanClass@Component 的类 / @Bean 的返回类型决定由构造器造还是由工厂方法造
scope@Scope("prototype")单例 90ns/次,原型 1092ns/次(实验 6)
initMethodName@Bean(initMethod=…)排在 afterPropertiesSet 之后(实验 8)
dependsOn@DependsOn只影响创建顺序,不注入任何东西
lazyInit@Lazy不参与 refresh() 的预实例化,第一次 getBean 才造
一句话记忆:IoC 的价值不是「解耦」这个空词,而是把「谁依赖谁」变成可检查的元数据。 一旦依赖变成数据,容器才能批量做四件事:统一创建、统一注入、统一回调、统一销毁。 循环依赖之所以能被「解决」,也正是因为依赖是数据——容器看得见环,才谈得上绕过它。

9.3refresh() 十四步里,真正干活的是这四步

AbstractApplicationContext.refresh() 有十四步,面试让你全背下来是耍流氓。真正和「Bean 从哪来」相关的只有四步:

#方法它决定了什么
1obtainFreshBeanFactory()建出 DefaultListableBeanFactory,此时它还是空的,一张图纸都没有
2invokeBeanFactoryPostProcessors()扫描 @Component、解析 @Configuration,把所有类变成 BeanDefinition 塞进容器
3registerBeanPostProcessors()把 BeanPostProcessor 实例化并排好序——它们必须在任何业务 Bean 之前就位
4finishBeanFactoryInitialization()调 preInstantiateSingletons(),把所有非懒加载单例一次性造完

这四步的耗时分布很反直觉。实验 1 的日志里,A 的构造器直到第 599 毫秒才打印:

=== case1 setter 循环依赖 ===
[   1.2ms] refresh() 开始
[ 599.1ms] A  -> 构造器执行完毕(此时 b 仍为 null)
[ 648.5ms] B  -> 构造器执行完毕(此时 a 仍为 null)
[ 658.1ms] B  -> setA 注入 A@2096194236
[ 663.1ms] A  -> setB 注入 B@655713354
[ 679.2ms] refresh() 结束

前 599 毫秒一个业务 Bean 都没造——全花在加载 Spring 自己的类、解析注解、注册后置处理器上。 而同一份代码里的第四个容器(实验 6)只要 4.1 毫秒:

=== case6 单例 vs 原型 ===
[   0.0ms] refresh() 开始
[   4.1ms] refresh() 结束
679ms 和 4.1ms 差 165 倍,差的不是容器,是类加载。 这就是为什么「第一次启动慢、之后快」,也是为什么微服务冷启动要预热。 面试里被问「Spring 启动慢在哪」,答「扫描和类加载」比答「Bean 太多」准确得多。

9.4一条 Bean 的流水线:doCreateBean 五步

getBean() 最终落到 AbstractAutowireCapableBeanFactory.doCreateBean()。五个动作,顺序固定:

protected Object doCreateBean(String beanName, RootBeanDefinition mbd, Object[] args) {
    // 1. 实例化:只调构造器,此时所有字段都是默认值
    Object bean = createBeanInstance(mbd, beanName, args);

    // 2. 提前暴露:把"造好但没填完"的对象包装成 ObjectFactory 放进第三级缓存
    //    —— 循环依赖能不能解,全看这一步在不在、在什么时候
    if (earlySingletonExposure) {
        addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
    }

    // 3. 填充属性:解析 @Autowired,把依赖塞进去(这里可能递归触发别的 Bean 创建)
    populateBean(beanName, mbd, bean);

    // 4. 初始化(顺序固定):
    //    Aware -> BPP.before -> @PostConstruct -> afterPropertiesSet -> initMethod -> BPP.after
    Object exposedObject = initializeBean(beanName, bean, mbd);

    // 5. 注册销毁回调,等容器 close() 时按逆序执行
    registerDisposableBeanIfNecessary(beanName, bean, mbd);
    return exposedObject;
}

第 1 步和第 3 步的分离,是 Spring 能解决循环依赖的唯一前提: 对象可以「先出生、后吃饭」。构造器注入没有这个缝隙,所以无解(9.8 节实测)。

第 3 步会递归。实验 7 用构造器写了一条 A → B → C 的链,看创建顺序:

=== case7 依赖链 A->B->C 的创建顺序 ===
[   0.0ms] refresh() 开始
[   2.3ms] ChainC 构造(最底层先创建)
[   3.0ms] ChainB 构造
[   3.5ms] ChainA 构造
[   4.0ms] refresh() 结束
注意方向:依赖链是 A 需要 B,B 需要 C,但创建顺序是 C → B → A。 因为要造 A 就得先有 B,要造 B 就得先有 C。依赖方向与创建方向永远相反—— 这也是「循环依赖 = 有向图里的环」这个类比的来源:有环就永远找不到那个「可以最先造」的点。

9.5十个回调,一个都不能背错

这是八股里最容易被抓细节的一题。下面这份输出是一次完整生命周期的原文,从构造到销毁:

=== case8 生命周期回调顺序 ===
[   0.0ms] Dep 构造
[  12.0ms] 1 构造器
[  70.9ms] 2 属性注入
[  71.9ms] 3 Aware 回调 setBeanName(life)
[  72.2ms] 3 Aware 回调 setBeanFactory
[  72.4ms] 4 BPP.postProcessBeforeInitialization
[  72.6ms] 5 @PostConstruct
[  72.8ms] 6 afterPropertiesSet
[  76.5ms] 7 initMethod
[  76.8ms] 7' BPP.postProcessAfterInitialization(在初始化方法之后)
[  92.8ms] --- 容器就绪,开始 close() ---
[  94.4ms] 8 @PreDestroy
[  94.6ms] 9 DisposableBean.destroy
[  94.8ms] 10 destroyMethod
[  97.3ms] --- close() 结束 ---
序回调来自哪个接口 / 注解典型用途
1构造器类本身只做赋值,别做 IO
2属性注入@Autowired此时依赖才可用
3setBeanName / setBeanFactoryBeanNameAware 等拿容器信息,一般不用
4postProcessBeforeInitializationBeanPostProcessor@PostConstruct 就是被它调起来的
5@PostConstructjakarta.annotation初始化逻辑的首选位置
6afterPropertiesSetInitializingBean老写法,会耦合 Spring 接口
7initMethod@Bean(initMethod=…)接第三方类时唯一的选择
7'postProcessAfterInitializationBeanPostProcessorAOP 代理在这一步生成
8@PreDestroyjakarta.annotation关连接池、停线程
9destroyDisposableBean同上,老写法
10destroyMethod@Bean(destroyMethod=…)同 7
三个最容易背错的点:
① postProcessAfterInitialization 在 initMethod 之后,名字里的 "AfterInitialization" 指的是「初始化方法全部跑完之后」, 所以在 BPP 里拿到的 bean 已经是完整可用的——AOP 代理正是在这里把原对象换掉。
② @PostConstruct 在 afterPropertiesSet 之前,因为它本身是 CommonAnnotationBeanPostProcessor(一个 BPP)在 postProcessBeforeInitialization 里反射调用的。
③ 销毁顺序是 @PreDestroy → DisposableBean.destroy → destroyMethod,与初始化顺序方向一致(都是注解 → 接口 → 自定义方法)。

9.6单例与原型:90ns 和 1092ns

「单例」这个词被用滥了:Spring 的「单例」不是设计模式里的单例,它只是「容器内每名字一份」。 两者的代价差别可以直接测出来:

=== case6 单例 vs 原型 ===
单例 getBean x200000 : 18.1 ms(90 ns/次)
原型 getBean x200000 : 218.5 ms(1092 ns/次)
单例对象是否同一个: true
原型对象是否同一个: false
原型实例是否被容器持有: 容器内 proto 单例数=0
对比项singleton(默认)prototype
创建时机refresh() 时全部造完每次 getBean() 现造
容器是否持有是(singletonObjects 里)否,实测容器内该名字的单例数 = 0
取一次的开销90 ns(一次 Map 查找)1092 ns(走完整 doCreateBean),12 倍
销毁回调会执行一次都不执行(见下方实测)
循环依赖setter/字段注入可解无解(没有单例缓存可放)

最后一行容易被忽略,但它是高频考点。把 @PreDestroy、DisposableBean、 destroyMethod 三种销毁回调全挂到一个原型 Bean 上,然后 getBean 两次再 close():

=== case11 原型 bean 的销毁回调 ===
[   1.4ms] refresh() 开始
[ 246.3ms] refresh() 结束
[ 246.5ms] getBean(pl) 两次
[ 258.8ms] ProtoLife 构造
[ 261.0ms] ProtoLife 构造
[ 261.3ms] close() 开始
[ 266.6ms] close() 结束 —— 上面有几行销毁回调?
答:零行。构造了两次,销毁回调一次都没打印。 原因是 registerDisposableBeanIfNecessary() 里对原型直接返回—— 容器根本没留引用,也就无从回调。原型 Bean 里开的连接、起的线程, 只能自己在业务代码里手动关,容器不会兜底。
这是「用 prototype 解决成员变量共享问题」时最常见的生产事故来源。

9.7三级缓存:把三个 Map 打印出来看

先给结论再上证据。三个缓存都在 DefaultSingletonBeanRegistry 里:

级别字段存什么什么时候被放进去
一级singletonObjects成品:依赖已注入、初始化已完成的 BeandoCreateBean 最后一步
二级earlySingletonObjects半成品:造好了、依赖还没填完的对象本身第一次有人来要早期引用时
三级singletonFactoriesObjectFactory(能造出早期引用的工厂)createBeanInstance 之后立刻

光看表格没有体感。实验 1 在 A、B 互相注入的过程中,每走一步就用反射把三个 Map 打一次:

=== case1 setter 循环依赖 ===
[   1.2ms] refresh() 开始
[ 599.1ms] A  -> 构造器执行完毕(此时 b 仍为 null)
[ 648.5ms] B  -> 构造器执行完毕(此时 a 仍为 null)
[ 658.1ms] B  -> setA 注入 A@2096194236
      [cache B.setA 之后] singletonObjects=ConcurrentHashMap/14(业务:[])
                           earlySingletonObjects=ConcurrentHashMap/1(业务:[a])
                           singletonFactories=ConcurrentHashMap/1(业务:[b])
[ 663.1ms] A  -> setB 注入 B@655713354
      [cache A.setB 之后] singletonObjects=ConcurrentHashMap/15(业务:[b])
                           earlySingletonObjects=ConcurrentHashMap/1(业务:[a])
                           singletonFactories=ConcurrentHashMap/0(业务:[])
[ 679.2ms] refresh() 结束
A@ctx=IocDemo$A@7cf162bc  B@ctx=IocDemo$B@2715644a
b.a == a ? true
a.b == b ? true
      [cache refresh 完成后] singletonObjects=ConcurrentHashMap/17(业务:[a, b])
                              earlySingletonObjects=ConcurrentHashMap/0(业务:[])
                              singletonFactories=ConcurrentHashMap/0(业务:[])

(singletonObjects 的总数里还有十几个是 environment、messageSource、 internalAutowiredAnnotationProcessor 这类容器自建 Bean,为了看清楚只保留了业务 key。)

把上面这段翻译成时序,就是这道题的标准答案骨架:

步发生了什么此刻三个缓存里的业务 key
①造 A:调构造器,A 实例已存在但 b=null三级 {a}
②填 A 的属性,发现需要 B → 转去造 B三级 {a}
③造 B:调构造器,B 实例已存在但 a=null三级 {a, b}
④填 B 的属性,发现需要 A → A 正在创建中,去缓存找早期引用二级 {a},三级 {b}
⑤B 装配完成,进入一级缓存;回到 A,把 B 注入进去一级 {b},二级 {a}
⑥A 装配完成,也进一级;把二级、三级里关于 a 的残留清掉一级 {a, b}

第 ④ 步是整道题的题眼:「从三级缓存取 ObjectFactory,调 getObject() 拿到早期引用, 然后把结果放进二级缓存,并删掉三级缓存里的工厂」——这正是实测输出里 earlySingletonObjects=[a] 且 singletonFactories=[b] 同时成立的原因。

为什么两级不行

如果只有「二级」——singletonFactories 里直接存对象,会怎样? 关键在于什么时候决定要不要生成代理。

方案做法代价
只有一级成品池,无早期引用循环依赖直接死(构造器那套)
两级(成品 + 早期对象)造完就把对象塞进早期池此时还不知道该不该包装代理;等 BPP 决定要包,B 手里已经是裸对象了
三级(成品 + 早期对象 + 工厂)先放工厂;真有人来要,才调用它现场决定返回裸对象还是代理多一次 Map 查找,换来「代理只生成一次、且拿到的都是代理」
一句话记忆:第三级不是为了「多存一份」,而是为了把「要不要包代理」这个决定推迟到最后一刻。 ObjectFactory 的 getObject() 里调的是 getEarlyBeanReference(), 其中会问所有 SmartInstantiationAwareBeanPostProcessor:「你要不要包装这个早期引用?」 AbstractAutoProxyCreator(@Transactional/@Async 的底层)就靠这个钩子, 把代理在早期就生成好并缓存,保证 B 拿到的是代理而不是裸对象。

9.8循环依赖:两种活法,四种死法

「Spring 解决了循环依赖」这句话只对一个子集成立: 单例 + setter/字段注入。其余场景全部启动即失败。逐条实测如下。

场景能否解决实测结果
单例 + @Autowired 字段能正常启动,b.a == a 为 true
单例 + setter 注入能同上(实验 1)
单例 + 构造器注入不能BeanCurrentlyInCreationException(实验 2)
单例 + 关掉 allowCircularReferences不能同上,且从「字段注入」就开始炸(实验 3)
原型 + setter 注入不能BeanCurrentlyInCreationException(实验 10)
任一场景 + 参数加 @Lazy能(绕过)注入的是 CGLIB 代理,真正用的时候才去容器取(实验 5)

死法一:构造器注入(最常见)

异常类型: org.springframework.beans.factory.UnsatisfiedDependencyException
异常消息: Error creating bean with name 'c1': Unsatisfied dependency expressed through
         constructor parameter 0: Error creating bean with name 'c2': Unsatisfied
         dependency expressed through constructor parameter 0: Error creating bean with
         name 'c1': Requested bean is currently in creation: Is there an unresolvable
         circular reference or an asynchronous initialization dependency?
--- 根因 ---
org.springframework.beans.factory.BeanCurrentlyInCreationException: ...

为什么构造器不行?回到 9.4 的 doCreateBean:addSingletonFactory() 排在 createBeanInstance() 之后。而构造器注入要求「参数先就位才能造对象」, 于是 A 的构造器还没返回,第三级缓存里就没有 A 的工厂,B 来要 A 只能得到「正在创建中」。 暴露早期引用的前提是对象已经存在,构造器阶段对象还不存在。

死法二:关掉 allowCircularReferences

ctx.setAllowCircularReferences(false);
// 同一对 A/B,只是不再允许循环
异常消息: Error creating bean with name 'a': Unsatisfied dependency expressed through
         method 'setB' parameter 0: ... Requested bean is currently in creation ...
--- 根因 ---
org.springframework.beans.factory.BeanCurrentlyInCreationException
Spring Boot 2.6 起,这个开关的默认值已经是 false! 也就是说在 Boot 2.6+ 里,字段注入的循环依赖也会启动失败, 报的正是上面这条消息。很多人以为自己项目「一直能跑」, 其实是因为老版本(≤2.5)默认 true。这条在面试里能直接区分「读过源码」和「背过博客」。

死法三:原型

=== case10 prototype 循环依赖 ===
[ 164.1ms] P1 构造
[ 213.9ms] P2 构造
异常消息: Error creating bean with name 'p1': Unsatisfied dependency expressed through
         method 'setP2' parameter 0: ... Requested bean is currently in creation ...
--- 根因 ---
org.springframework.beans.factory.BeanCurrentlyInCreationException

原型根本没有「单例池」可放。三级缓存是 DefaultSingletonBeanRegistry 的字段, 名字里就写着 Singleton——原型 Bean 不会进这三个 Map,自然谈不上提前暴露。

活法:@Lazy 把环断开

=== case5 @Lazy 断环 ===
[   3.1ms] LA -> 构造
[  77.1ms] LA -> setLb 注入 IocDemo$LB$$SpringCGLIB$$0
[  77.4ms] LA -> 注入对象是 CGLIB 代理? true
[  77.8ms] LB -> 构造(此刻还没有任何人需要真正的 LB)
[  79.2ms] refresh() 结束

@Lazy 的思路不是「解决环」,而是把环拆成一条延迟的边: 注入的不是 LB 本身,而是一个 CGLIB 代理(实测类名带 $$SpringCGLIB$$), 代理内部持有 BeanFactory,第一次真正调用方法时才去 getBean。 环在启动期消失了,代价是:把启动期错误推迟成运行期错误。

不要用 @Lazy 当常规手段。它让「依赖关系」变成「运行时才知道」, 启动自检、循环引用排查、Bean 依赖图可视化全部失效。 正确做法是重构图:把双向依赖中真正共享的那部分抽成第三方 Bean,让环变成树。 面试被追问「你怎么解决的」,答「抽公共依赖 + 单向依赖」比答「加 @Lazy」高一个层次。

9.9代理 + 循环依赖:两个版本的两种表现

这一节是给「我已经知道三级缓存了」的人准备的。真实项目里循环依赖 + AOP 代理( @Transactional、@Async、@Cacheable)会撞上一个更凶的错误。 用自定义 BeanPostProcessor 模拟 AOP 包装(返回 Proxy),同一份代码在两个版本上跑:

// 7.0.9 实测
=== case4 循环依赖 + BeanPostProcessor 包装 ===
[   3.9ms] PA -> 构造
[   4.7ms] PB -> 构造
[   5.6ms] PB -> setIa 注入 IocDemo$PA          <-- 拿到的是【裸对象】
[   6.4ms] BPP -> 把 pa 包装成代理(此刻 PB 手里已经是原始对象)
信息: Bean 'pa' marked for pre-instantiation (not lazy-init) but currently
      initialized by other thread - skipping it in mainline thread
[   8.3ms] PB -> 构造                          <-- pb 被【重新创建了一次】
[   8.6ms] PA -> 构造
[   8.7ms] BPP -> 把 pa 包装成代理
[   8.9ms] PB -> setIa 注入 $Proxy16             <-- 这次拿到了代理
[   9.9ms] refresh() 结束
// 6.1.13 实测(同代码)
异常类型: org.springframework.beans.factory.BeanCurrentlyInCreationException
异常消息: Error creating bean with name 'pa': Bean with name 'pa' has been injected
         into other beans [pb] in its raw version as part of a circular reference,
         but has eventually been wrapped. This means that said other beans do not
         use the final version of the bean. ...
版本现象根因(已核对字节码)
6.1.13 直接抛 BeanCurrentlyInCreationException,提示「以 raw version 注入,最终却被包装」 doCreateBean 末尾比对「早期引用」与「最终对象」,不一致就报错。这是保护性失败
7.0.9 不报错。pb 被构造两次,最终拿到代理 7.0 重写了预实例化:DefaultListableBeanFactory 新增 bootstrapExecutor 字段与 preInstantiateSingleton()(返回 CompletableFuture),主线程遇到「正被别的线程初始化」的 Bean 会跳过并重试
结论不该是「7.0 更安全」,而是「别依赖容器兜底」。 两个版本的共同点是:循环依赖期间被包装的 Bean,注入方拿到的可能不是最终版本。 7.0 只是把这次冲突「重试掉了」,`pb` 被造两次本身就说明第一次是废的—— 如果 PB 的构造器里做了副作用(发消息、开连接),副作用就执行了两次。
生产上看到 has been injected into other beans [...] in its raw version, 正确动作是拆环,不是调 allowRawInjectionDespiteWrapping。

9.10三类 getBean 异常、速记卡与自查

接口定义了、实现也写了,启动却报 NoSuchBeanDefinitionException—— 这类问题不值得花时间,但要知道报错长什么样,才能一眼定位:

=== case9 三类 getBean 异常 ===
[未 refresh] IllegalStateException: ...AnnotationConfigApplicationContext@6659c656
             has not been refreshed yet
[找不到] org.springframework.beans.factory.NoSuchBeanDefinitionException:
        No bean named 'notExist' available
[多个候选] NoUniqueBeanDefinitionException: No qualifying bean of type 'IocDemo$Pay'
          available: expected single matching bean but found 2: wx,ali
[指定名字] WxPay
异常触发条件怎么修
IllegalStateException容器还没 refresh() 就 getBean先 refresh();ApplicationContext 构造器里已自动调用
NoSuchBeanDefinitionException名字写错 / 没在扫描范围内 / 是接口没实现类查 @ComponentScan 的包路径、类上有没有注解
NoUniqueBeanDefinitionException同类型有两个以上候选(实测 wx, ali)加 @Primary、@Qualifier("wx"),或按名字取

五张速记卡

卡内容
卡 1 · 图纸注解/XML 都先变成 BeanDefinition;容器照着它造,不照着类造。
卡 2 · 五步实例化 → 提前暴露 → 填充属性 → 初始化 → 注册销毁。第 1、2 步之间的缝隙,就是循环依赖的全部空间。
卡 3 · 十回调构造 → 注入 → Aware → BPP.before → @PostConstruct → afterPropertiesSet → initMethod → BPP.after → @PreDestroy → destroy → destroyMethod。
卡 4 · 三级一级成品、二级半成品、三级工厂。第三级存在的唯一理由:推迟「要不要包代理」的决定。
卡 5 · 活一种死四种只有单例 + setter/字段能解;构造器、原型、关开关全失败;@Lazy 是把环推迟到运行期。

六个自测(能脱口而出才算过)

  1. 三级缓存里,singletonFactories 存的是 ObjectFactory 而不是对象,为了什么?
  2. A、B 循环依赖,实测在哪一步之后 earlySingletonObjects 里才出现 a?此时三级缓存里还剩谁?
  3. @PostConstruct 和 afterPropertiesSet 谁先执行?各自是被谁调起来的?
  4. 原型 Bean 上挂 @PreDestroy,容器 close() 时会执行吗?为什么?
  5. 为什么构造器注入的循环依赖无法靠三级缓存解决?请从 doCreateBean 的语句顺序回答。
  6. 线上报 has been injected into other beans [...] in its raw version,你会改哪三处?
一句话记忆:三级缓存不是「Spring 很聪明」,而是「Spring 承认对象可以先出生、后长大」。 它救的只是这个时间差带来的环;凡是绕不过这个时间差的场景(构造器要求参数先就位、原型没有池子), 它一律失败,而且失败得很干脆。面试真正想听的,是你知道它在哪儿失败。

动手清单与自查表

#动手项可观察的判据(做到了会看到什么)
1 跑实验 1,用反射打印三个缓存的 key B.setA 之后:二级 [a] + 三级 [b] 同时成立
2 把 A、B 改成构造器注入,重跑 启动失败,根因 BeanCurrentlyInCreationException,消息含 currently in creation
3 加一行 ctx.setAllowCircularReferences(false) 连字段注入也失败——这条就是 Boot 2.6+ 的默认行为
4 把 A、B 的 @Scope 改成 prototype 同样失败,因为三个缓存里一个原型 key 都没有
5 给 setLb 的参数加 @Lazy 启动成功,注入对象类名带 $$SpringCGLIB$$
6 把生命周期实验的销毁回调全挂到原型 Bean 上 close() 后零行销毁日志,构造却执行了两次
反例实验(做了会记一辈子):把实验 1 的 A 改成构造器注入 B, B 保持 setter 注入 A。照样失败—— 因为环里只要有一条边是「构造器」边,容器就无法在造出 A 之前拿到 A 的早期引用。 一条构造器边,整条环全废,这与「环上其他边是 setter」无关。
卡住了怎么办去哪查
不知道 Bean 有没有被创建ctx.getBeanDefinitionNames() 看图纸,ctx.containsBean(name) 看成品
不知道谁依赖谁ctx.getBeanFactory().getDependentBeans(name)(只管 @DependsOn);实质依赖看启动日志的创建顺序
不知道为何启动失败从异常链最底层的 cause 往上读,第一条 BeanCurrentlyInCreationException 才是真因
怀疑代理问题打印 bean.getClass().getName(),看有没有 $$SpringCGLIB$$ 或 $Proxy
想看清回调顺序写一个 BeanPostProcessor,在 before/after 各打一行——比读文档快