面试季我常被问到一类问题:“你读过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、代理没生效,回到源码的流程上找,通常几分钟就能定位。这套思维一旦建立,面试和日常工作其实是同一个能力。