Spring容器初始化底层原理:从BeanDefinition到三级缓存全解析
1. 初始化到底在初始化什么先把大方向捋清楚1.1 容器初始化的四项原子任务写过几年Spring真正让我对框架两个字有了敬畏之心的是第一次点进refresh()源码的那一刻。一个容器从new出来到真正能提供完整的依赖管理能力背后不是创建几个Bean这么简单而是一整套环环相扣的工作流。Spring初始化这四个字可以拆成四项原子任务容器自身的环境准备、Bean定义的收集与注册、Bean后置处理器的装配、以及所有单例Bean的实例化与初始化。这个排序是刻意设计的存在强顺序依赖。比如Component类要被容器管理前提是它先被扫描出来并注册成BeanDefinitionConfiguration类里的Bean方法要被调用前提是配置类本身已经变成容器里的一个Bean。如果这些前置工作和实例化混在一起干容器就会钻入先有鸡还是先有蛋式的死循环。所以Spring把整个流程分成大阶段先收集元数据再加工元数据最后才动手创建实例。从一个最容易理解的现象说起为什么Autowired能在每个Bean里生效因为Spring在初始化阶段安装好了AutowiredAnnotationBeanPostProcessor为什么Configuration里的Bean方法能被正确解析因为ConfigurationClassPostProcessor在合适的时机扫描和处理了配置类。这些后置处理器都不是凭空出现的而是在refresh()的特定阶段被注册和激活的。你去看Spring的任何一本源码分析书籍绕不开的都是这条主线。理解了四项原子任务后续的代码级阅读就有了地图不会淹没在类名海洋里。这套分解方式也被很多优秀的开源框架沿用比如Spring Boot的自动装配本质上是配置类解析这一步的扩展产物。1.2 12步refresh为什么要把流程拆得这么细refresh()方法是整个初始化过程的中枢名字直译是刷新但在容器首次new出来时它执行的就是初始化本身。我第一次看这12步最大的疑问是为什么不能三步并两步直接一把梭答案藏在两个词里扩展点与顺序依赖。Spring之所以是Spring不是靠死板的定制逻辑而是大量留白给开发者扩展。BeanFactoryPostProcessor可以在BeanDefinition刚注册完、还没实例化时修改定义BeanPostProcessor可以在Bean实例化前后插入自己的逻辑。这些钩子都挂在refresh()的特定位置顺序一旦错乱机制就崩了。比如BeanDefinition后置处理器必须在实例化之前执行否则你还没来得及改定义Bean已经按旧定义建完了比如BeanPostProcessor必须在所有单例创建之前注册否则你的切面逻辑在第一个Bean创建时就漏掉了。refresh()的步骤分门别类可以归成几组。准备组包括prepareRefresh、obtainFreshBeanFactory、prepareBeanFactory、postProcessBeanFactory解决容器本身可用装配组包括invokeBeanFactoryPostProcessors、registerBeanPostProcessors解决后置处理器就位基础设施组是initMessageSource、initApplicationEventMulticaster、onRefresh、registerListeners解决Message、事件能力可用最后finishBeanFactoryInitialization触发所有单例的创建finishRefresh完成生命周期发布。把12步记成四组后整个流程就不再是一段没法背的清单而是一段有因果的故事。很多Spring高级面试题问refresh各步骤的作用本质上考的就是你能不能把这个因果链讲清楚。这套分组方式还有个现实用途手写迷你Spring。网上的简化版IoC容器多半省略了initMessageSource和onRefresh这些基础设施步骤只保留四组中最核心的装配组和创建组代码量能砍掉一大半。但砍掉的这些步骤并不是没用只是手写环境里不需要消息国际化和Web容器回调。一旦回到生产级Spring这些步骤全都会真实参与。所以手写Spring的价值在于让你理解哪些步骤是必备的最小骨架而读完整源码的价值在于让你明白完整骨架需要补哪些零件。2. 起点从AnnotationConfigApplicationContext构造器看起2.1 无参构造器Reader、Scanner凭什么一起创建日常代码里最常见的启动方式是new AnnotationConfigApplicationContext(AppConfig.class)。这一行的第一站是它的无参构造器。很多人以为无参构造器什么都没做实际上它把后续所有工作的地基都打好了public AnnotationConfigApplicationContext() { this.reader new AnnotatedBeanDefinitionReader(this); this.scanner new ClassPathBeanDefinitionScanner(this); }这里创建了两个关键组件。AnnotatedBeanDefinitionReader负责点对点注册——把一个个类直接转换成BeanDefinition比如Configuration类、Bean方法对应的定义ClassPathBeanDefinitionScanner负责按包批量扫描——从指定包路径下识别Repository、Service、Component、Controller等注解类并生成对应的BeanDefinition。两者互补一个精致灵活一个批量高效。面试题喜欢问ComponentScan是怎么工作的工作实现时就会落在这个scanner上。要留意的是reader和scanner都传入了this作为Registry也就是说它们往哪个注册表里注册BeanDefinition答案就是当前的ApplicationContext内部持有的DefaultListableBeanFactory。一个构造器把注册目标和工具都绑好了简洁又扎实。2.2 register(componentClasses)做了什么无参构造器跑完之后走到register()。它把传入的类包装成BeanDefinition用前面创建的reader去注册。注意一个细节这里注册的AppConfig本身也是一个Bean会被当作配置类纳入管理。源码是这样的public void register(Class?... componentClasses) { this.reader.register(componentClasses); }reader会逐一读取类的元信息包括类名、作用域、是否懒加载、是否抽象等等生成AnnotatedGenericBeanDefinition并注册到容器。更微妙的是reader在构造阶段就已经顺手注册了几个内部BeanDefinitionConfigurationClassPostProcessor、AutowiredAnnotationBeanPostProcessor、CommonAnnotationBeanPostProcessor等。也就是说即使你完全不写包扫描路径只要传入了配置类配置解析和依赖注入的基础设施都已经就位。很多人忽略这个细节等到手写迷你Spring时才意识到原来Spring在启动的最初几步就已经悄悄铺垫好了一整套后置处理器。2.3 refresh()的同步与线程安全三行启动代码的最后一行是refresh()。它开头用synchronized (this.startupShutdownMonitor)锁住整段流程官方注释写得很直白refresh和destroy不能并发执行。如果你在生产环境同时调refresh()和close()会有一个等待锁的间隙。Spring容器启动阶段本身不是并发友好的这在排查多线程环境下容器好像还没初始化完成就被访问的问题时特别有用。很多early access的线程会在refresh还没执行完时尝试getBean拿到的状态要么残缺要么直接抛异常。正确做法是等待Spring发布ContextRefreshedEvent后再放行外部流量或者在refresh完成前不暴露任何外部入口。3. 关键转折配置类解析与BeanDefinition注册3.1 ConfigurationClassPostProcessor为何如此重要refresh()进行到invokeBeanFactoryPostProcessors时Spring会从容器里找出所有已注册的BeanFactoryPostProcessor并执行。这里的头号主角是ConfigurationClassPostProcessor。它自身也是一个BeanDefinition在AnnotatedBeanDefinitionReader初始化时就被预先注册了但它的特殊在于实现了BeanDefinitionRegistryPostProcessor所以会被优先执行。为什么要优先因为它的职责是解析配置类把配置类上的ComponentScan、Import、Bean方法声明真正转换成BeanDefinition。如果让其他后置处理器先跑而它们依赖的Bean定义还没解析出来整个注册表就是残缺的。这个先定义后使用的排序理念在做Spring插件或自定义扩展时尤其值得照搬。3.2 ComponentScan扫描的底层操作ConfigurationClassParser的doProcessConfigurationClass方法里解析顺序是PropertySource、ComponentScan、Import、Bean方法。注意ComponentScan只是先被解析出声明真正的扫描动作随后执行。扫描由ClassPathBeanDefinitionScanner#scan驱动底层通过ClassPathScanningCandidateComponentProvider调用MetadataReader用ASM读取class文件的注解元信息——这一步不会真正把类加载进JVM只是读取字节码上的Annotation因此非常轻量。扫描命中后Spring为每个候选类生成ScannedGenericBeanDefinition再通过registry.registerBeanDefinition注册进DefaultListableBeanFactory的beanDefinitionMap。这里有个容易踩的坑扫描默认只识别组件注解和Configuration一个没有加任何注解的普通类即使放在包里也绝不会被扫描到。如果你在包扫描路径下放了一个POJO期待Spring自动管理它那注定失望。要为普通类注册Bean要么加Component要么手动通过Bean方法返回。3.3 BeanDefinition的元数据模型看懂初始化BeanDefinition这个概念必须吃透。它不是一个类定义而是一个Bean的配方。里面存着类名、构造参数、属性值、懒加载标记、作用域、初始化方法名、销毁方法名、是否抽象、是否自动装配候选等信息。Spring把配方统一建模在BeanDefinition接口中实现类包括RootBeanDefinition、ChildBeanDefinition、AnnotatedGenericBeanDefinition、ScannedGenericBeanDefinition等。什么时候用哪种传配置类注册时多半是AnnotatedGenericBeanDefinition包扫描出来的是ScannedGenericBeanDefinition手动new一个RootBeanDefinition再注册是手写Spring框架的常见套路。一个值得单独说的细节是merged bean definition。Spring在getBean时并不会直接使用原始BeanDefinition而是通过getMergedLocalBeanDefinition获取合并后的定义。因为父子BeanDefinition之间存在继承关系子定义会继承父定义的属性值合并步骤要解决哪些属性被子定义覆盖、哪些沿用父定义。这个逻辑多数开发者感觉不到但它直接影响Bean属性覆盖行为。做框架扩展时如果发现某个Bean的属性莫名其妙被父定义覆盖了大概率就是没吃透合并机制。4. 高潮单例Bean创建全链路与三级缓存源码解读4.1 preInstantiateSingletons批量实例化的起点配置类解析完毕、BeanDefinition全部就位之后refresh()走到最关键的一步finishBeanFactoryInitialization。它调用DefaultListableBeanFactory#preInstantiateSingletons把所有非懒加载的单例Bean一次性实例化出来。这是初始化阶段最耗时的部分也是线上启动慢问题的核心区域。核心代码如下public void preInstantiateSingletons() throws BeansException { ListString beanNames new ArrayList(this.beanDefinitionNames); for (String beanName : beanNames) { RootBeanDefinition bd getMergedLocalBeanDefinition(beanName); if (!bd.isAbstract() bd.isSingleton() !bd.isLazyInit()) { if (isFactoryBean(beanName)) { // FactoryBean特殊处理 } else { getBean(beanName); } } } }注意它遍历的是beanDefinitionNames的快照副本。为什么不用原列表因为实例化过程中某些Bean的创建可能触发新的BeanDefinition注册比如一个Bean在PostConstruct里手动注册了另一个BeanDefinition直接遍历原列表会漏掉或并发修改。这种遍历快照的写法在普通业务代码里也值得借鉴。还有一个容易忽略的细节FactoryBean会走上一个特殊分支。Spring会先创建FactoryBean本身然后根据它的配置决定是否立即调用getObject来产出真正的Bean。如果你自定义过FactoryBean可以观察它在启动时是不是被调了两次——一次是自身初始化一次是产出对象这就是preInstantiateSingletons这里的分叉逻辑在起作用。搞懂这一步很多FactoryBean相关的奇怪现象就能解释通了。4.2 getBean到doCreateBean中间的转折getBean的完整调用链是AbstractBeanFactory#getBean - doGetBean - AbstractAutowireCapableBeanFactory#createBean - doCreateBean。doGetBean的第一步会通过getSingleton(beanName)从缓存里尝试获取拿到就直接返回拿不到才进入创建逻辑。进入创建前Spring会处理循环依赖监测如果当前beanName处于正在创建状态且又要求创建它就说明出现了循环引用此时会根据allowCircularReferences配置决定是否允许。同时doGetBean里还会处理DependsOn先递归创建依赖的Bean。真正干活的是doCreateBean内部脉络是创建实例 - 提前暴露 - 填充属性 - 执行初始化 - 注册销毁回调。每步都有后置处理器参与。核心骨架是这样的protected Object doCreateBean(String beanName, RootBeanDefinition mbd, Object[] args) { BeanWrapper instanceWrapper createBeanInstance(beanName, mbd, args); Object bean instanceWrapper.getWrappedInstance(); boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); } Object exposedObject bean; populateBean(beanName, mbd, instanceWrapper); exposedObject initializeBean(beanName, exposedObject, mbd); return exposedObject; }createBeanInstance里面最值得研究的是构造器推断。Spring会根据BeanDefinition里的构造参数、Autowired构造器候选、以及智能构造器选择逻辑决定用哪个构造器来实例化。只有一个无参构造时走SimpleInstantiationStrategy的instantiateClass有多个构造器且标注了Autowired时靠prioritizeConstructors配合autowireConstructor来挑选。这个机制是Spring 5里多个构造器时Bean还能创建成功现象背后的功臣。凡是在业务代码里遇到过为什么我写了两个构造器Spring报找不到合适构造器答案基本就在这段逻辑里。4.3 三级缓存数据结构和循环依赖的求解路径Spring三级缓存原理大概是面试命中率最高的源码问题。三个缓存定义在DefaultSingletonBeanRegistry里private final MapString, Object singletonObjects new ConcurrentHashMap(256); private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); private final MapString, ObjectFactory? singletonFactories new HashMap(16);一级缓存singletonObjects存成品Bean二级缓存earlySingletonObjects存提前暴露出来的早期Bean已经生成对象但还没完成初始化三级缓存singletonFactories存ObjectFactory也就是生产早期Bean的工厂。为什么需要三级而不是两级核心奥义在于延迟生成和支持代理覆盖。如果从来没有循环依赖三级缓存里的工厂永远不被调用白白躺在Map里无伤大雅。一旦发生循环依赖比如A依赖B、B依赖A整个过程是这样的A先被实例化但属性还没填充就把A的ObjectFactory放到三级缓存A填充属性时发现需要B于是创建BB实例化后提前暴露B填充属性时发现需要A此时getSingleton从一级缓存找不到A二级缓存也没有但三级缓存里找到了A的ObjectFactory调用它生成一个早期A的引用放入二级缓存并删除三级缓存中的工厂B拿到这个早期A的引用完成自身的属性和初始化B初始化完成后回到A的填充环节A继续完成后续初始化。整个过程通过提前暴露半成品引用绕开了单例创建顺序的死结。关键点在第四步这个早期引用是不是被代理过取决于有没有SmartInstantiationAwareBeanPostProcessor在getEarlyBeanReference环节介入。如果A上有Async或者AOP切面这里就会先产生代理对象使得B注入的是代理而非裸Bean。这也是为什么三级缓存不能用单级或者二级简单替代二级缓存里存的是已经生成好的对象但代理对象的生成时机需要等到BeanPostProcessor链到位而ObjectFactory正好提供了必要时再生成的延迟能力。有一类循环依赖是三级缓存救不了的构造器注入。因为构造器注入发生在createBeanInstance阶段此刻Bean还没有机会提前暴露进三级缓存依赖方根本找不到它的早期引用。所以Spring官方和社区的建议一致循环依赖优先用setter或字段注入。另外Spring Boot 2.6以后默认禁止循环依赖老项目升级时遇到BeanCurrentlyInCreationException多半都是这个默认值变化引起的。4.4 初始化回调PostConstruct、InitializingBean与init-method的执行顺序很多新手对Bean初始化多个回调的执行顺序搞不清楚。直接给结论先PostConstruct标注方法然后InitializingBean#afterPropertiesSet最后才是XML或注解里指定的init-method。为什么是这个顺序PostConstruct由CommonAnnotationBeanPostProcessor处理它属于BeanPostProcessor的前置阶段afterPropertiesSet是Bean实现接口的自定义逻辑init-method则是配置声明式的兜底方案。三者依次覆盖注解标识、接口扩展、配置声明三类场景层层递进。initializeBean的实现里Spring先调用applyBeanPostProcessorsBeforeInitialization再调用invokeInitMethods处理afterPropertiesSet和init-method最后调用applyBeanPostProcessorsAfterInitialization。有一个隐藏坑PostConstruct在Spring 5和Spring 6两代体系里包路径不同Spring Boot 3配旧包名会静默失效。如果发现初始化方法没执行先检查import的是不是jakarta.annotation.PostConstruct而不是javax.annotation.PostConstruct。这个坑排查成本低收益高值得优先验证。顺着初始化回调还能引出一个高频知识点BeanPostProcessor分前后AOP代理通常在postProcessAfterInitialization阶段生成。也就是说代理对象产生于Bean初始化完成之后这也是很多自调用不走代理问题的根源——你调自己类里的另一个方法走的是this引用而不是代理引用自然绕过了切面逻辑。想自调用也走代理要么注入代理引用要么用AopContext.currentProxy()。5. 实战避坑初始化相关的故障排查与优化5.1 循环依赖报错该怎么定位最常见的报错是BeanCurrentlyInCreationExceptionSpring会打印类似Requested bean is currently in creation: Is there an unresolvable circular reference?的提示。定位思路不要靠猜按三步走看堆栈里出现的beanName把创建A - 创建B - 创建A的链条列出来确认这几个Bean的注入方式是构造器还是setter如果是构造器注入改成setter/字段注入或者用Lazy在构造参数上加延迟代理破坏硬依赖。Spring Boot 2.6以后还有一个大坑官方默认禁止循环依赖setter循环依赖也会直接抛异常。很多从老版本升级的项目都会踩到。我见过不少团队为了省事把配置项spring.main.allow-circular-references设为true一开了之。短期确实省事长期会让问题隐藏起来尤其当循环依赖链路越来越复杂时排查成本会指数级增长。我的建议是能重构就重构不能重构就用Lazy注解精确处理尽量别全局开开关。这个开关每开一次就是在告诉Spring我对这条循环链路心里有数但你真的有数吗最好先画清楚依赖图再说。5.2 Bean初始化顺序错乱怎么排序有时候希望A先初始化、B后初始化因为A要在B创建前做前置准备。最直观的做法是让A依赖B由Spring被动排好序但很多场景下两者没有注入关系纯粹是时序要求这时用DependsOn注解显式声明依赖顺序。它的实现原理在doGetBean里如果当前beanName对应的依赖beanNames不为空会先递归getBean这些依赖从而强制顺序。对代码可读性来说DependsOn比人为调整类名顺序可靠得多。也可以用ApplicationListener事件解耦初始化逻辑。比如某个Bean创建完发布一个事件另一个Bean监听事件做后续动作。这种方式比硬编码顺序优雅但要小心事件监听的线程模型如果异步监听线程池必须在事件发布前初始化完毕如果同步监听事件发布顺序又可能反过来约束初始化顺序。还有一点经验之谈初始化阶段的事件监听最好别做重IO操作因为finishRefresh发布ContextRefreshedEvent时对外服务往往已经处于即将对外的临界状态监听器里一旦阻塞整个启动流程都会被拖住。5.3 初始化慢的常见元凶线上服务启动慢很多人第一反应是Bean太多了。其实Bean数量多往往只是表象真正耗时的是三类元凶元凶类别典型表现定位手段解决方向初始化阶段IOPostConstruct里查库、调接口JDK Flight Recorder抓启动期阻塞把IO挪到懒加载或首次调用复杂代理链多个AOP切面叠加async-profiler看热点方法缩减切面、精准pointcut单例互相等待线程池在Bean初始化时被占用启动线程转储调整初始化依赖和异步策略先说第一个初始化阶段埋网络或数据库IO是最常见的反模式。一次DB调用可能耗时几十到几百毫秒如果几十个Bean各自初始化时都查一次库启动时间瞬间破秒。第二个是代理链每加一层AOPBean初始化就要多做一次穿针引线而且代理对象生成后还要再次经过一遍后置处理器检查。第三个是单例互相等待比如A初始化时要等一个线程池提交任务而线程池的创建又依赖B这类问题的排查难度最高只能靠线程转储和分析依赖关系手工定位。定位慢启动我习惯的顺序是先看Spring启动耗时统计找出具体在哪一步卡住再用async-profiler采样启动期的CPU热点区分是CPU密集型还是IO阻塞。之后把耗时Top N的Bean逐个审通常能覆盖九成以上的启动慢问题。如果真要上并行初始化比如Spring Boot的并行Bean初始化特性要谨慎评估对依赖关系和BeanPostProcessor顺序的影响否则很容易踩出新问题。6. 写在后面一点个人体会我个人在实际操作中的体会是Spring初始化流程里那些看似多余的步骤和缓存没有一步是白白设计的。如果只记住一句话我希望是这句——初始化流程的每一步背后都是对顺序二字的极端执着先定义后使用先注册后实例化先暴露后填充。写这篇东西的时候我脑子里一直浮着一个画面第一次手写极简IoC容器试图把Spring的初始化流程缩到最小结果砍到第三步就发现没有BeanDefinition就已经寸步难行。那次经历让我彻底明白框架设计里的重往往是为了换稳。最后再分享一个小建议如果你想深入验证自己的理解最好的方式不是反复看别人的源码分析而是打开源码自己走一遍断点从new AnnotationConfigApplicationContext()开始在refresh的12步里逐个加断点观察每一步前后BeanDefinition数量和singletonObjects的变化。这个过程比任何教程都更能帮你建立整体感。实测下来走完一遍之后再面对Spring相关的面试题和线上问题心里会稳得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →