news 2026/10/14 22:55:08

SpringBoot启动流程全解析:从注解到内嵌Tomcat就绪

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot启动流程全解析:从注解到内嵌Tomcat就绪

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分组,最后按优先级合并成属性源。

优先级顺序大致是(从高到低):

  1. 命令行参数
  2. Java 系统属性
  3. 操作系统环境变量
  4. profile 特定的配置文件
  5. 不带 profile 的应用配置文件
  6. 默认属性

我之前遇到过一个诡异的问题:生产环境怎么配端口都无效,接口还是从 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()); } }

实际控制台输出顺序是:

  1. ApplicationStartingEvent
  2. ApplicationEnvironmentPreparedEvent
  3. ApplicationContextInitializedEvent
  4. ApplicationPreparedEvent
  5. ApplicationStartedEvent
  6. (此时服务已就绪)
  7. 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=30s

max-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之前阻塞几十秒,健康检查永远不通过,这才是最难受的启动故障。

理解启动流程这件事,最直接的价值不在于面试背题,而是让你在"应用起不来、起来不正常、起来很慢"三类问题上,拥有从现象反推根因的能力。你不需要一次性记住所有内部类名,但上面这些关键节点、事件顺序、配置优先级、故障分析器,建议你在下一个项目里亲自验证一遍,它们会成为你诊治启动问题时的肌肉记忆。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 22:54:30

Juniper EX交换机Zabbix监控模板:SNMP OID到告警的落地实践

简介&#xff1a;为Zabbix用户提供了一套开箱即用的Juniper EX系列交换机监控模板&#xff0c;适用于需要统一管理企业网络设备的管理员。资源包共3个文件&#xff0c;以XML模板文件为主&#xff0c;辅以Markdown说明文档和License授权文件&#xff0c;压缩包整体仅10KB&#x…

作者头像 李华
网站建设 2026/10/14 22:54:20

AI采购合规实战:从需求梳理到合同条款的完整指南

1. 为什么AI采购的合规问题值得提前布局很多团队在采购AI工具时&#xff0c;第一反应是看功能、比价格、测效果&#xff0c;合规这件事往往被排在最后。等到法务部门发来风险提示&#xff0c;或者审计环节被卡住&#xff0c;才发现之前省下的那点采购预算&#xff0c;根本不够填…

作者头像 李华
网站建设 2026/10/14 22:53:11

大模型写代码如何省token又保智商?分层配置与混合调度实战

1. 选型困局&#xff1a;省钱和聪明之间的真实博弈“写代码&#xff0c;希望节省token&#xff0c;但智商要高怎么选配置”——这个问题我在过去大半年里被问过不下几十次。问的人有刚入行的新手&#xff0c;也有带团队多年的老手&#xff0c;大家的痛点出奇一致&#xff1a;预…

作者头像 李华
网站建设 2026/10/14 22:53:06

通用工作Agent崛起:模型与芯片如何降格为可替换后端

1. 从"模型即产品"到"模型即插槽"的认知转变过去两年&#xff0c;绝大多数团队在构建智能体应用时&#xff0c;默认把大模型当成整个系统的"心脏"——选定了某个模型&#xff0c;整个技术栈、提示词体系、工具调用格式、甚至业务逻辑都围绕它来设…

作者头像 李华
网站建设 2026/10/14 22:51:52

银企直联对接工商银行:商业资料包如何减少联调返工

简介&#xff1a;这份资源聚焦工商银行银企直联的接口开发与系统对接&#xff0c;面向ERP、财务及资金管理系统开发者与实施人员&#xff0c;帮助解决企业内外部系统与工行核心系统、网银或现金管理平台之间的实时结算与信息查询问题。压缩包共2000个文件&#xff0c;约28.47MB…

作者头像 李华
网站建设 2026/10/14 22:49:24

Yarn CapacityScheduler深度解析:资源调度原理与生产调优实战

1. 项目概述&#xff1a;Yarn调度器不是“排队系统”&#xff0c;而是资源博弈的裁判员 很多人第一次看到“Yarn调度器”这个词&#xff0c;下意识就理解成“让任务排个队、一个个跑”的简单逻辑——这恰恰是踩进认知陷阱的第一步。我带过不少刚从单机开发转到大数据平台的开发…

作者头像 李华