1. 从main方法到SpringApplication:入口背后的初始决策
先别急着看run(),我把SpringBoot的启动流程拆开讲。天天写SpringApplication.run(Application.class, args),但这一行背后经过了哪些阶段、每个阶段都在做什么,很多人其实是朦胧的。搞清楚这套流程,不是为了装X,而是当启动变慢、起不来、或者你想在启动阶段干点私活的时候,你知道该去哪里下手。
1.1 SpringApplication构造阶段到底做了什么准备
你调用SpringApplication.run(),第一步不是跑run,而是先new一个SpringApplication实例。这一步很多人忽略了,但信息量很大。它要做四件核心的事:
- 从classpath下推断当前应用类型,是WebFlux(响应式Web)还是Servlet Web,还是普通非Web应用。
- 加载
META-INF/spring.factories,这个文件里注册了一堆启动阶段要用的工厂类,比如ApplicationContextInitializer、ApplicationListener。 - 找到main方法所在类,记住它是主配置源。
- 读取应用的主配置属性,比如
spring.main.*相关配置。
这里最容易被人跳过的是setSources操作。SpringApplication在构造后会保存初始化参数,但真正的主配置类是在run阶段才加入的。很多人调试的时候发现bean没有扫描到,十有八九是主启动类位置放错了,扫描范围没有覆盖到你的业务包。
另外一个隐藏细节是WebApplicationType的推断逻辑:先去查classpath里有没有org.springframework.web.reactive.DispatcherHandler,有就判断为WebFlux;如果没有再查javax.servlet.Servlet和org.springframework.web.context.ConfigurableWebApplicationContext是否存在,来判断是不是Servlet Web。不同推断结果直接影响后面容器的创建策略。
1.2 run方法入口:整体阶段划分
进入run方法后,整个启动流程可以划成六大阶段,我先把版图铺开,后面逐段拆:
- 计时器启动与Java AWT headless模式设置。
- 通过
SpringApplicationRunListeners广播Starting事件。 - 组装ApplicationArguments(命令行参数)和配置环境。
- 打印Banner。
- 创建并刷新ApplicationContext。
- 发布Started事件,执行Runner回调。
六个阶段里,最肥的一段是创建和刷新容器,我们日常遇到的大部分启动问题都埋在这一段。但前面几个"小阶段"也不是走过场,比如命令行参数的处理逻辑,直接决定了你--server.port=8081能不能生效。
2. 环境准备阶段:配置从哪里来、往哪里去
环境(Environment)是SpringBoot启动的一个核心枢纽,它管着后续所有属性值的来源。你可以把它想象成一个大水表,所有配置源往这里灌水,容器里的bean再从这里面取水。
2.1 配置源加载顺序与覆盖优先级
SpringBoot的配置源不是一个,而是一串。它们按固定优先级排列,高优先级覆盖低优先级。常见的配置源从高到低大概是:
- 命令行参数(最高)
- Java系统属性(
System.getProperties()) - 操作系统环境变量
- application-{profile}.yml / application.yml
- 带
@PropertySource注解的配置
这里给个我实测过的例子。一个服务在本地跑得好好的,部署到某环境之后就出现端口不对,后来查下来是系统环境变量里有一个SERVER_PORT=8080,覆盖了application.yml里的server.port=9090。如果你不理解优先级模型,这种问题排查起来就是无头苍蝇。
配置环境还有一个值得一提的作用:profiles的激活时机。环境准备好之后才去解析spring.profiles.active,然后加载对应的profile专属配置。所以你在application.yml里配置profile相关项,和你在启动参数里--spring.profiles.active=dev指定,效果一样,但有个隐藏坑:环境变量里激活的profile优先级最高,本地开发时一旦环境变量设置了,本地配置就永远不生效。
2.2 环境准备阶段的扩展点:EnvironmentPostProcessor
环境阶段有个很少被提及但极其好用的扩展点:EnvironmentPostProcessor。这是最早期的介入窗口,在环境对象构建出来之后、容器创建之前执行。它的典型用途:动态注入自定义配置源、按机房/标签修改配置项、解密配置中心下发的密文。
实现方式不复杂,实现接口然后注册到META-INF/spring.factories的org.springframework.boot.env.EnvironmentPostProcessor键下。需要注意两点:一是在这个阶段你拿不到Spring容器,只能操作Environment对象;二是手动添加的配置源加到列表末尾,默认优先级最低,要主动调addFirst才能抬高优先级。
3. 容器创建与刷新:启动流程的主战场
环境准备就绪后,SpringBoot开始创建Spring容器(ApplicationContext)。不同类型应用创建不同容器,Servlet Web应用创建的是AnnotationConfigServletWebServerApplicationContext,普通应用创建的是AnnotationConfigApplicationContext。这一步只花了很少的代码,但后续的refresh才是大头。
3.1 refresh()的12个步骤:Spring的生命周期总动员
容器创建出来后,调用的refresh()方法是整个Spring框架最核心的方法,12个步骤我挑重点说:
prepareRefresh():准备刷新前的状态,设置启动时间、活跃标志、初始化占位符。obtainFreshBeanFactory():告诉子类刷新内部bean工厂。prepareBeanFactory():配置bean工厂的标准上下文特性,比如类加载器、SpEL解析器。postProcessBeanFactory():给子类扩展点,SpringBoot的Web容器整合就在这里动手脚。invokeBeanFactoryPostProcessors():执行所有BeanFactoryPostProcessor,这是给你机会在bean实例化之前修改bean定义。registerBeanPostProcessors():注册所有BeanPostProcessor,它们负责在bean实例化前后做拦截。initMessageSource()、initApplicationEventMulticaster():初始化国际化资源和事件广播器。onRefresh():这是个模板方法,SpringBoot在这里创建Web服务器。registerListeners():把ApplicationListener注册到广播器。finishBeanFactoryInitialization():剩下的非懒加载单例bean在这里全部实例化。finishRefresh():发布ContextRefreshed事件,启动生命周期处理器。
要理解SpringBoot的启动流程,最短路径就是把refresh()这12步吃透。因为SpringBoot所做的"自动"几乎全是在这些扩展点里塞了自己的逻辑。
3.2 内置Web容器的创建:Tomcat是在什么时候诞生的
很多刚接触SpringBoot的人有个误解:Web服务器是"启动之前"就起来的。其实不是。Tomcat的创建发生在refresh的onRefresh()阶段。也就是说,容器把大部分基础设施准备好之后,才轮到内嵌服务器出场。
Tomcat的创建过程可以细化为四步:
getWebServerFactory():从容器里拿ServletWebServerFactory,默认是TomcatServletWebServerFactory。- 配置端口、协议、连接器参数(从Environment里读)。
getTomcatWebServer():实例化Tomcat,设置工作目录、初始化连接器。- 调用
tomcat.start()启动服务。
这里有个实战场景值得多说一句:如果你的业务代码里有用到@PostConstruct且它触发了对外的HTTP请求,而这个请求打回自己的端口,会出现Connection refused。原因很简单:@PostConstruct是在bean初始化阶段执行的,此时Tomcat还没到onRefresh阶段启动,端口根本没开。
3.3 单例Bean实例化阶段:启动时间的大头
finishBeanFactoryInitialization()是启动流程里最耗时的一步,因为所有非懒加载单例bean都要在这里创建。bean多、依赖链深的应用,启动慢基本都是卡在这一段。
这段逻辑的核心是DefaultListableBeanFactory.preInstantiateSingletons():按注册顺序遍历bean定义,逐个实例化。遇到循环依赖(A依赖B、B依赖A)时,Spring通过三级缓存解决,而不是报错——除非你设置了allowCircularReferences=false。
从排查启动性能的角度,你可以用Java Flight Recorder(JFR)录制启动过程,然后在事件里看ClassLoad、InstanceCreation的高耗时分布。实际经验中,启动慢的元凶往往是某个初始化逻辑里做了远程调用、数据库连接池预热或者无脑扫描,而不是Spring框架本身的效率问题。
4. 自动装配的底层机制:为什么你的bean能被找到
SpringBoot最亮眼的特性是"自动装配"。很多人知道它的存在,但搞不清它和普通@ComponentScan的区别。说白了:组件扫描负责找你自己写的bean,自动装配负责加载第三方库的bean。两者协同完成了一个SpringBoot应用的装配。
4.1 组合注解的层层拆解
主启动类上的@SpringBootApplication其实是一个复合注解,它内部组合了三个核心注解:
@SpringBootConfiguration:本质上是一个@Configuration,标志当前类为配置类。@EnableAutoConfiguration:启动自动装配功能的开关。@ComponentScan:扫描主类所在包及其子包下的组件。
值得注意的是@EnableAutoConfiguration内部还有一个@AutoConfigurationPackage注解,它的作用是把主类所在的包注册成一个"自动配置包"的基础包。后面自动配置类里要扫描组件时,会拿这个基础包作为起点。这就是为什么主类包路径要被顶在项目最外层,否则服务层、控制器层全部扫不到。
4.2 自动配置类的加载与条件装配
@EnableAutoConfiguration通过@Import(AutoConfigurationImportSelector.class)导入了一个选择器。这个选择器在启动时会读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(老版本是spring.factories),把所有注册的自动配置类全部加载进来,这一步是"候选",不代表每个配置都会生效。
真正的"生效"靠的是条件注解,比如:
@ConditionalOnClass:classpath下存在某个类才生效。@ConditionalOnMissingBean:容器里没有指定bean才生效。@ConditionalOnProperty:配置了指定属性才生效。@ConditionalOnWebApplication:当前是Web应用才生效。
这就像一份大菜单,餐厅把所有菜品都列出来(加载候选),但后厨只做客人实际的菜(条件判断)、食材有货的菜(classpath存在性判断)。一整套下来,既灵活又不会让你引入的库之间互相打架。
我自己调试自动装配问题时的做法是:在启动配置里加--debug参数,启动后控制台会打印一份"自动装配报告",哪些生效、哪些不生效、为什么不生效,写得清清楚楚。排查第三方starter没生效的问题,第一步就靠它。
5. 监听器机制与启动后的Runner:启动流程的可观测性
启动流程不是闷头跑完就完了,SpringBoot在各个阶段都抛出了事件,给外部方一个"旁观和干预"的机会。
5.1 从Starting到Ready:启动事件的完整链条
SpringBoot的事件有明确的生命周期顺序:
ApplicationStartingEvent:run最开始,环境未准备。ApplicationEnvironmentPreparedEvent:环境已准备,容器未创建。ApplicationContextInitializedEvent:容器创建完毕,刷新未开始。ApplicationPreparedEvent:刷新前一刻。ApplicationStartedEvent:容器刷新完成,Runner未执行。ApplicationReadyEvent:Runner执行完毕,应用可以对外提供服务。ApplicationFailedEvent:启动异常时触发。
监听这些事件有两种姿势。一种是实现ApplicationListener接口并注册为Spring bean,这种方式能收到容器后续发布的事件,但前期的几个事件(Starting、EnvironmentPrepared)它收不到,因为那时候bean还没创建出来。另一种是启动时通过SpringApplication.addListeners()直接传入监听器,它能全程接收。
这一块的实际价值在于灰度环境上报、监控埋点、启动健康度检查。我曾经在一个服务里监听ApplicationReadyEvent,在收到事件后主动向注册中心上报实例元数据,确保服务完全就绪后才接流量,效果比固定sleep靠谱得多。
5.2 CommandLineRunner和ApplicationRunner的时序细节
容器刷新完成后,SpringBoot会执行容器里所有Runner的回调。两个接口功能一样,区别在于入参:CommandLineRunner拿到的是原始String数组,ApplicationRunner拿到的是封装好的ApplicationArguments对象。
执行顺序有讲究:如果多个Runner要实现优先级,可以用@Order注解。数值越小越先执行。这里还藏着一个细节:SpringBoot只认实现了ApplicationRunner或CommandLineRunner接口的bean,普通的@PostConstruct方法在bean实例化阶段就跑了,时序上比Runner更早。所以如果你要在"应用完全就绪后"做初始化,必须用Runner,不能用@PostConstruct。
需要强调的执行顺序是:@PostConstruct->InitializingBean.afterPropertiesSet()->ApplicationRunner/CommandLineRunner。搞清楚这三者的时机,能避免"在错误时机做了正确的事"这类启动问题。
6. 扩展点集锦与启动问题排查思路
很多框架难题的本质是对扩展时机不理解。SpringBoot的扩展点都锚定在启动流程的某个固定阶段,你只要知道启动到哪一步了,就知道自己该用哪个扩展点。
6.1 常用扩展点速查
| 扩展点 | 时机 | 典型用途 |
|---|---|---|
EnvironmentPostProcessor | 环境创建后、容器前 | 修改配置源、密文解密 |
ApplicationContextInitializer | 容器创建后、refresh前 | 给容器预置自定义bean工厂配置 |
BeanDefinitionRegistryPostProcessor | bean定义注册后 | 动态注册/修改bean定义 |
BeanFactoryPostProcessor | bean定义已注册 | 修改bean定义的属性值 |
BeanPostProcessor | bean实例化后 | 代理包装、属性检查 |
ApplicationListener | 事件发布时 | 状态感知、联动逻辑 |
Runner | 容器刷新后 | 数据预热、注册上报 |
按时机分成两组更好记:容器启动前用EnvironmentPostProcessor和ApplicationContextInitializer;容器启动中用BeanFactoryPostProcessor这种操作bean定义的;启动完成后用Runner和事件监听器。时序搞对了,代码不会错到哪里去。
6.2 启动失败排查的四个切入点
实际开发中SpringBoot启动失败大概分四类,排查手法各不相同:
第一类是容器初始化前就挂了,典型是端口被占用。这种问题看第一行异常就够,BindException直接告诉你答案,处理完端口冲突即可。
第二类是bean装配失败,报UnsatisfiedDependencyException或BeanCreationException。拿到异常栈,从下往上看,找到最底部的Caused by,那才是根因。常见是构造函数参数缺失、bean名称冲突、循环依赖未解决。遇到这种问题不要急着猜,先跑--debug看自动装配报告,确认你的bean是不是真的被扫描到了。
第三类是启动超时或卡住。这种最难查,因为没有什么异常可看。优先怀疑远程调用和数据库连接没设超时时间。用jstack抓线程栈看主线程停在哪一行,是百试百灵的办法。
第四类是环境问题导致启动逻辑走了错误分支。看起来是业务代码bug,其实是配置没生效或者profile激活不对。建议启动时打开--debug观察日志里配置源的加载顺序,再手动验证Environment里的值是否符合预期。
6.3 启动速度优化的一些实操心法
个人经验总结三个最有效的启动提速方向:
- 把不必要立即初始化的bean改为懒加载,通过
@Lazy或spring.main.lazy-initialization=true全局开启。代价是第一次请求时会慢,可能不符合某些场景,适合功能型应用。 - 排查并去掉启动期的远程调用。很多服务默认初始化就会去拉配置中心、调依赖服务,这些远程操作是启动慢的元凶。给它们加超时、降级,或者干脆延迟到后台任务执行。
- 检查classpath里的组件扫描范围。如果主类在顶层包设得太大,会把无关包全部纳入扫描,白白产生一堆不必要的bean定义。合理收窄扫描包范围,启动时间经常能砍掉三分之一。
另外分享一个排查启动时间的技巧:启动时加上-Dspring.boot.log.startup-info=true,启动完成后控制台会打印每个阶段耗时,一行一行核对,定位瓶颈特别方便。
7. 我对整套流程的理解
SpringBoot的启动流程虽然复杂,但骨架非常清晰:先构造引导类,然后准备环境,创建容器,刷新容器,最后对外暴露服务和托底回调。自动装配和事件监听都是挂在这条主线上的支线。理解它以后,你会有一种"能掌控启动行为"的底气:什么时机该做什么、什么扩展点能帮我做什么、出了问题从哪一步查,心里都有数。
这个内容后续还可以这样扩展:如果你想去折腾SpringCloud,会发现服务注册发现、配置刷新、熔断降级,本质上都在复用同一套启动扩展点体系。把SpringBoot启动流程吃透,后面学SpringCloud会轻松一大截。