news 2026/11/5 23:33:40

Spring源码面试:Bean生命周期、三级缓存与@Autowired解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring源码面试:Bean生命周期、三级缓存与@Autowired解析

面试季我常被问到一类问题:“你读过Spring源码吗?”说读过的人很多,但能扛住追问的很少。就拿“Bean生命周期”这个最经典的问题来说,面试官只要连续追问几个“为什么”:为什么三级缓存能解决循环依赖、为什么构造器注入的循环依赖救不回来、@Autowired是在哪个阶段生效的、为什么@PostConstruct会先于InitializingBean执行,大部分人就答不上来了。

这篇东西写给两类人:一类是准备面试、想从“背八股”进化到“讲原理”的Java工程师;另一类是已经写了几年业务代码,但对Spring容器始终停留在“会用”层面的朋友。我会从源码路径出发,把IOC容器启动、Bean实例化、初始化回调、循环依赖、自动装配、构造方法推断这些点一个个拆开,结合面试中真实会被追问的细节来讲。读完你能做到的,是在面试现场用自己的话把这条链路的来龙去脉讲清楚,而不是背下一堆结论。

1. 从“背结论”到“看源码”:面试追问链是怎么建立的

1.1 一个典型的IOC连环追问

很多面试官喜欢从“Spring Bean的生命周期”开场,因为这个知识点覆盖面广、由浅入深,能快速探测候选人的深度。完整的追问链大概是这样的:

  • 第一层:Bean生命周期有哪些阶段?—— 背过的人都能答出实例化、属性填充、初始化、销毁。
  • 第二层:初始化阶段有哪几种注册方式?顺序是什么?—— 开始有人卡壳,尤其是SmartInitializingSingleton和@PostConstruct的执行顺序。
  • 第三层:循环依赖是怎么解决的?三级缓存分别存的什么?—— 多数人能背出“三级缓存”,但说不清为什么需要三级,二级够不够。
  • 第四层:@Autowired是在哪个阶段、由哪个类完成的?—— 到这里基本就能筛掉一大半人。
  • 第五层:如果构造器注入发生了循环依赖,为什么不报错?换成setter为什么就能解决?—— 能答上来的人,通常才是真正读过源码的。

这条追问链的本质,是面试官在验证你对“容器如何管理对象”这件事有没有建立真实的心理模型。源码阅读的意义不在于背类名和行号,而在于帮你建立起这个模型:BeanDefinition是什么、BeanFactoryPostProcessor和BeanPostProcessor的分工差异、容器创建Bean的那几个关键方法各自做了什么。有了模型,再多的问题都是在这个模型上推算,而不是靠记忆硬扛。

1.2 源码阅读的三个正确姿势

我在带团队和帮人准备面试时发现,读源码最容易犯的错是“从头读源码”。AbstractApplicationContext.refresh()这一个方法就有十几行核心调用,每一行往下追又是一个巨大的调用树,追到第三天人就崩了。

正确姿势应该是这样:

主线优先。每次只追一条主线,比如今天只搞懂“一个Bean从getBean到返回实例,路径上有哪些方法”,其他的分支看到了先记笔记、不要深挖。一天解决一个问题,比一周通读一遍有效得多。

场景驱动。用最常见的场景去验证代码路径。比如启动一个最小配置的容器,打断点看refresh()是怎么走到finishBeanFactoryInitialization()的;再比如模拟一个setter注入的循环依赖,打断点看三级缓存每一步的变化。场景越具体,代码越容易读进去。

版本固定。Spring的源码在不同版本之间有差异,比如5.x和6.1之后循环依赖默认行为就有变化。读源码前先在pom里把spring-core的版本固定下来,别拿5.2的代码去对6.x的包看,会看得一头雾水。

2. 容器启动的核心链路:从配置类到BeanDefinition就绪

2.1 ConfigurationClassPostProcessor:启动时的第一个关键关卡

容器启动的入口是AbstractApplicationContext.refresh(),这里面有一个方法叫invokeBeanFactoryPostProcessors(),它做了一件很多人知道但没细想过的事:在所有Bean实例化之前,先把配置类解析掉,把@Bean方法、@ComponentScan扫描到的类、@Import导入的类全部转换成BeanDefinition注册进容器。

执行这个动作的类是ConfigurationClassPostProcessor,它实现了BeanDefinitionRegistryPostProcessor,而BeanDefinitionRegistryPostProcessor又继承自BeanFactoryPostProcessor。它的执行时机非常早,早到容器里连一个普通Bean都还没开始创建。

