1. 从入口主函数到容器就绪:一次启动到底经历了几个阶段
坦白说,我第一次把 SpringBoot 项目跑到Started Application in X.XXX seconds这句日志时,只觉得很爽,根本没想过这行字背后到底做了什么。直到有一次线上环境启动后接口迟迟不可用,日志既不报错也没有堆栈,我才意识到:不了解启动流程,遇到启动类问题基本只能靠盲猜。
后来我把SpringApplication.run()之前和之后的所有关键节点逐个断点了一遍,才真正看清整个启动管线。简单归纳,一次 SpringBoot 启动大约经历四个大阶段:
- 应用准备阶段:解析启动类上的
@SpringBootApplication,推断 Web 应用类型,加载ApplicationContextInitializer和ApplicationListener。 - 环境准备阶段:加载
application.properties/application.yml,确定激活的 profile,绑定端口等配置项。 - 容器刷新阶段:创建并刷新
ApplicationContext,完成 Bean 的扫描、注册、依赖注入,以及自动配置的生效。 - Web 服务启动阶段:创建内嵌的 Servlet 容器(如 Tomcat),绑定端口,注册 DispatcherServlet,最终对外提供服务。
这四个阶段不是等量齐观的。环境准备和容器刷新占了启动 80% 以上的耗时,Web 服务启动往往在最后几百毫秒内完成。而你如果想知道@SpringBootApplication入口这个看似很普通的组合注解为什么有这么大威力,就得从它内部的每个子注解开始挖。
这篇文章我从头到尾把这条链路拆一遍,顺便把我调试过程中的断点位置、日志观察方法、常见坑一起放进来,适合三类人:
- 刚接触 SpringBoot,想知道 main 函数启动之后发生了什么;
- 遇到启动慢、端口冲突、自动配置不生效,想要自己排查;
- 准备面试,但不想只背八股,想真正理解容器和 Spring 容器的关系。
2. @SpringBootApplication的三重身份:组合注解、自动装配与扫描边界
2.1 注解内部的"套娃"结构
很多新手以为@SpringBootApplication是一个独立的 Spring 注解,实际上它是一个典型组合注解,源码上标注了另外三个注解:
| 注解 | 实际作用 | 等价写法 |
|---|---|---|
@SpringBootConfiguration | 继承@Configuration,标明这是一个配置类 | @Configuration+ 额外的 Boot 语义 |
@EnableAutoConfiguration | 开启自动配置,启动自动配置导入器 | 单独标注在任意配置类上 |
@ComponentScan | 开启组件扫描,默认扫描当前包及其子包 | @ComponentScan+ 手动指定包路径 |
理解这三个注解的各自职责,是看懂启动流程的第一把钥匙。@SpringBootConfiguration让启动类本身成为一个 JavaConfig 配置类,因此你可以在启动类上直接定义@Bean。而@ComponentScan默认会把启动类所在包作为扫描原点,这解释了为什么很多人把启动类放在com.xxx根包下——放得越深,扫描到的组件就越少,越容易漏 Bean。
真正难懂的是@EnableAutoConfiguration。它本身不带任何实现逻辑,只是通过@Import(AutoConfigurationImportSelector.class)引入了一个选择器。Spring 容器在刷新阶段遇到ImportSelector时,会回调其selectImports()方法,返回一大批需要注册的配置类全限定名。自动配置类从哪里来?答案是配置文件:老版本读META-INF/spring.factories里的EnableAutoConfiguration键,新版本(Boot 2.7 开始)则优先读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。我建议你直接打开 Jar 包看一眼这个文件,里面动辄上百行配置类名,那种"原来如此"的感受比任何源码分析文章都强烈。
2.2 自动配置不是全量生效,而是"条件装配"
自动配置类这么多,不可能全部生效。SpringBoot 对每个配置类都使用了@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty等条件注解做了一次过滤。这就是为什么你引入spring-boot-starter-web之后,Tomcat 和 DispatcherServlet 会自动出现,而不引入则什么都没有。
举一个最直观的例子:DataSourceAutoConfiguration只有在 classpath 中存在javax.sql.DataSource时才会参与评估,且如果用户已经手动定义了DataSource的@Bean,@ConditionalOnMissingBean会让整个自动配置类直接退出。这种设计既保证了开箱即用,又给开发者留出了覆盖能力。
这里我踩过一个很典型的坑:在某个改造项目中,自己写了一个@Configuration类去装配RestTemplate,但原来的自动配置已经在容器里注册了一个,导致项目里出现两个RestTemplate实例。排查半天才发现问题是条件顺序和包扫描范围打架。所以我的习惯是:凡是手动覆盖自动配置的场景,一律留意控制台里的Negative matches报告。
2.3 通过 exclude 和扫描路径控制装配行为
如果某个自动配置你确实不需要,最快的方式是在注解上排除:
@SpringBootApplication( scanBasePackages = "com.example.base", exclude = {DataSourceAutoConfiguration.class} ) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }scanBasePackages用来解决启动类不在根包下导致的扫描不到问题;exclude用来关闭不想要的自动配置。后者在初始化数据源、消息队列时不依赖自动装配的场景特别实用。
注意:排除的不是 Bean,而是自动配置类。一个自动配置类可能注册多个 Bean,移除它之后所有相关 Bean 都不会注册。如果你只是想让某个 Bean 不生效,正确的做法是用自己的
@Bean覆盖,而不是exclude。
我在实际排查中,习惯在启动时打开配置报告来确认到底哪些自动配置生效了。做法是在application.properties里加一行:
debug=true这样控制台会输出CONDITIONS EVALUATION REPORT,分为Positive matches和Negative matches两大部分。看这份报告比猜@Conditional的条件快得多。
3. SpringApplication.run()执行链:初始化器、监听器与环境准备藏在哪
3.1 静态 run 方法背后的"双阶段"设计
我们平时写的SpringApplication.run(Application.class, args)是静态方法,但它的内部其实是两段式调用:先new SpringApplication(primarySources)构造一个应用实例,再调用实例的run(args)。这个设计容易被忽略,但构造阶段恰恰埋藏了很多关键信息。
在构造SpringApplication实例时,SpringBoot 会完成几件影响全局的事:
- 推断 Web 应用类型:检查 classpath 中是否存在
org.springframework.web.servlet.DispatcherServlet、org.springframework.web.reactive.DispatcherHandler等类,决定走SERVLET、REACTIVE还是NONE。 - 加载
ApplicationContextInitializer:从spring.factories中拿到所有初始化器,后面在容器准备阶段逐个执行。 - 加载
ApplicationListener:把事件监听器全部收集起来,后续启动过程中的每个阶段都会发布对应事件。 - 推断主启动类:通过堆栈信息反查包含
main方法的类,作为组件扫描的默认基准包。
很多人在run方法里加日志或修改代码,却忽略构造阶段做的类型推断。判断 Web 类型这一行逻辑,直接决定了后面创建AnnotationConfigServletWebServerApplicationContext还是AnnotationConfigReactiveWebServerApplicationContext,两者完全不是一个容器。
3.2 环境准备:配置数据是如何一步步流进来的
实例的run(args)方法开始执行后,第一步是通过SpringApplicationRunListeners广播启动事件,紧接着就进入了环境准备阶段。这一阶段核心任务是把application.properties、application.yml、命令行参数、环境变量等所有外部配置,整合成一个ConfigurableEnvironment放进上下文。
这里有一个关键角色ConfigDataEnvironmentPostProcessor。它是 SpringBoot 2.4 之后配置加载重构的产物,统一负责读取配置数据。在它内部有一套复杂的"配置数据解析"逻辑:先定位可用的配置资源(比如文件系统的配置文件、classpath 下的配置文件),再按profile分组,最后按优先级合并成属性源。
优先级顺序大致是(从高到低):
- 命令行参数
- Java 系统属性
- 操作系统环境变量
- profile 特定的配置文件
- 不带 profile 的应用配置文件
- 默认属性
我之前遇到过一个诡异的问题:生产环境怎么配端口都无效,接口还是从 8080 起来。后来扒开环境变量列表才发现是容器平台注入了一个SERVER_PORT环境变量,优先级高于配置文件,直接覆盖了端口设置。这类问题如果对配置优先级不敏感,很容易排查几个小时也没有头绪。
3.3 创建上下文、prepareContext 与刷新上下文
环境准备好之后,run方法根据 Web 类型创建对应的ApplicationContext。如果是 Servlet 应用,就会创建AnnotationConfigServletWebServerApplicationContext。紧接着进入prepareContext:注册启动类作为配置源,执行之前加载的ApplicationContextInitializer,发布ApplicationPreparedEvent。
最常见的误解是把"创建上下文"直接等同于"容器就绪"。实际上此时 Spring 容器内部还是空的,绝大多数 Bean 都是在后面refreshContext里被注册并实例化的。
refreshContext调用的是AbstractApplicationContext.refresh(),这是 Spring 容器的核心方法。Spring Framework 源码里它的注释是"模板方法,用于上下文刷新",内部包含了十多个步骤:
- 准备刷新
- 获取 BeanFactory 并进行标准配置
- 激活 BeanFactoryPostProcessor
- 注册 BeanPostProcessor
- 初始化消息源
- 初始化事件多播器
- 创建内嵌 Web 服务器(这一步对 Boot 来说至关重要)
- 注册特殊的单例 Bean
- 执行所有单例 Bean 的非延迟初始化
- 完成刷新并发布上下文刷新事件
前几步贝理解起来比较抽象,但你只要记住一条主线:refresh()完成时,Spring 容器里的单例 Bean 基本都创建好了,事务、AOP、事件机制也都就位了。这也是为什么很多"启动后立即调用某接口"的报错,本质上都发生在ApplicationReadyEvent发布之前而非之后。
3.4 启动事件漏斗:对着日志还原执行顺序
如果你在run方法执行期间加一个自定义ApplicationListener,就能完整观察到整个启动过程中的事件顺序。我通常会在项目里临时加这样一个类:
@Component public class StartupEventListener implements ApplicationListener<SpringApplicationEvent> { @Override public void onApplicationEvent(SpringApplicationEvent event) { System.out.println("[EVENT] " + event.getClass().getSimpleName()); } }实际控制台输出顺序是:
ApplicationStartingEventApplicationEnvironmentPreparedEventApplicationContextInitializedEventApplicationPreparedEventApplicationStartedEvent- (此时服务已就绪)
ApplicationReadyEvent
这套事件顺序是排查启动问题的"时间轴"。比如你发现ApplicationStartedEvent出现了但服务端口还没开启,说明问题出在内嵌容器启动阶段;如果ApplicationReadyEvent迟迟不来,多半是某个ApplicationRunner或CommandLineRunner阻塞了。
4. 内嵌Servlet容器的诞生:Tomcat如何与Spring容器握手
4.1 容器工厂的装配与连接器初始化
Boot 之所以能做到"去掉 web.xml、去掉外置 Tomcat、一台裸 JVM 就能跑 Web 服务",核心靠的是ServletWebServerApplicationContext与WebServerFactory这套抽象。在refresh()的某个关键步骤里,SpringBoot 会调用onRefresh(),此时首次触发createWebServer()。
我来拆一下createWebServer()内部发生的事情:先通过getWebServerFactory()获取一个ServletWebServerFactory。在 classpath 里只有 Tomcat 依赖时,容器会创建TomcatServletWebServerFactory。然后调用factory.getWebServer(...),这一步会真正实例化一个 Tomcat 对象,并完成连接器和容器的初始化。
Tomcat 的初始化过程异常庞大,但核心事件包括:建立StandardServer→ 建立StandardService→ 建立Connector(默认 HTTP/1.1,端口绑定在server.port上)→ 建立StandardContext(对应一个 Web 应用上下文)→ 注册DispatcherServlet到该上下文中。
4.2 端口、线程池与优雅关闭:配置项背后的落脚点
很多人对server.port=8080的认知停留在"配置文件里写一下"这个层面。实际上这个配置项的传递链路是:ServerProperties通过@EnableConfigurationProperties绑定到server.*配置 →TomcatServletWebServerFactory里的port字段被注入 →getWebServer()创建Connector时把这个端口设置给连接器。
对接口流量较大的项目,我建议关注以下跟容器强相关的配置:
server.port=8080 server.tomcat.threads.max=200 server.tomcat.threads.min-spare=20 server.tomcat.max-connections=10000 server.tomcat.accept-count=200 server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30smax-connections和accept-count是 TCP 层面的背压参数;threads.max是业务线程池上限;shutdown=graceful加上超时时间,能让容器关闭时先停止接收新请求,把在途请求处理完再销毁线程池。
这一组参数里,最容易忽略的是server.tomcat.threads.max与 Java 虚拟线程的取舍。SpringBoot 3.2 之后可以直接配置spring.threads.virtual.enabled=true启用虚拟线程,此时 Tomcat 默认不再阻塞平台线程,压测表现往往比调大threads.max更明显。
4.3 ServletContext 与 DispatcherServlet 的注册时机
内嵌 Tomcat 创建的StandardContext本身只代表一个空的 Web 应用壳,业务请求要能进来,必须往里面注册DispatcherServlet。这一步同样由WebServerFactory完成:通过注册一个ServletContextInitializer,在容器启动时将 DispatcherServlet 映射到/。
所以你能看到一种很有意思的依赖关系:Tomcat 的 Web 容器并不依赖 Spring 容器先行完成,而是两个容器在onRefresh()中交替启动。Spring 容器先创建好DispatcherServlet这个 Bean,然后 Tomcat 启动时才把它注入 ServletContext;Tomcat 连接器启动监听端口后,请求才会真正触达 Spring MVC。
如果你想验证这个时序,可以在 IDE 中对ServletWebServerApplicationContext#onRefresh打一个断点,观察调用栈:此时 Spring 容器已经开始刷新,但 Tomcat 还没有await。往回退一步,能看到AbstractApplicationContext.refresh中的onRefresh调用位置;往前继续跑,端口就打开了。我建议每个想深入理解 Boot 的人都去走一遍这个断点,比读十篇博客都有用。
5. 启动故障快速定位:从失败分析器到日志时序还原
5.1 端口冲突这类故障是怎么被诊断出来的
SpringBoot 最贴心的一点,是内置了一整套FailureAnalyzer错误分析器。启动过程中抛出的异常并不直接裸奔,而是先被包装成FailureAnalysis报告,给出"发生了什么、怎么解决"的明确提示。
最典型的是端口被占用的场景。比如你启动了一个实例占着 8080,再启动第二个实例,控制台不会只输出一个BindException,而会输出类似这样的诊断:
*************************** APPLICATION FAILED TO START *************************** Description: Web server failed to start. Port 8080 was already in use. Action: Identify and stop the process that's listening on port 8080 or configure this application to listen on another port.这种提示对运维同学极其友好,少走了很多弯路。但也要注意,不是所有失败都会被精确分析到。比如 Bean 定义冲突、循环依赖,报告给出的信息往往只提示到类名层面,具体到哪条调用链还得自己拉线程堆栈。
5.2 启动慢的根因定位:先看日志,再看线程
遇到启动慢,我一般分三步排查:
- 第一步:在
application.properties里打开logging.level.org.springframework.boot.autoconfigure=DEBUG,查看每个自动配置类的评估耗时。这一步能快速揪出拖慢启动的候选 Bean。 - 第二步:多启动几次,对比
Started Application in X.XXX seconds的波动区间。如果每次差几秒,大概率是 Docker、网络、DNS 这类环境因素;如果稳定慢,则锁定在应用代码里。 - 第三步:在启动过程中对 JVM 做线程采样,使用
jstack连续抓几次,看主线程阻塞在哪个Class.forName、哪个数据库连接池初始化、哪个Bean构造函数上。
一次真实的案例是:某个服务启动固定慢 8 秒,日志卡在Initializing Servlet 'dispatcherServlet'。用jstack抓主线程,发现阻塞在SocketInputStream.socketRead0,也就是某个@PostConstruct方法里做了远程 HTTP 调用,而对方服务响应超时才导致容器迟迟拿不到就绪标记。这种问题只看日志永远定位不了。
5.3 通过条件评估报告排查"自动配置没生效"
之前提到的debug=true条件评估报告,是我排查自动配置问题时最常用的资料。报告里对每个自动配置类都给出匹配条件和匹配结果,比如:
Negative matches: --------------- DispatcherServletAutoConfiguration Did not match: - @ConditionalOnClass found required class 'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition)看到这种Negative matches时,第一反应应该是检查依赖:是不是漏了spring-boot-starter-web,或者引入了spring-boot-starter-webflux导致上下文类型切换成了响应式。@ConditionalOnClass不需要你理解太深,只要知道它判断的是 classpath 里有没有对应类即可。
经验:别在
debug=true下输出的十屏日志里从头看到尾。搜索Positive matches和Negative matches分界点,直接看自己关心那几个类的匹配结果,效率最高。
6. 用断点把启动过程变成一部可以逐帧回放的电影
6.1 三个最有价值的断点位置
源码读十遍,不如断点走一遍。我推荐三个必打的断点位置:
SpringApplication#run(String...)方法第一行:看整个启动流程的入口参数是什么,也方便查看this.listeners、this.initializers在构造阶段加载了多少个。ServletWebServerApplicationContext#onRefresh()方法内:看内嵌 Web 服务器的创建时机,以及调用栈里AbstractApplicationContext.refresh的整个流转。AbstractApplicationContext#finishRefresh()方法内:此时所有单例 Bean 已初始化完成,紧接着会发布ContextRefreshedEvent,再往后就是ApplicationRunner执行。
在每个断点停下来时,不要只看变量值,多看 IDE 里的调用栈(Call Stack)面板。调用栈会把整个启动链路按方法嵌套展开,是最标准的"启动时序还原工具"。
6.2 在应用侧打印生命周期日志
如果你的项目不方便开 IDE,也可以用临时监听器的方式输出阶段日志。我把这段代码放在启动类同包的config目录里,调试完直接删除:
@Component public class LifecycleLogger { @EventListener(ApplicationReadyEvent.class) public void onReady(ApplicationReadyEvent event) { System.out.println("容器已就绪,启动耗时: " + event.getTimeTaken().toMillis() + "ms"); } }配合 SpringBoot 自带的启动提示,你可以看到一条完整的就绪链:Tomcat started on port(s): 8080→Started Application in X.XXX seconds→容器已就绪。三行日志逐次出现,说明 Spring 容器、Web 服务器、业务就绪回调三层都正常完成了。
如果第三行迟迟不出现,问题十有八九出在CommandLineRunner或ApplicationRunner中,因为它们是在ApplicationReadyEvent之前执行的。只要在你的 runner 实现里加一个耗时统计,很快就能找到是哪一步卡住了。
6.3 个人偏好:启动阶段不要做网络 IO
最后说一个我个人的工程习惯,也是踩过几次坑换来的:不要在@PostConstruct、ApplicationRunner、SmartLifecycle.start()里做依赖外部服务的同步调用。启动阶段应当只做资源准备和内部状态初始化,外部依赖放到首次请求时建立连接,或者至少加超时和降级。否则你的应用进程是起来了,实际上却可能因为一个外部调用在ApplicationReadyEvent之前阻塞几十秒,健康检查永远不通过,这才是最难受的启动故障。
理解启动流程这件事,最直接的价值不在于面试背题,而是让你在"应用起不来、起来不正常、起来很慢"三类问题上,拥有从现象反推根因的能力。你不需要一次性记住所有内部类名,但上面这些关键节点、事件顺序、配置优先级、故障分析器,建议你在下一个项目里亲自验证一遍,它们会成为你诊治启动问题时的肌肉记忆。