写了好几年Java,我一直觉得Spring Boot最神奇的地方,就是那一行SpringApplication.run。不知道你有没有好奇过,为什么只写一个@SpringBootApplication,再执行一个run方法,一个能处理请求的Web服务就起来了?这背后不是魔法,而是一套设计得相当精密的启动流程。搞懂它,不只是为了面试,更是在线上遇到启动慢、自动配置不生效、端口起不来这类问题时,能顺着链路快速定位的思路基础。
这篇文章我会从启动入口拆起,逐步讲到环境准备、自动配置、内嵌容器和事件机制,最后分享一些我自己排查启动故障时积累的经验。适合刚接触 Spring Boot、想从“会用”进阶到“懂原理”的同学,也适合工作一两年的后端开发来查漏补缺。
1. Spring Boot 启动的宏观链路:从 main 方法到服务就绪
1.1 启动入口的两段式构造
先说结论。Spring Boot 的启动入口是固定的三板斧,哪怕项目再复杂,最后都会收敛到那一行SpringApplication.run上。很多新手以为这行代码做了“启动一个 Tomcat”这么简单的事,实际上它内部被拆成了两段:先new SpringApplication(主类.class),再调用这个实例的run(args)。
第一段构造过程里有几件关键事值得展开讲。
public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) { this.webApplicationType = WebApplicationType.deduceFromClasspath(); this.bootstrapRegistryInitializers = new ArrayList<>( getSpringFactoriesInstances(BootstrapRegistryInitializer.class)); this.initializers = new ArrayList<>( getSpringFactoriesInstances(ApplicationContextInitializer.class)); this.listeners = new ArrayList<>( getSpringFactoriesInstances(ApplicationListener.class)); this.mainApplicationClass = deduceMainApplicationClass(); }推断 Web 应用类型。Spring Boot 启动时会扫一遍 classpath,看有没有org.springframework.web.servlet.DispatcherServlet、jakarta.servlet.Servlet、org.springframework.web.reactive.DispatcherHandler这些类,从而判断当前应用是传统的 Servlet Web 应用,还是响应式的 Reactive Web 应用,或者根本就是个非 Web 应用。这一步直接决定了后面创建哪种 ApplicationContext。
加载三组 SPI。从约定文件里把BootstrapRegistryInitializer、ApplicationContextInitializer、ApplicationListener的实现类全部实例化出来。注意这里用的是getSpringFactoriesInstances,本质就是读取 classpath 下所有 jar 里的约定配置,再做去重和实例化。这就是为什么你引入某个 Starter 后,它的自动配置、监听器天然会被感知到,不需要写一行注册代码。
推断主启动类。通过当前线程的堆栈,找到包含 main 方法的那个类。网上很多教程会说“主类必须包在根包下”,其实 Spring Boot 为了兼容非标准布局,提供了一套兜底手段:直接扫栈。如果你自己写的工具里也调用SpringApplication.run,要小心主类推断可能不准确,这算是比较少人注意的坑。
1.2 run() 方法的核心执行节奏
Spring Boot 2.x 的 run 方法核心流程我做了精简,把骨架留出来。
public ConfigurableApplicationContext run(String... args) { StopWatch stopWatch = new StopWatch(); stopWatch.start(); DefaultBootstrapContext bootstrapContext = createBootstrapContext(); SpringApplicationRunListeners listeners = getRunListeners(args); listeners.starting(bootstrapContext, this.mainApplicationClass); ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, args); printBanner(environment); ConfigurableApplicationContext context = createApplicationContext(environment); // ...异常报告器、上下文加工、刷新、启动后处理省略 refreshContext(context); afterRefresh(context, applicationArguments); stopWatch.stop(); listeners.started(context, timeTakenToStartup); callRunners(context, applicationArguments); listeners.ready(context, timeTakenToReady); return context; }我把这段流程按顺序和阶段总结成一张表,方便你对照。
| 阶段 | 关键动作 | 对应事件 |
|---|---|---|
| 启动计时 | 创建 StopWatch,记录启动总耗时 | 无 |
| 监听器开始 | 通知所有运行监听器,应用开始启动 | ApplicationStartingEvent |
| 环境准备 | 加载默认属性、命令行参数、多 profile 配置 | ApplicationEnvironmentPreparedEvent |
| Banner 打印 | 输出启动 Banner,可自定义/关闭 | 无 |
| 上下文创建 | 按应用类型创建 AnnotationConfigServletWebServerApplicationContext 等 | ApplicationContextInitializedEvent |
| 上下文刷新 | 执行 Bean 定义解析、注册、实例化,Tomcat 在此阶段启动 | ApplicationPreparedEvent、Spring 容器事件 |
| 启动完成 | 启动计时结束,触发启动完成事件 | ApplicationStartedEvent |
| Runner 执行 | 执行 ApplicationRunner / CommandLineRunner | 无 |
| 就绪通知 | 应用完全就绪,可以接收流量 | ApplicationReadyEvent |
注意一个容易误解的地方:在 Spring Boot 的启动日志里,Started Application in X seconds出现的位置对应 ApplicationStartedEvent,但在它之前其实内嵌 Tomcat 已经启动并占用了端口。真正能对外提供服务,是等 ApplicationReadyEvent 发出之后。中间差的那点时间,通常就是各种 Runner 和@PostConstruct的预热逻辑。
1.3 三种 ApplicationContext 的创建路径
Spring Boot 不像传统 Spring 项目那样要你手动选ClassPathXmlApplicationContext还是AnnotationConfigApplicationContext,它根据第一步推断出的 Web 应用类型自动创建。
- SERVLET 类型:创建
AnnotationConfigServletWebServerApplicationContext。它会注册默认的DispatcherServlet、过滤器链、内嵌容器管理等相关 Bean。 - REACTIVE 类型:创建
AnnotationConfigReactiveWebServerApplicationContext。走 WebFlux 那一套,内嵌容器可能是 Netty。 - NONE 类型:创建
AnnotationConfigApplicationContext。比如命令行跑批任务、定时任务的应用,就不会启动任何 Web 容器。
对大部分业务系统来说,你永远只会用到第一种。但这个推断机制告诉我们一件事:如果你不小心引入了一个 Web 相关依赖,而你的应用本来不需要 Web 容器,启动时可能会出现Web server failed to start这类莫名其妙的错。排查的思路不是去怀疑配置写错了,而是先把应用类型拉回预期。
2. 自动配置是怎么“自动”起来的:SPI 与条件装配
2.1 @SpringBootApplication 拆开看
启动原理很多人讲不清楚,是因为把两个概念混在了一起:Spring 容器的启动,和 Spring Boot 的自动配置。前者是“把 Bean 装进来”,后者是“决定装哪些 Bean”。拆开看就清晰了。
@SpringBootApplication是一个合成注解,它等价于下面三条:
@SpringBootConfiguration:本质是@Configuration,标记这是一个配置类。@EnableAutoConfiguration:开启自动配置机制,这是整个 Spring Boot 的灵魂。@ComponentScan:扫描当前包及其子包下的@Component、@Service、@Repository、@Controller等组件。
这里有个经典坑:如果把@ComponentScan的 basePackage 改成了其他包,而且又没包含主类所在的包,那么很多本来应该被扫描到的业务 Bean 会静默消失。所以大型项目用@SpringBootApplication时,要么别乱改扫描路径,要么把扫描范围显式放到足够大的公共包上。
@EnableAutoConfiguration内部通过@Import(AutoConfigurationImportSelector.class)引入一个导入选择器,这个类会在解析配置阶段被触发,返回一组自动配置的类全限定名。接下来要说的加载机制,就集中在这个导入选择器上。
2.2 自动配置候选的加载来源
手动深入AutoConfigurationImportSelector的时候,你会看到这些关键方法:
getCandidateConfigurations():拿到所有自动配置候选类名。getExclusionAutoConfigurations():处理spring.autoconfigure.exclude和@EnableAutoConfiguration(exclude=...)的排除清单。filter():用一组条件过滤器,把不满足条件的配置类从候选列表中剔除。removeDuplicates():去重。sort():按照自动配置类上的@AutoConfigureOrder、@AutoConfigureBefore、@AutoConfigureAfter排序。
候选类名从哪里来?Spring Boot 2.7 之前是META-INF/spring.factories文件里的org.springframework.boot.autoconfigure.EnableAutoConfiguration配置项;2.7 开始引入新的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports;3.x 起彻底抛弃 spring.factories 这条通道。
# spring.factories 形式(2.7 以前) org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.example.demo.config.MyAutoConfiguration # AutoConfiguration.imports 形式(2.7+) com.example.demo.config.MyAutoConfiguration你引入一个第三方 Starter 时,它 jar 包里如果带了这个 imports 文件,启动时就会被 Spring Boot 读到。这也是为什么你只需要引入依赖,不用手动@Import配置类的原因。自己写公共模块想做得方便一点,也应该按这个形式提供自动配置,而不是让使用方手动 import。
2.3 条件装配的常见套路
加载进来的自动配置类不可能全都生效。比如你的项目没引入 Redis 客户端,Redis 自动配置就应该自动放弃。Spring Boot 用一堆@Conditional注解来完成这种判断,常见的有:
@ConditionalOnClass:classpath 中存在某个类才生效。@ConditionalOnMissingBean:容器中没有某个 Bean 才生效。@ConditionalOnBean:容器中存在某个 Bean 才生效。@ConditionalOnProperty:配置项满足匹配值才生效。@ConditionalOnWebApplication:应用是 Web 应用才生效。
拿RedisAutoConfiguration举例,它的类头大致长这样:
@AutoConfiguration @ConditionalOnClass(RedisOperations.class) @EnableConfigurationProperties(RedisProperties.class) @Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class }) public class RedisAutoConfiguration { @Bean @ConditionalOnMissingBean(name = "redisTemplate") public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) throws UnknownHostException { // ... } }第一层@ConditionalOnClass(RedisOperations.class)决定了:只要 classpath 里没有 Redis 驱动,整个配置类直接跳过。第二层对 redisTemplate 这个 Bean 的判断:如果业务代码里自己定义了一个名为redisTemplate的 Bean,自动配置的默认模板就不再注册,这遵循“用户可以覆盖默认实现”的设计思想。
这里有个非常容易踩的坑:@ConditionalOnMissingBean对 Bean 的判断发生在注册阶段,而不是解析阶段。如果你的业务 Bean 是靠@ComponentScan扫描进来的,Spring Boot 会先处理自动配置类,再扫描主程序包时,@ConditionalOnMissingBean有可能误判。要规避它,通常是调整自动配置类与扫描的先后顺序,或者不用缺失判断,专门写一个@ConditionalOnProperty来决定是否启用。
2.4 用 --debug 拿到专属自动配置报告
排查“某个自动配置为什么没生效”的最快方法,是启动时加参数,或者在application.properties里写debug=true。
启动之后,日志里会出现两段关键输出:
Positive matches:命中的自动配置类,后面会列出命中的条件注解。Negative matches:未命中的自动配置类,并且会说明是哪个条件不满足。
比如说你怀疑某个@ConfigurationProperties没生效,看 Negative matches 里会直接提示@ConditionalOnProperty的哪一项没匹配上。有些项目日志出于安全考虑关闭了启动信息,那就在本地启动时专门开一次--debug,看完再关。这个能力在生产排查同类问题时也很有价值,但要注意日志量大,不要在高峰时段随便开。
3. Web 环境的启动细节:内嵌容器与监听器机制
3.1 内嵌 Tomcat 的启动链路
Spring Boot 默认的 Web 容器是 Tomcat,但你没有手动写过 Tomcat 配置,容器是怎么起来的?要追溯到ServletWebServerFactoryAutoConfiguration这个自动配置类。
- 解析
ServletWebServerFactoryAutoConfiguration时,发现 classpath 里有Tomcat.class,于是往容器里注册TomcatServletWebServerFactory。 - 刷新上下文阶段,
ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar会注册一堆后置处理器,其中WebServerFactoryCustomizerBeanPostProcessor会把你在配置类里定义的WebServerFactoryCustomizer应用到工厂上,端口、压缩、超时等配置都在这时被改写。 WebServerStartStopLifecycle或者ServletWebServerApplicationContext.onStartup阶段会调用factory.getWebServer(),这个方法内部创建真实的 Tomcat 对象,设置 Connector,准备 Context,最后调用tomcat.start()绑定端口。
整个过程最容易被忽略的是:Tomcat 在这里实际上是一段“容器生命周期”管理,而不是单纯的 Socket 服务启动。Spring MVC 的DispatcherServlet是通过DispatcherServletRegistrationBean注册进 Tomcat 的 Servlet 上下文里的。把这两件事分开理解后,遇到“端口起不来”和“请求 404”两类问题时,你就能快速定位到底是容器层的问题还是 Servlet 层的问题。
3.2 事件广播与监听器:启动过程的“导航信号”
Spring Boot 启动全程会发出多个事件,这些事件统称SpringApplicationEvent。它们走的是SpringApplicationRunListeners,而不是 Spring 容器的事件广播器。区别在于前者发生在容器刷新之前、之中、之后的各种阶段,后者至少在上下文刷新完成后才会完整介入。
我整理了一份事件速查表,排查问题时可以直接对照。
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
| ApplicationStartingEvent | 启动开始,环境未构建 | 记录启动日志、设置载入参数 |
| ApplicationEnvironmentPreparedEvent | 环境已准备,容器未创建 | 根据环境调整日志级别、过滤配置项 |
| ApplicationContextInitializedEvent | 上下文已创建,Bean 未开始加载 | 扩展点注册 |
| ApplicationPreparedEvent | 准备刷新上下文 | 预热数据源、预创建连接池 |
| ApplicationStartedEvent | 上下文已刷新,Runner 未执行 | 上报启动状态、执行轻量校验 |
| ApplicationReadyEvent | 全部就绪 | 发心跳、预热缓存、开任务 |
| ApplicationFailedEvent | 启动失败 | 保存故障现场、推送告警 |
举个例子,我之前遇到一个场景:某个服务要求上线后立刻把注册信息报到配置中心,但启动日志显示服务正常,配置中心里却迟迟没有数据。后来发现数据上报逻辑写在ApplicationStartedEvent监听里,而这个时候 Runner 还没跑完,注册中心客户端连接的超时时间偏大,导致上报丢失。调整到ApplicationReadyEvent之后,问题立刻消失。这就是事件时序对业务代码的直接影响了。
3.3 配置加载的顺序与优先级
环境准备阶段还有个容易被小看的能力:属性来源的合并。Spring Boot 的ConfigurableEnvironment会维护一个属性源列表,后加入的覆盖先加入的。默认优先级从高到低大致是这个顺序:
- 命令行参数:
--server.port=8090 - Java 系统属性:
-Dserver.port=8090 - 操作系统环境变量:
SERVER_PORT=8090 application-{profile}.yml(如 application-prod.yml)application.yml/ application.properties- 默认属性(代码里通过
setDefaultProperties设置的)
所以“application.yml 里明明写了端口 8080,启动后却是 8090”这种事,大概率不是文件写错了,而是命令行参数或者环境变量的优先级把配置覆盖了。排查这类问题的方法是写一个临时Environment打印所有属性源,或者启动日志里临时打开debug,看看EnvironmentPrepared日志输出了哪些属性源。定位后就能判断谁在“抢”配置。
另外,通过spring.config.additional-location可以追加外部配置文件目录,spring.config.import可以在 2.4 之后的版本里引入其他配置中心来源。组合使用时要谨慎,因为外部化配置一旦多起来,优先级冲突会直接演变成生产事故。
3.4 应用事件与 Runner 的执行时机
很多团队在启动流程里会写预加载逻辑,比如预热缓存、预加载字典、初始化线程池,常见的写法有两种:实现ApplicationRunner,或者实现CommandLineRunner。区别在于ApplicationRunner拿到的是封装好的ApplicationArguments,而CommandLineRunner拿到的是原始字符串数组,其余没有本质区别。
Spring Boot 执行顺序是:先执行所有ApplicationRunner和CommandLineRunner,之后再发ApplicationReadyEvent。也就是说,如果你在 Runner 里做了耗时操作,服务虽然已经能“listen”端口,但访问时会有一段空窗期,直到 Ready 事件发出后才是真正完全可用。对需要精细化发布控制的场景,建议在 Ready 事件之后再开始对外提供流量,可以用 readiness 探针配合这一事件。
Runner 之间如果有多套执行顺序需求,可以用@Order注解控制,数字越小越先执行。这里有个小建议:不要在 Runner 里做长时间阻塞任务,也尽量不要在 Runner 里做重试逻辑,把启动阶段的职责收敛到“校验、预热、上报”三件事上,其他事情交给后台异步任务,否则启动时长会被拖得很离谱。
4. 启动故障排查:最常踩的坑与排查套路
4.1 启动慢的定位方法
Spring Boot 项目启动慢了,先要分清是容器阶段慢,还是业务初始化慢。我在实际排查中一般按三步走:
第一步,看Started Application in X seconds这行日志,X 数值在 10 秒以内算正常,超过就值得查。第二步,确认--debug开启了没有,看哪一段日志出现明显停顿;如果日志没打点,就用jstack直接抓线程堆栈,看主线程卡在哪个方法上。第三步,重点怀疑几个常见慢点:
- 数据库连接池初始化慢:比如连接池创建时就去 ping 数据库,数据库网络异常导致超时。
- Redis、消息队列等中间件连接慢:连接超时配置过长。
- DNS 解析慢:尤其在某些内部环境,反解主机名可能卡住几秒。
- 安全框架初始化慢:大量权限声明的加载与注册。
- 字节码增强:CGLIB 代理大量类时,启动阶段会明显变慢。
有一个我踩过的坑:某个服务启动后要等 30 秒才监听端口,日志里一切正常。排查到最后发现是自定义的公司内部框架在prepareEnvironment阶段做了外网配置中心的长连接重试,超时设了 5 次,每次 6 秒。像这种发生在容器阶段之前的逻辑,--debug看不到,只能靠jstack或者打印阶段时间戳来定位。
4.2 自动配置不生效的排查
自动配置不生效,先不要改代码,按下面这个顺序排查。
- 确认依赖确实进到了 classpath。用
mvn dependency:tree或者查看最终打包的 jar 里有没有对应类,最简单的方法是看MANIFEST和BOOT-INF/lib目录。 - 确认自动配置没有被排除。检查
spring.autoconfigure.exclude配置,@SpringBootApplication(exclude = ...)属性,以及是否有AutoConfigurationImportFilter实现类在过滤。 - 用
--debug看 Negative matches,里面会直接列出条件不满足的原因。 - 如果是自研模块,检查
AutoConfiguration.imports文件是否放在META-INF/spring/目录下,且编码是 UTF-8。旧版本项目还可能沿用spring.factories,但 Spring Boot 3.x 不再兼容。
我曾经遇到一个诡异情况:自动配置类的@ConditionalOnClass明明匹配,但配置类就是没加载。后来发现是同一个 class 名在两个 jar 里都有,classpath 顺序导致加载了旧版本,而旧版本里的注解元数据不一致。解决办法是统一依赖版本,排除旧 jar。排查这种问题的核心思路就一句话:先确认候选类被加载了,再确认条件通过了,最后确认 Bean 注册成功了,三步分别打点。
4.3 端口冲突与其他启动失败场景
端口冲突是启动失败里高频的一种,报错一般是:
Web server failed to start. Port 8080 was already in use.处理方式:
- 本地开发:用系统命令找到占用 8080 端口的进程,杀掉后重试。
- 线上部署:不建议直接改端口,建议检查是否同一台机器上重复部署了服务,或确认注册中心里端口分配是否正确。
- 如果只是临时需要换端口,命令行
--server.port=0可以让系统随机选一个可用端口,适合本地联调。
另一类常见失败是BeanCreationException导致启动直接终止。看到这个异常不要直接翻最下面,往上看 Caused by,大部分真正的根因都在后面几行。比如连接池初始化失败、某个 Bean 构造方法里远程调用超时等。值得注意的是,Spring Boot 2.6 起默认禁止循环依赖,如果你在升级版本后突然出现The dependencies of some of the beans in the application context form a cycle的报错,而代码逻辑一直正常,多半是项目的循环依赖被新版本默认策略挡住了。临时方案是设置spring.main.allow-circular-references=true,但不建议长期开,根子上应该打散 Bean 依赖图。
4.4 版本升级时常见的启动问题
Spring Boot 版本升级也是启动问题的高发期,特别是大版本,比如 2.x 升 3.x。这些是我实际遇到过的类型:
- JDK 版本不满足:Spring Boot 3.x 要求 JDK 17 及以上,直接编译失败或启动报
UnsupportedClassVersionError。 - javax 到 jakarta:3.x 里 Servlet、JPA 等包的命名空间从
javax.*改成了jakarta.*。没改的代码在启动阶段就会报 ClassNotFound,典型的是javax.servlet相关依赖。 - 自动配置加载方式变化:3.x 不再通过
spring.factories读取 EnableAutoConfiguration,旧的 starter 需要同步更新。 - 配置属性变更:比如 Redis 相关配置从
spring.redis.*迁移到spring.data.redis.*,还按照老配置写,启动不报错,但配置会静默失效。 - 默认行为变化:除了循环依赖默认禁止,还有
spring.main.allow-bean-definition-overriding的默认值也在不同版本有差异,导致覆盖注册的 Bean 在升级后直接失败。
升级之前,建议先看官方迁移文档,并在本地把debug=true打开跑一遍完整启动,同时对比两版的自动配置报告,手工核对哪些配置项被命中、哪些被跳过。这个动作看起来繁琐,但比上线后半夜排查强得多。
最后再分享一个我个人的习惯:每次接手一个陌生的 Spring Boot 项目,我都会先加--debug跑一次启动,把 Positive matches 和 Negative matches 存档留存。后面对比版本升级、排查“突然不生效”的问题时,这份报告就是最可靠的地图。启动原理这个东西,其实不是让人背源码,而是为了在出问题的时候,能顺着那条链路快速找到怀疑点。你能从main方法一路说清楚环境准备、自动配置、容器启动、事件通知这几段,就已经足够应付绝大多数日常疑难杂症了。