做Java后端的朋友,不管你是刚写业务代码的初级工程师,还是已经独立带模块的资深开发,Spring容器启动流程都是绕不开的关卡。CRUD写得再多,注解记得再熟,真遇到“Bean怎么多了一个”“为什么启动就报循环依赖”“Spring Boot扫不到我写的@Component”这类问题时,如果对容器的理解只停留在“用完就行”,排查起来基本靠猜。
Spring容器启动流程,说白了就是一段从配置到可用的过程。Spring把你写在XML、注解、JavaConfig里的Bean定义(BeanDefinition)加载进来,经过解析、后处理、实例化、属性填充、初始化,最终变成一个个可用的Bean对象放进容器里,再通过依赖注入在运行期把不同Bean串起来。这篇文章不晒源码截图,不讲高深术语,我只从一个实际调试过、阅读过源码、被Spring启动折磨过多次的从业者视角,把这条链路从头到尾捋一遍,重点讲清楚每一步在做什么、为什么这么做,以及出问题时该往哪里看。
适合谁看?如果你是Spring新手,这篇文章帮你建立完整的容器认知框架;如果你已经写过不少Spring代码,文中整理的扩展点、循环依赖分析、启动排查技巧,应该能让你下次遇到诡异启动问题时少走很多弯路。
1. 先搞清楚Spring容器到底在“启动”什么
1.1 容器的本质:一个装着Bean定义和实例的“仓库”
很多新手容易混淆一个概念:Spring容器不是一个“活着”的对象,它更像一个仓库管理系统。你往里面登记商品信息(BeanDefinition),它按流程加工出商品实物(Bean实例),同时管理这些实物的创建、使用、销毁。
从接口层面看,Spring容器的根是BeanFactory,最常用的实现是DefaultListableBeanFactory。ApplicationContext是在BeanFactory之上做了增强的高级容器,额外提供国际化、事件发布、资源加载、环境抽象等能力。Spring Boot中的ApplicationContext,在Web场景下通常是AnnotationConfigServletWebServerApplicationContext,非Web场景下是AnnotationConfigApplicationContext。
这里要记住一个核心结论:不管外层包了多少层ApplicationContext,底层真正执行Bean管理逻辑的,还是那个DefaultListableBeanFactory。理解启动流程,核心就是看这个类在启动过程中做了哪些动作。
| 容器类型 | 核心能力 | 典型场景 |
|---|---|---|
| BeanFactory | Bean定义注册、实例化、依赖注入 | 嵌入式、轻量场景 |
| ApplicationContext | BeanFactory能力 + 事件、国际化、AOP集成 | 绝大部分Java应用 |
| Spring Boot的ApplicationContext | 上述能力 + 自动配置、Web服务内嵌 | Spring Boot应用 |
1.2 从配置到可用的全过程概览
我把整个启动流程拆成四个大阶段,这样看代码不容易迷路。
阶段一是配置加载与注册。读取XML、执行@ComponentScan扫描、解析@Configuration配置类,生成BeanDefinition并注册到容器。阶段二是BeanDefinition后处理。容器调用BeanFactoryPostProcessor,允许在Bean实例化之前修改BeanDefinition,比如把某个Bean的scope从singleton改成prototype,或者动态替换Bean的实现类。阶段三是Bean实例化与初始化。容器遍历所有非懒加载的单例Bean,逐个实例化、填充属性、执行初始化回调。阶段四是容器刷新完成,发布ContextRefreshedEvent事件,Web容器启动额外逻辑。
四个阶段不是平摊时间的。我之前用JFR做过公司项目的启动录制,阶段三经常占启动总耗时的70%以上,尤其是Bean数量多、依赖链路长的中大型项目。所以排查启动性能问题,重点看阶段三,这个结论我会在后面展开。
2. 启动流程的核心阶段拆解
2.1 第一阶段:配置元数据加载与注册
以最常见的注解方式为例。当你执行new AnnotationConfigApplicationContext(AppConfig.class)时,构造方法里其实做了三件事:创建DefaultListableBeanFactory,注册容器内置的Bean后置处理器,然后调用refresh()方法。
refresh()第一步是prepareRefresh,做容器状态标记和环境属性校验;然后是obtainFreshBeanFactory,XML时代这一步会去解析XML定义,注解方式下直接复用已有的BeanFactory。
接下来是invokeBeanFactoryPostProcessors,这一步是整个启动流程里的重头戏。处理顺序非常讲究:
- 先处理容器内部注册的BeanDefinitionRegistryPostProcessor。
- 再处理用户定义的BeanDefinitionRegistryPostProcessor,按PriorityOrdered、Ordered、无序三个批次依次执行。
- 最后处理普通BeanFactoryPostProcessor。
为什么这个顺序极其重要?因为@Configuration配置类的解析、@ComponentScan扫描、@Import导入、@MapperScan这类功能,本质都是BeanDefinitionRegistryPostProcessor在起作用。其中ConfigurationClassPostProcessor是最关键的一个。
举个例子,ConfigurationClassPostProcessor执行时,会把AppConfig配置类做深度解析。配置类上若有@ComponentScan,就会扫描指定包路径下的所有类,把带@Component、@Service、@Repository、@Controller注解的类逐个注册成BeanDefinition。注意,这个阶段处理的是“定义”,不是创建实例。BeanDefinition里记录了类的全限定名、构造器信息、属性值、初始化方法等元数据,相当于一份“创建说明书”。
2.2 第二阶段:BeanDefinition的后处理窗口
在BeanDefinitionRegistryPostProcessor处理完后,所有类层面的BeanDefinition基本都注册进容器了,但还没开始实例化。这时候容器提供了一个全局修改窗口,叫BeanFactoryPostProcessor。
这个阶段比较抽象,我举个真实场景。假设你引入了一个第三方库,它里面某个Bean初始化特别慢,每次启动都要空等好几秒,而你又改不了第三方源码。这时候可以在自己项目的配置类里定义一个BeanFactoryPostProcessor,把那个BeanDefinition的lazyInit属性改成true。由于BeanFactoryPostProcessor执行在Bean实例化之前,这个修改完全来得及。
注意一个关键点:BeanDefinitionRegistryPostProcessor本身也是BeanFactoryPostProcessor的扩展,但它必须优先执行。逻辑很简单——先把BeanDefinition注册齐,后面基于BeanDefinition的修改才有意义。这个设计是典型的“先注册后处理”模式,注册者是生产者,后处理器是消费者。如果你在网上看到有文章把两者的调用顺序搞混,那说明作者没读懂源码。
2.3 第三阶段:实例化前准备与单例预创建
BeanFactoryPostProcessor执行完毕后,容器会注册所有BeanPostProcessor。这个名字与BeanFactoryPostProcessor只差几个字母,但作用对象完全不同:前者作用于BeanDefinition,后者作用于Bean实例。
接着容器进入finishBeanFactoryInitialization阶段,做几件准备工作:
- 初始化类型转换服务ConversionService。
- 配置嵌入式值解析器,也就是@Value占位符的解析器。
- 冻结BeanDefinition,此后不允许再修改。
- 遍历所有非懒加载单例Bean,逐个调用getBean触发创建。
这里有个容易被忽视的细节:在预创建单例Bean之前,容器会先检查注册的BeanDefinition数量。如果容器里一个BeanDefinition都没有,直接跳过实例化阶段。所以当你发现Spring Boot启动很快、但业务Bean一个都没创建时,第一步先去确认BeanDefinition到底有没有被扫描进来,而不是怀疑容器有问题。
触发getBean之后,每个Bean具体怎么被创建,见下一章。
3. 关键环节:实例化、属性填充与初始化
3.1 实例化策略:构造器推断与提前引用
每个Bean进入getBean流程后,先查缓存,看单例池里有没有现成的实例;没有再判断是否单例,并尝试解决循环依赖问题;最后才进入doCreateBean。
doCreateBean第一步是createBeanInstance,Spring根据BeanDefinition的配置选择实例化策略。如果指定了instanceSupplier,直接用工厂方法生成;如果指定了factoryMethodName,就调用对应工厂方法;否则走构造器自动装配。
构造器自动装配是重头戏。Spring通过ConstructorResolver的resolveAutowiringByType方法,从容器里找出匹配构造器参数类型的Bean,再用反射创建实例。这里有个实际行为容易踩坑:如果一个类有多个构造器,Spring会优先选择无参构造器。只有一个有参构造器时,按参数类型自动注入。多个构造器且都没加@Autowired时,Spring需要自动推断最合适的那个,匹配度综合参数类型和名称决定。
创建出实例后,紧接着是一个关键动作:allowEarlyReference。如果是单例Bean且正在创建中,Spring会把当前实例包装成ObjectFactory放进三级缓存singletonFactories。这个设计的唯一目的,就是给循环依赖留出“提前暴露”的窗口,后面会详细说。
3.2 属性填充与依赖注入
实例创建完成,Spring进入populateBean属性填充阶段。这个阶段做的事非常多,也是Bean真正“长出依赖”的地方。
核心流程是先调用InstantiationAwareBeanPostProcessor的postProcessAfterInstantiation回调,如果该方法返回false,Spring直接跳过后面的属性填充。这是很多定制场景爱用的短路开关。接着是postProcessPropertyValues,这是@Autowired、@Value、@Resource等注解依赖注入的真正入口。
AutowiredAnnotationBeanPostProcessor会遍历目标Bean的所有字段和方法,找出带@Autowired的依赖点,通过getBean获取依赖实例后反射设置。@Value则通过嵌入式值解析器做占位符替换和类型转换。
这里有无数人栽过的坑:当依赖的Bean尚未创建时,@Autowired会触发目标Bean的创建。假设BeanA依赖BeanB,BeanB又依赖BeanA,就形成了循环依赖。Spring的三级缓存机制正是为了解决这个问题而生的。
3.3 初始化回调:从Aware到@PostConstruct
属性填充完成,Spring进入initializeBean阶段。这个阶段的执行顺序非常严格,实际顺序如下:
构造方法 -> @Autowired属性填充 -> BeanNameAware等Aware接口 -> @PostConstruct -> InitializingBean.afterPropertiesSet -> init-method先回调Aware系列接口。BeanNameAware、BeanClassLoaderAware、BeanFactoryAware依次调用,这个顺序在源码和文档里都是固定的。我自己在扩展BeanFactoryAware做容器上下文注入时踩过顺序的坑,这里特意提醒一句。
然后是BeanPostProcessor的postProcessBeforeInitialization。这里有个重要细节:@PostConstruct注解的执行点是在InitDestroyAnnotationBeanPostProcessor的postProcessBeforeInitialization方法里。也就是说,@PostConstruct在时机上早于InitializingBean和init-method。
为什么是这个顺序?我的理解是,Aware接口必须先执行,因为Bean得先知道自己叫什么名字、属于哪个BeanFactory,才有能力去初始化。而@PostConstruct放在InitializingBean之前,是Spring“注解优先于接口”的哲学体现,init-method因为是历史包袱,所以排最后。
最后是postProcessAfterInitialization,这是AOP自动代理的入口。AbstractAutoProxyCreator在这个阶段判断目标Bean是否满足切点要求,满足则生成代理对象并返回。所以说Spring AOP是“Bean初始化完成后包装出来的”,这句结论没有错。
3.4 三级缓存与循环依赖是怎么绕过去的
三级缓存就是DefaultSingletonBeanRegistry里的三个Map:
- singletonObjects:一级缓存,存最终成品Bean。
- earlySingletonObjects:二级缓存,存提前暴露的早期对象,可能还没填充属性。
- singletonFactories:三级缓存,存ObjectFactory,用于生成早期引用。
核心逻辑在DefaultSingletonBeanRegistry.getSingleton方法。我把代码简化一下,方便对照看:
protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }很多人问:为什么一定要三级缓存,不能直接把刚实例化的对象放进二级缓存吗?
我理解的核心在于:三级缓存里存的不是对象,而是ObjectFactory。这个工厂背后往往与SmartInstantiationAwareBeanPostProcessor挂钩。典型场景是AOP代理:如果目标Bean需要代理,而代理对象要等到后置处理阶段才生成,那三级缓存允许后置处理器在“最终实例化完成前后”决定返回原始对象还是代理对象。如果直接放二级缓存,提前创建出的是裸对象,切面逻辑会漏掉。三级缓存的价值,就是延迟暴露决策。
再补充两个边界:构造器注入造成的循环依赖,三级缓存解不了,因为构造器执行阶段还没产生早期对象。prototype作用域的Bean也不会进缓存,循环依赖基本只能靠初始化回调处理。所以你在日志里看到UnsatisfiedDependencyException时,先确认是不是用了构造器注入,这是最典型的诱发方式。
4. 从Spring到Spring Boot:启动流程的自动化演变
4.1 Spring Boot的自动配置注入点
Spring Boot基于Spring,但把启动流程做了大量自动化包装。核心入口是SpringApplication.run(),幕后会创建ApplicationContext、准备环境变量、装配自动配置类。
关键点是:Spring Boot并没有改变Spring容器的核心流程,它只是在refresh()过程中额外添加了自动配置处理器和启动监听器。自动配置核心是靠@EnableAutoConfiguration,它通过@Import导入了AutoConfigurationImportSelector,后者读取并加载自动配置类列表。
你顺着这个思路去看源码就不会迷路:从main方法进入SpringApplication.run,再到createApplicationContext、refreshContext、invokeBeanFactoryPostProcessors,一路走下来,最终还是回归到Spring原生容器启动流程。我把“Spring容器启动流程”当作理解Spring Boot自动配置的地基,就是这个原因。
4.2 启动流程中的扩展点在哪里找
我整理了几个最常用的扩展点,很多框架和中间件就是靠它们写出来的:
| 扩展点 | 接口/注解 | 执行时机 | 典型用途 |
|---|---|---|---|
| Bean定义注册 | BeanDefinitionRegistryPostProcessor | refresh早期 | 动态注册BeanDefinition,如MyBatis的Mapper扫描 |
| Bean定义修改 | BeanFactoryPostProcessor | BeanDefinition解析完成后 | 修改Bean属性、scope、懒加载设置 |
| Bean实例化前拦截 | InstantiationAwareBeanPostProcessor | 实例化前后 | 动态代理创建、短路属性填充 |
| 属性填充后置处理 | postProcessPropertyValues | 属性填充阶段 | 实现@Autowired、@Value注入 |
| 初始化前 | postProcessBeforeInitialization | 初始化开始前 | @PostConstruct执行点 |
| 初始化后 | postProcessAfterInitialization | 初始化完成后 | AOP代理生成 |
| 容器刷新事件 | ApplicationListener | refresh完成后 | 启动后执行一次性任务 |
这张表是Spring扩展机制的“地图”。出问题时先定位目前是哪个阶段,再去对应的扩展点找切入点,比一头扎进源码瞎翻效率高得多。我遇到不少人问“怎么在Spring启动后执行一段业务逻辑”,答案往往就是表格最后一行的ContextRefreshedEvent。
4.3 手写一个最小容器启动流程
理解启动流程最好的方式不是听我讲,而是动手写。我之前为了验证自己有没有真正理解,手写过一套极简的Spring容器,核心代码只有几十行,但把BeanDefinition注册、实例化、属性填充、BeanPostProcessor串起来了。放一个简化版核心逻辑供参考:
public class MiniApplicationContext { private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); private final List<BeanPostProcessor> postProcessors = new ArrayList<>(); private final Map<String, BeanDefinition> beanDefinitions = new HashMap<>(); public void registerBeanDefinition(String name, BeanDefinition definition) { beanDefinitions.put(name, definition); } public void refresh() { for (Map.Entry<String, BeanDefinition> entry : beanDefinitions.entrySet()) { String name = entry.getKey(); if (!entry.getValue().isLazyInit() && !singletonObjects.containsKey(name)) { getBean(name); } } } public Object getBean(String name) { if (singletonObjects.containsKey(name)) { return singletonObjects.get(name); } BeanDefinition definition = beanDefinitions.get(name); Object instance = instantiate(definition); singletonObjects.put(name, instance); // 模拟提前暴露 populate(instance); Object finalBean = initialize(instance); singletonObjects.put(name, finalBean); return finalBean; } }这种写法虽然粗糙,但会非常直观地看到“先实例化、再填属性、再初始化”的顺序,看到为什么AOP代理要放到最后,也看到循环依赖里拿到的是早期引用而不是最终Bean。我给所有想深入Spring底层的人同一个建议:先自己写一遍最小容器,再回来看源码,很多概念一下子就钉进脑子里了。
5. 常见问题与排查技巧实录
5.1 NoSuchBeanDefinitionException与BeanDefinition缺失
这个应该是Spring开发中出现频率最高的启动报错。报错信息通常是NoSuchBeanDefinitionException,意思是按类型或名字找不到Bean。
我的排查步骤一般是这样的:
先确认对应类有没有被Spring扫描到。最常见的原因是Controller、Service实现类漏加了@Component,或者包路径与@ComponentScan设置的路径不一致。重点检查配置类上的@ComponentScan是否覆盖了目标包。
再看是不是接口类型注入但容器里没有对应实现类。比如你注入一个List接口类型,但容器里没有List接口的实现类Bean,这种错误比较隐蔽。
最后看是不是BeanName冲突被覆盖了。两个同名的BeanDefinition注册,后加载的覆盖先加载的,导致最终容器里找不到预期的那个。
提示:排查Bean注册问题时,启动参数加--debug能够输出自动配置报告和条件匹配结果。在Spring Boot 2及以上版本中,debug模式会打印所有自动配置类的匹配情况,这是定位“为什么这个Bean没注册”的最快方式。
5.2 循环依赖报错与构造器注入的冲突
循环依赖在默认情况下其实可以解决,前提是依赖关系能够适配三级缓存的机制。但我看到的循环依赖报错,大部分是构造器注入引起的。
举个例子:A的构造器需要B,B的构造器需要A。A创建时构造器要B,B还在创建中,B又回头要A,而A还没完成实例化,三级缓存里没有A的早期对象。这种情况下三级缓存无能为力。
解决办法有三个方向:改成字段注入或setter注入,让某一方不再依赖构造器;用@Lazy修饰其中一个构造器参数,延迟创建;或者重构依赖关系,把A和B互相依赖的部分抽出去。
5.3 启动慢的核心排查思路
启动慢的问题,绝大多数场景下不是Spring容器本身慢,而是Bean初始化业务慢。我复盘过几次项目启动慢的案例,方向集中在这几个方面:
数据源连接池初始化。很多项目在启动时提前初始化数据库连接池,连接池创建和连接检查占用大量时间。解决思路是调整HikariCP的initializationFailTimeout和connectionTimeout参数,或者把连接池改成懒加载。
第三方SDK的初始化。注册中心、配置中心、消息客户端等组件在启动阶段会做网络握手,一旦遇到超时重试,启动时间会被拉得很长。看启动日志里的耗时热点,基本能锁定是哪个组件。
全量单例预创建。Spring默认预创建所有非懒加载单例Bean,如果项目里Bean数量上万,预创建阶段自然慢。可以将不常用的Bean设置成@Lazy,尤其是那些重接口对接类Bean。
排查方法我推荐使用打点法:把logback启动日志级别调到DEBUG,或者用JFR录制启动过程,能非常直观地看到每个阶段的时间消耗,比凭感觉猜靠谱得多。
5.4 一个能救命的启动阶段定位技巧
最后分享一个我一直在用的调试技巧:Spring的refresh()方法里每一大步都有对应的日志锚点。当启动报错时,看最后一条DEBUG或INFO日志,基本就能判断卡在哪个阶段。
日志停在Invoking BeanFactoryPostProcessors,说明BeanDefinition解析还没完成;停在Creating shared instance of singleton bean 'xxx',说明卡在某个具体Bean的实例化阶段;停在Finishing refresh,说明容器刷新快完成但事件监听器报错了。
这个方法我用了很多年,比乱加断点高效得多。遇到复杂启动问题,先看日志锚点,再结合上面的扩展点表定位到具体代码,问题通常就能在半小时内水落石出。换个角度想,Spring容器启动流程虽然庞杂,但它本质上是一套高度有序的流水线。只要摸清每个环节的职责和顺序,再复杂的问题都能沿着这条线索一步步找到根因。