为什么要赶在这个时机做?道理很简单:如果先实例化了业务Bean,后面扫描到的BeanDefinition就没法参与依赖注入了。好比装修房子,你必须先定好每个房间的功能(BeanDefinition),再开始砌墙(实例化),砌完墙再改房间布局代价就大了。Spring把“收集房屋设计图”和“动工建造”两件事严格分开了,这正是IOC容器设计的核心思想:BeanDefinition是设计图,Bean实例是成品。

2.2 getBean与doCreateBean的分工

BeanDefinition收集完毕之后,refresh()会走到finishBeanFactoryInitialization(),在这里触发对所有非懒加载单例Bean的实例化。这一段有个很经典的“分工”:外部调用的统一入口是getBean(),它负责处理单例缓存、作用域、循环依赖这些全局逻辑;而真正new对象、填充属性、执行初始化的方法叫doCreateBean()。

我之前看代码时容易混的就是这一层。getBean()更像前台接待,它先检查一级缓存有没有现成的,没有就找三级缓存看能不能提前暴露,都不行才把活儿交给doCreateBean()。doCreateBean()是具体干活的工人,负责createBeanInstance(反射构造对象)、populateBean(属性填充)、initializeBean(初始化回调)这三板斧。

面试中如果说“getBean创建了一个Bean”,严格讲是不准确的,getBean的职责是“获取或创建”。这一个小细节,讲出来就会让面试官觉得你是真看了源码,而不是在背流程。

3. Bean生命周期深度拆解:实例化前后到底发生了什么

3.1 实例化、属性填充与初始化:三段式的真实边界

很多面试题答案会把Bean生命周期列为“实例化→属性填充→初始化→销毁”四步,这方向对,但边界划得太粗。真正到源码层面,边界要清晰得多。

实例化对应的是doCreateBean()里的createBeanInstance()。这一步只负责通过构造方法反射出一个对象,此刻这个对象内部所有属性都是默认值(null、0、false),它只是一个“半成品”。构造方法推断就在这里发生:到底是走无参构造还是有参构造,参数从哪里来,都在这一步决定。

属性填充对应populateBean()。这一步Spring会把BeanDefinition里记录的属性值和依赖项填入对象。如果有@Autowired字段或setter方法,也是在这一步由AutowiredAnnotationBeanPostProcessor在postProcessProperties()中完成的。但要注意,填充的是bean里声明的属性和依赖,@PostConstruct方法还不在这步调用。

初始化对应initializeBean()。这一步才轮到各种初始化回调。到这里对象已经是一个“完整可用”的Bean了,所有依赖都就位,回调方法才开始执行。很多人把初始化和实例化混在一起说,面试一追“@PostConstruct是在对象构造完之后立刻执行吗”,一下就露馅了,中间还隔着属性填充这一步。

3.2 三种初始化方式的真实执行顺序

Spring里注册初始化回调的方式有好几种,最常见的是这三类:

  • @PostConstruct注解方法,由CommonAnnotationBeanPostProcessor处理,在postProcessBeforeInitialization()阶段调用。
  • 实现InitializingBean接口的afterPropertiesSet()方法,在initializeBean()里的invokeInitMethods()中直接调用。
  • XML或@Bean中声明的initMethod自定义方法,也在invokeInitMethods()中调用,但排在afterPropertiesSet()之后。

它们的执行顺序是先@PostConstruct,再afterPropertiesSet(),最后initMethod。很多人能背出这个顺序,但说不清机制。其实顺序来自两套不同的执行通道:@PostConstruct走的是BeanPostProcessor的前置处理通道,它在initializeBean()的开头部分先执行;而InitializingBean和initMethod在invokeInitMethods()里执行,后者在前置处理之后。

我建议面试时别只背顺序,要说清“@PostConstruct之所以最早,是因为它挂在BeanPostProcessor.postProcessBeforeInitialization()上,扫描时机天然在最前面”。这样一讲,连为什么这么设计都顺带交代了:把注解回调和接口回调拆在不同阶段,就是让扩展点可以叠加、可以排序,而不是耦合到一个方法里硬编码。

3.3 销毁阶段的细节与失效场景

销毁阶段对应的是doCreateBean的逆过程,入口在ConfigurableApplicationContext的close()。这里有个值得注意的细节:Spring对容器关闭时销毁单例Bean的调用顺序,是先执行@PreDestroy注解方法,再执行DisposableBean.destroy(),最后执行自定义destroyMethod。

但有一个很容易踩坑的场景:原型Bean的销毁不归Spring管。每次getBean拿到一个新的prototype实例,返回给你之后容器就不再跟踪它了,所以@PreDestroy和DisposableBean在prototype作用域下都不会被执行。很多项目里用prototypeBean存放数据库连接或线程池,等到容器关闭时发现资源没释放,排查到最后查出来是这个问题。正确做法是自己在业务代码里管理原型Bean的清理,或者把它做成单例来持有,再在单例的销毁逻辑里去清理。

4. 循环依赖:三级缓存的设计与边界

4.1 三级缓存与“半成品”的提前暴露

循环依赖说白了就是A依赖B,B又依赖A。在构造器注入模式下,这是个死结:构造A需要B,构造B又需要A,谁都没法先new出来。但在setter注入或字段注入时,Spring用一套“允许先给半成品”的机制解决了问题。

这套机制就是DefaultSingletonBeanRegistry里的三个Map:

  • singletonObjects:一级缓存,存放已经完整创建好的成品单例Bean。
  • earlySingletonObjects:二级缓存,存放提前暴露的早期引用(半成品)。
  • singletonFactories:三级缓存,存放ObjectFactory工厂对象,用来生成早期引用。

整个流程是这样的:A开始实例化,得到一个原始对象,在属性填充之前,Spring先把一个 ObjectFactory 存进三级缓存,这个工厂对应的lambda内部封装了getEarlyBeanReference()逻辑,也就是允许SmartInstantiationAwareBeanPostProcessor对早期引用做增强(比如生成代理)。属性填充时发现依赖B,于是去创建B,B的创建过程中填充属性时发现依赖A,于是转回找A,从三级缓存取出工厂执行lambda,拿到A的早期引用(半成品),塞进B。B初始化完成后成为成品进入一级缓存。回到A,继续完成自己的属性填充和初始化,最后也进入一级缓存。

面试时最基础的要求是能把这个流程复述出来。但如果只讲到这一步,面试官大概率会接着问下一个问题:“二级缓存不是也能存半成品吗?为什么非要三级?”

4.2 二级缓存够不够的问题

这是IOC问题里少有的“设计哲学”级考点。答案是:如果Spring不打算支持AOP代理,或者不考虑代理对象与原始对象的区分,二级缓存确实够用。

设想一下:只存在两级缓存,一级存成品,二级存半成品。A实例化后在属性填充前直接把半成品放进二级缓存,B拿到这个半成品引用完成注入,一切正常,根本不需要三级。但问题是,如果A是一个需要被AOP代理的Bean,AOP生成的代理对象通常是BeanPostProcessor后置处理的结果,而循环依赖发生时,属性填充阶段就要把A的引用给B了,那时候A还没执行后置处理,如果直接把原始半成品给B,B里持有的就是原始对象A,而不是最终容器里的代理对象A,代理就失效了。

三级缓存里那个ObjectFactory存在的意义,是允许在“需要提前暴露A的引用”那一刻,就通过getEarlyBeanReference()把代理逻辑跑一遍。也就是说,B拿到的不是简单原始对象,而是可能已经被代理过的早期引用。等到A自己走完初始化后置处理,容器会判断已经产生的代理和最终要生成的代理是否一致,从而保证循环依赖场景下代理不失效、单例不变形。

所以在源码里,AOP相关的AbstractAutoProxyCreator是一个SmartInstantiationAwareBeanPostProcessor,它重写了getEarlyBeanReference()来提前创建代理。这解释了为什么三级缓存设计成工厂而不是直接放对象:工厂是懒加载的,只有真正出现循环依赖需要提前引用时才执行,不出现循环依赖时,这个lambda根本不执行,也就不存在代理提前暴露的额外开销。

4.3 哪些循环依赖救不回来

三级缓存不是万能的,有三个非常典型的场景会把循环依赖直接干成启动失败:

构造器注入的循环依赖。因为Spring必须调用构造方法才能拿到对象,连半成品都产生不了。流程走进createBeanInstance()时参数解析需要B,B又要A,死锁直接抛BeanCurrentlyInCreationException。

prototype作用域的循环依赖。三级缓存的工厂机制只针对单例Bean设计,每个prototype都是要现new的,A没构造完就去引B,B引A,无法提前暴露,一样报错。@Async或过早AOP代理的场景有时也会因为代理链路复杂化暴露出问题,虽然用了三级缓存可以延迟,但配置不当依然可能引发循环依赖。

