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的BeanConditionalOnMissingBean会让整个自动配置类直接退出。这种设计既保证了开箱即用又给开发者留出了覆盖能力。这里我踩过一个很典型的坑在某个改造项目中自己写了一个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里加一行debugtrue这样控制台会输出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 ApplicationListenerSpringApplicationEvent { 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.port8080的认知停留在配置文件里写一下这个层面。实际上这个配置项的传递链路是ServerProperties通过EnableConfigurationProperties绑定到server.*配置 →TomcatServletWebServerFactory里的port字段被注入 →getWebServer()创建Connector时把这个端口设置给连接器。对接口流量较大的项目我建议关注以下跟容器强相关的配置server.port8080 server.tomcat.threads.max200 server.tomcat.threads.min-spare20 server.tomcat.max-connections10000 server.tomcat.accept-count200 server.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30smax-connections和accept-count是 TCP 层面的背压参数threads.max是业务线程池上限shutdowngraceful加上超时时间能让容器关闭时先停止接收新请求把在途请求处理完再销毁线程池。这一组参数里最容易忽略的是server.tomcat.threads.max与 Java 虚拟线程的取舍。SpringBoot 3.2 之后可以直接配置spring.threads.virtual.enabledtrue启用虚拟线程此时 Tomcat 默认不再阻塞平台线程压测表现往往比调大threads.max更明显。4.3 ServletContext 与 DispatcherServlet 的注册时机内嵌 Tomcat 创建的StandardContext本身只代表一个空的 Web 应用壳业务请求要能进来必须往里面注册DispatcherServlet。这一步同样由WebServerFactory完成通过注册一个ServletContextInitializer在容器启动时将 DispatcherServlet 映射到/。所以你能看到一种很有意思的依赖关系Tomcat 的 Web 容器并不依赖 Spring 容器先行完成而是两个容器在onRefresh()中交替启动。Spring 容器先创建好DispatcherServlet这个 Bean然后 Tomcat 启动时才把它注入 ServletContextTomcat 连接器启动监听端口后请求才会真正触达 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 thats listening on port 8080 or configure this application to listen on another port.这种提示对运维同学极其友好少走了很多弯路。但也要注意不是所有失败都会被精确分析到。比如 Bean 定义冲突、循环依赖报告给出的信息往往只提示到类名层面具体到哪条调用链还得自己拉线程堆栈。5.2 启动慢的根因定位先看日志再看线程遇到启动慢我一般分三步排查第一步在application.properties里打开logging.level.org.springframework.boot.autoconfigureDEBUG查看每个自动配置类的评估耗时。这一步能快速揪出拖慢启动的候选 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 通过条件评估报告排查自动配置没生效之前提到的debugtrue条件评估报告是我排查自动配置问题时最常用的资料。报告里对每个自动配置类都给出匹配条件和匹配结果比如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 里有没有对应类即可。经验别在debugtrue下输出的十屏日志里从头看到尾。搜索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之前阻塞几十秒健康检查永远不通过这才是最难受的启动故障。理解启动流程这件事最直接的价值不在于面试背题而是让你在应用起不来、起来不正常、起来很慢三类问题上拥有从现象反推根因的能力。你不需要一次性记住所有内部类名但上面这些关键节点、事件顺序、配置优先级、故障分析器建议你在下一个项目里亲自验证一遍它们会成为你诊治启动问题时的肌肉记忆。