还有一种情况特别容易踩坑:从Spring Boot 2.6开始,容器默认禁止循环依赖(spring.main.allow-circular-references=false)。很多老项目从2.5升到2.7之后启动直接报“The dependencies of some of the beans in the application context form a cycle”,就是这个默认策略变了。处理办法不是无脑改成true,而是先审视代码为什么会产生循环依赖,绝大多数业务场景下的循环依赖都属于设计问题,通过调整依赖方向就能拆掉。改配置是最后选项。

5. @Autowired背后的执行机制与构造方法推断

5.1 AutowiredAnnotationBeanPostProcessor的装配路径

@Autowired注解的真正执行者,是AutowiredAnnotationBeanPostProcessor。它也是一个BeanPostProcessor,但它的main逻辑不是postProcessBeforeInitialization,而是postProcessProperties()。这个方法的执行点就落在populateBean()的属性填充阶段。

这里有个面试官常挖的细节:@Autowired是“先按类型去找Bean,再按名字去兜底”。这句描述来自AutowiredAnnotationBeanPostProcessor内部使用的DefaultListableBeanFactory的resolveDependency()流程。源码真实路径是先做类型匹配:找到唯一的候选直接返回;找到多个候选时,才会看哪个Bean的名字跟当前字段名或setter参数名一致,一致就选它,不一致再抛NoUniqueBeanDefinitionException。通过这个机制,@Autowired对同类型多实现的处理逻辑是最优先匹配与字段名相同的Bean,这跟@Resource“先按名字,再按类型”的默认策略刚好反着的。

顺带一提,很多人会用@Autowired来注入一个接口的列表,比如List ,这是走的ObjectProvider和List依赖解析分支,源码会收集所有该类型Bean并注入有序集合。这个细节用在回答“如何优雅地实现策略模式”的追问里,非常加分。

5.2 构造方法推断:多个构造器的抉择

除了字段和setter注入,还有一种重要的注入方式是构造器注入,而这背后有个容易忽略的机制——构造方法推断。

Spring在createBeanInstance()中要决定用哪个构造方法实例化Bean。推断逻辑的核心在ConstructorResolver和AutowiredAnnotationBeanPostProcessor的determineCandidateConstructors()中。简单归纳是这样的:

  • 类里只有一个构造方法:直接用它,不论有没有@Autowired。
  • 没有构造方法:默认无参构造。
  • 有多个构造方法且都没有@Autowired:Spring 4.3之后会选择参数最长的那个构造方法,前提是所有参数都能在当前容器中解析到Bean;否则回退无参构造。
  • 有多个构造方法,其中一个标注了@Autowired:优先用它。

这个机制解决了老版本中“必须写无参构造”的麻烦。很多现代代码风格喜欢一切走构造器注入,其实就是看到了构造器注入能保证依赖不可变、容易做单元测试这个优势。而这里有个坑是:如果写了两个构造方法,一个无参一个有参,没标@Autowired,业务Bean里又到处go野地new,结果Spring按规则选中长参数构造器,可能直接报参数解析错误,排查半天才发现是构造器推断不是按直觉来的。

5.3 三种注入方式的实用选择

结合源码和工程实践,我对三者的选择建议是:

构造器注入最稳,依赖明确且不可变,但构造器参数多时类看起来会重;字段注入写起来最简洁,缺点是不易测试,且容易形成隐式依赖;setter注入灵活性最好,适合可选依赖和运行时切换,但略啰嗦。

源码层面三者的处理路径也稍有不同:字段注入和setter注入都走populateBean,由AutowiredAnnotationBeanPostProcessor处理;构造器注入则是走createBeanInstance,在实例化阶段就完成依赖解析,直接在构造参数里把依赖取出来。理解了这个分工,你就能解释为什么构造器注入的Bean在构造时就知道所有依赖,而字段注入在构造完、属性填充时才知道谁的依赖有问题——这会影响你对报错日志的判断。

6. 面试怎么“答深”:从报错日志到回答层次

6.1 循环依赖报错日志的三种形态

面试官问“循环依赖”时,如果你能顺手说出几种典型报错长什么样,会很加分。我总结过三种最常遇到的:

形态一:BeanCurrentlyInCreationException。日志特征是出现“is in dependency resolution”和“currently in creation”。多发生在构造器注入或prototype循环依赖。定位思路是先看哪个Bean名出现在“currently in creation”里,再找依赖链中谁引了它。

形态二:启动报cycle。特征是一串A→B→A的链路描述,Spring Boot 2.6+默认禁用循环依赖时常见。这种最好修,因为链路直接打出来了,顺着链路重构依赖方向即可。

形态三:NoUniqueBeanDefinitionException。它不是循环依赖,但面试爱混着问。出现的原因是类型匹配时找到多个候选Bean,又没有用@Primary或@Qualifier消歧。回答时可以顺带引出前面讲过的类型优先、名字兜底的机制,展示源码级别的理解。

6.2 我把一个锚点答案拆给你看

前面讲的细节比较多,我整理一个能串起来讲的锚点答案,实际面试时从“初始化顺序”切入展开,效果非常稳:

“Bean实例化之后会进入属性填充,在这个阶段由AutowiredAnnotationBeanPostProcessor通过postProcessProperties来处理@Autowired的依赖解析。填充完成后进入initializeBean,先执行Aware接口回调,再执行BeanPostProcessor的postProcessBeforeInitialization,@PostConstruct就挂在这个阶段;随后调用invokeInitMethods,先执行afterPropertiesSet再执行initMethod,然后在postProcessAfterInitialization阶段生成代理。关于循环依赖,容器的三级缓存里存的是ObjectFactory而不是直接存半成品对象,这个设计是为了在需要提前暴露引用时能配合getEarlyBeanReference生成AOP代理,从而保证B拿到的引用和最终容器中的Bean保持一致。”

这段话用到的信息点全是从源码流程里提炼出来的,环环相扣。面试官如果接着追问“postProcessAfterInitialization和三级缓存里的代理会不会重复创建”,你可以继续讲AOP代理的判断逻辑和已代理对象不重复代理的细节。能答到这一层,面试已经赢了。

6.3 常见问题速查表

问题一句话核心深度加分点
Bean生命周期有哪些阶段实例化、属性填充、初始化、销毁能说出BeanPostProcessor挂载在初始化前后
@PostConstruct比InitializingBean早的原因一个走前置处理器,一个走invokeInitMethods讲出两个通道不同,顺序不是硬编码而是机制使然
循环依赖为什么三级缓存不是两级三级缓存存ObjectFactory,延迟到需要时生成代理结合AOP代理失效场景回答
构造器注入为什么不支持循环依赖构造器阶段拿不到半成品能指出报错类名BeanCurrentlyInCreationException
@Autowired和@Resource区别前者类型优先名字兜底,后者名字优先能用源码路径resolveDependency解释
原型Bean为什么销毁回调不执行容器不跟踪原型实例生命周期能给出资源和线程池的清理方案

我个人的经验是,读源码这件事不用贪多,也不需要把整个Spring的源码从头翻到尾。找几个高频面试题的切入点,对着断点一步步走一遍,把“为什么”想透,收获远比通读大得多。每次项目里遇到奇奇怪怪的Bean问题,比如初始化顺序不对、某个Bean注入成了null、代理没生效,回到源码的流程上找,通常几分钟就能定位。这套思维一旦建立,面试和日常工作其实是同一个能力。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 23:02:29

Python+IMAP远程关机:用邮件指令轻松控制电脑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/5 16:07:24

2025年韩系车强力反击:本土化定义与产品策略如何重塑合资格局

1. 先别急着下结论:2025年韩系车翻身的真实信号这几年只要聊到汽车市场,大家都习惯把韩系车当成“被遗忘的边缘角色”。尤其是2022年到2024年那阵子,韩系车在国内的存在感肉眼可见地下降,经销商退网、车型更新慢、终端只能靠降价续…

作者头像 李华
网站建设 2026/10/23 5:37:20

服务器磁盘满了?从告警到清理的完整排查

背景 凌晨收到微信告警:磁盘使用率超过 90%。 SSH 上去一看: $ df -h Filesystem Size Used Avail Use% Mounted on /dev/vda1 40G 36G 2.1G 95% /40G 的盘,只剩 2G。再涨一点,MariaDB 写不进去,Ngin…

作者头像 李华
网站建设 2026/10/20 20:22:41

实时数仓与离线数仓的本质:业务节奏驱动的分层协同

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 0:55:18

读写数据依赖:芯片设计中的性能瓶颈与处理方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/5 9:48:53

Muse登顶App Store并开源SDK:AI Agent正从屏幕走向现实硬件

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华