Spring Boot 启动流程:SpringApplication、环境准备、上下文创建与 Runner(099)

Spring与Spring Boot约 15 分钟阅读8,392 字符
知识库文档 100 / 251

引言: 这一行代码背后发生了什么

每一个 Spring Boot 应用都从 SpringApplication.run(MyApplication.class, args) 开始。这一行代码背后,Spring Boot 完成了:推断应用类型(Servlet/Reactive/None)、创建 Environment 并加载 17 级 PropertySource、创建 ApplicationContext、执行 refresh() 刷新容器、注册并启动嵌入式 Web 服务器、执行 Runner 回调——整个过程涉及 20+ 个步骤,每一步都有明确的扩展点和可观测性。这篇文章把这条启动链路完整拆开,从 SpringApplication 构造到 ApplicationContext 刷新再到 Runner 执行,讲清楚每一步在做什么、为什么这样设计、以及哪些环节可以扩展。

一、SpringApplication 的构造:推断应用类型与初始化引导注册表

文章配图
文章配图

文章配图
文章配图

推断应用类型

new SpringApplication(MyApplication.class) 的构造函数中,Spring Boot 做的第一件事是推断应用类型。它通过检查 classpath 上是否存在特定类来判断:

java
// SpringApplication 构造函数中的逻辑(简化)
this.webApplicationType = WebApplicationType.deduceFromClasspath();
Classpath 上的类 推断结果 说明
存在 DispatcherServlet 且不存在 DispatcherHandler SERVLET 传统 Servlet Web 应用
存在 DispatcherHandler 且不存在 DispatcherServlet REACTIVE WebFlux 响应式应用
两者都不存在 NONE 非 Web 应用(如批处理)

这个推断直接影响后续两个关键决策:创建什么类型的 ApplicationContext(AnnotationConfigServletWebServerApplicationContext 还是 AnnotationConfigReactiveWebServerApplicationContext),以及是否启动嵌入式 Web 服务器。

初始化引导注册表

构造函数的第二步是加载 META-INF/spring.factories 中的 ApplicationContextInitializer 和 ApplicationListener:

java
this.initializers = getSpringFactoriesInstances(ApplicationContextInitializer.class);
this.listeners = getSpringFactoriesInstances(ApplicationListener.class);

这一步通过 SpringFactoriesLoader 从 classpath 上所有 jar 包的 spring.factories 文件中读取 SPI 扩展,实例化后存入 initializers 和 listeners 列表。这些初始化器和监听器会在后续的启动流程中被逐个调用。

推断主类

构造函数还会从调用栈中推断 main 方法所在的类(mainApplicationClass),这个类会被用作 @SpringBootApplication 注解的载体,用于后续的 @ComponentScan 范围确定。

二、SpringApplication.run() 的全流程:从启动到就绪

文章配图
文章配图

文章配图
文章配图

run() 方法内部按顺序执行以下步骤:

1. 创建 StopWatch 并启动

java
StopWatch stopWatch = new StopWatch();
stopWatch.start();

这个 StopWatch 记录了整个启动过程的耗时,最后在 ApplicationStartedEvent 日志中输出。

2. 创建 BootstrapContext

Spring Boot 2.4+ 引入的 BootstrapContext 是一个独立的引导上下文,在 ApplicationContext 创建之前存在,用于承载"引导阶段"需要的 Bean(如 BootstrapRegistryInitializer 注册的 Bean)。引导完成后,BootstrapContext 会被关闭,其中的 Bean 会被迁移到主 ApplicationContext 或直接丢弃。

3. 设置 java.awt.headless

对于非 Windows 环境,System.setProperty("java.awt.headless", "true") 确保在没有图形界面的服务器环境中,AWT 相关的类也能正常工作(比如生成验证码图片)。

4. 获取并启动 SpringApplicationRunListeners

java
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting(bootstrapContext, mainApplicationClass);

SpringApplicationRunListener 是启动流程的"生命周期钩子",通过 spring.factories 加载。默认实现是 EventPublishingRunListener,它会在每个启动阶段广播对应的 SpringApplicationEvent(如 ApplicationStartingEvent、ApplicationEnvironmentPreparedEvent 等)。

5. 准备 Environment

java
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);

这是整个启动流程中最重要的步骤之一。prepareEnvironment() 做了三件事:创建 Environment、添加命令行参数到 PropertySource、添加 ConfigurationPropertySources 作为最高优先级的 PropertySource。关于 Environment 的详细机制,参见 098 号文章。

6. 打印 Banner

java
Banner printedBanner = printBanner(environment);

默认打印 Spring Boot 的 ASCII 艺术字 Banner,可以通过 spring.banner.location 自定义,或设置 spring.main.banner-mode=off 关闭。

7. 创建 ApplicationContext

java
context = createApplicationContext();

根据第一步推断的 webApplicationType 创建对应的 ApplicationContext 实现类。对于 SERVLET 类型,创建 AnnotationConfigServletWebServerApplicationContext。

8. 准备 ApplicationContext

java
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);

这一步做了大量工作:设置 Environment、注册 ApplicationContextInitializer、调用所有 initializers、加载 sources(即 @SpringBootApplication 标注的主类)、注册 SpringApplicationArguments 和 banner 为 Bean、触发 ApplicationContextInitializedEvent。

9. 刷新 ApplicationContext

java
refreshContext(context);

这是 Spring 容器启动的核心——调用 AbstractApplicationContext.refresh() 方法。这个 12 步方法完成了 Bean 定义的加载、BeanFactoryPostProcessor 的处理、Bean 的实例化和初始化、ApplicationEventMulticaster 的创建等。refresh() 是 Spring Framework 的核心链路,不是 Spring Boot 特有的,但 Spring Boot 在 refresh() 过程中通过 ServletWebServerApplicationContext 在 onRefresh() 阶段创建了嵌入式 Web 服务器。

10. 记录启动耗时

java
stopWatch.stop();

11. 发布 ApplicationReadyEvent

java
listeners.ready(context, startupStep);

这是整个启动流程的最后一个事件,标志应用已完全就绪,可以接收外部请求。很多"启动后一次性初始化"的逻辑(如缓存预热)都监听这个事件。

12. 调用 Runners

java
callRunners(context, applicationArguments);

三、ApplicationRunner 与 CommandLineRunner:启动后的回调

文章配图
文章配图

ApplicationRunner 和 CommandLineRunner 是 Spring Boot 提供的两个启动后回调接口。它们的区别在于参数类型:

java

// CommandLineRunner:接收原始 String[ ] 参数

@Component
public class MyCommandLineRunner implements CommandLineRunner {
    @Override
    public void run(String... args) {
        // args 是原始命令行参数
    }
}

// ApplicationRunner:接收封装后的 ApplicationArguments 对象
@Component
public class MyApplicationRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        // args.getOptionNames() 获取选项名
        // args.getOptionValues("name") 获取选项值
        // args.getNonOptionArgs() 获取非选项参数
    }
}

ApplicationRunner 的 ApplicationArguments 是一个更友好的封装,它解析了命令行参数中的 --key=value 格式,提供了 getOptionNames()、getOptionValues() 等方法。CommandLineRunner 接收的 String... args 是原始的字符串数组,不做任何解析。

执行顺序

多个 Runner 之间可以通过 @Order 注解控制执行顺序:

java
@Component
@Order(1)
public class CacheWarmUpRunner implements ApplicationRunner { ... }

@Component
@Order(2)
public class DataInitRunner implements ApplicationRunner { ... }

所有 Runner 都在 ApplicationReadyEvent 发布之后、main 方法返回之前执行。如果 Runner 中抛出异常,启动会失败。

Runner 的使用场景

  • 缓存预热:启动后加载热点数据到 Redis 或本地缓存。
  • 数据初始化:检查数据库中的必需数据(如默认配置项),不存在则插入。
  • 健康检查:验证依赖的外部服务是否可达。
  • 注册服务:向注册中心(如 Nacos、Eureka)注册服务实例。

四、启动流程中的事件广播

文章配图
文章配图

Spring Boot 在启动流程中发布了一系列事件,你可以通过 ApplicationListener 或 @EventListener 在特定阶段执行自定义逻辑:

事件 触发时机 典型用途
ApplicationStartingEvent 启动刚开始,Environment 尚未创建 日志初始化、提前注册监听器
ApplicationEnvironmentPreparedEvent Environment 创建完成 根据环境变量调整配置
ApplicationContextInitializedEvent ApplicationContext 创建完成,但尚未 refresh 注册额外的 Bean 定义
ApplicationPreparedEvent refresh 之前,Bean 定义已加载 最后的机会修改 Bean 定义
ApplicationStartedEvent refresh 完成,但 Runner 尚未执行 启动成功后的日志
ApplicationReadyEvent 应用完全就绪,可以接收请求 缓存预热、服务注册
ApplicationFailedEvent 启动过程抛出异常 启动失败告警

五、自定义启动流程

文章配图
文章配图

Spring Boot 的启动流程可以通过多种方式扩展:

1. ApplicationContextInitializer

在 ApplicationContext refresh 之前执行,可以修改 Environment 或注册 BeanFactoryPostProcessor:

java
public class MyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        // 在 refresh 之前修改 Environment
        context.getEnvironment().getSystemProperties().put("my.key", "value");
    }
}

注册方式:在 META-INF/spring.factories 中声明,或通过 SpringApplication.addInitializers() 编程式添加。

2. SpringApplicationRunListener

监听启动流程的每个阶段,可以在每个阶段之间插入自定义逻辑:

java
public class MyRunListener implements SpringApplicationRunListener {

    public MyRunListener(SpringApplication application, String[ ] args) { }


    @Override
    public void starting(ConfigurableBootstrapContext bootstrapContext) { }
    @Override
    public void environmentPrepared(ConfigurableBootstrapContext bootstrapContext, 
                                     ConfigurableEnvironment environment) { }
    // ... 其他阶段
}

3. 编程式启动

java
new SpringApplicationBuilder(MyApplication.class)
    .profiles("prod")
    .initializers(new MyInitializer())
    .listeners(new MyListener())
    .run(args);

SpringApplicationBuilder 提供了流畅的 API 来构建 SpringApplication 实例,适合在启动前需要动态决策的场景。

六、常见陷阱

文章配图
文章配图

陷阱一:Runner 中抛出异常导致启动失败

Runner 的异常不会被 Spring Boot 静默处理——如果 Runner 抛出未捕获的异常,callRunners 方法会直接抛出 IllegalStateException,导致启动失败。这意味着 Runner 中如果有"可恢复"的错误(比如外部的缓存服务暂时不可达),应该用 try-catch 包裹,而不是让异常冒泡。

陷阱二:在 ApplicationStartedEvent 和 ApplicationReadyEvent 之间做选择

ApplicationStartedEvent 在 refresh() 完成后立即发布,此时嵌入式 Web 服务器可能还没有启动完毕(onRefresh() 阶段创建了 Web 服务器,但 finishRefresh() 中的 start() 还没执行)。ApplicationReadyEvent 在 Web 服务器启动完成、应用可以接收请求后才发布。如果你的逻辑需要调用自己的 HTTP 接口(比如自检),必须在 ApplicationReadyEvent 之后——在 ApplicationStartedEvent 时 Web 服务器还没开始监听端口。

陷阱三:推断主类失败

SpringApplication 通过 new RuntimeException().getStackTrace() 推断 mainApplicationClass。如果你的 main 方法不在一个标准类中(比如通过脚本引擎调用),这个推断可能失败。此时需要显式传入主类:new SpringApplication(MyConfig.class).run(args)。

陷阱四:BootstrapContext 的 Bean 迁移

BootstrapContext 中的 Bean 在引导完成后会被关闭。如果你的 Bean 在引导阶段被创建并注册到 BootstrapContext,但在主 ApplicationContext 中还需要使用,需要确保它通过 BootstrapRegistryInitializer 注册时指定了 ApplicationContext 迁移策略。否则,BootstrapContext 关闭后 Bean 会被销毁,主 ApplicationContext 中找不到它。

小结:启动流程的"可观测"与"可扩展"

Spring Boot 的启动流程设计的核心思想是"可观测"和"可扩展"——每个阶段都通过事件广播让外部代码感知,每个节点都提供了扩展点(ApplicationContextInitializer、SpringApplicationRunListener、Runner)。理解这条链路后,你不仅能回答"Spring Boot 是怎么启动的",还能在面试中说清楚"如果我想在启动时做一件事,应该挂在哪个钩子上"——这个"选对钩子"的能力,比"知道有哪些钩子"更能体现对框架的深入理解。

(以上正文阅读时长约 40-50 分钟,以下问答部分不计入正文阅读时长)

文末答疑:面试官会怎么问

问 1:**SpringApplication.run()**和**new SpringApplicationBuilder().run()**有什么区别?在什么场景下应该用后者?

答:SpringApplication.run() 是静态方法,内部就是 new SpringApplication(primarySources).run(args),适合"默认配置就够了"的场景。SpringApplicationBuilder 提供了流畅的链式 API,适合需要动态配置的场景:比如根据环境变量决定是否启用某些 profile、根据命令行参数注册不同的 ApplicationContextInitializer、或者在父子容器架构中构建层次化的 ApplicationContext。如果你需要在一个进程中启动多个 Spring Boot 应用(比如一个管理端口 + 一个业务端口),SpringApplicationBuilder 的 parent()/child() 方法是唯一的选择。

问 2:**ApplicationContextInitializer**和**BeanFactoryPostProcessor**都可以在容器 refresh 之前修改 Bean 定义,它们有什么区别?分别在什么阶段执行?

答:执行顺序是 ApplicationContextInitializer → BeanFactoryPostProcessor。ApplicationContextInitializer 在 ApplicationContext 创建后、refresh() 之前被调用,此时 BeanFactory 还没有被 refresh() 初始化,Bean 定义还没有加载,所以 ApplicationContextInitializer 只能修改 Environment 或注册 BeanFactoryPostProcessor,不能直接修改 Bean 定义。BeanFactoryPostProcessor 在 refresh() 的 invokeBeanFactoryPostProcessors 阶段被调用,此时 Bean 定义已经加载完毕,可以直接修改 Bean 定义(如修改属性值、替换类名等)。简单说:ApplicationContextInitializer 是"在 Bean 定义加载之前",BeanFactoryPostProcessor 是"在 Bean 定义加载之后"。

问 3:如果**main**方法里调用了两次**SpringApplication.run()**,第二次调用会发生什么?会有两个独立的 Spring 容器吗?

答:是的,会有两个独立的 ApplicationContext。每次 SpringApplication.run() 都会创建一个新的 SpringApplication 实例(或复用已有的实例),然后创建新的 ApplicationContext 并执行 refresh()。两个容器之间完全独立,有各自的 Bean 定义、各自的 Environment、各自的 Web 服务器。如果两次调用都创建了 Web 服务器,第二个会启动失败(端口冲突),除非配置了不同的端口。这个特性在集成测试中偶尔有用——比如需要模拟两个独立的应用在同一个 JVM 中交互。

问 4:**CommandLineRunner**和**ApplicationRunner**的异常是怎么处理的?如果 Runner 中调用了**System.exit()**会发生什么?

答:Runner 的异常会被 callRunners 方法捕获并包装成 IllegalStateException 重新抛出,最终导致 SpringApplication.run() 失败返回。如果 Runner 中调用了 System.exit(),JVM 直接退出,Spring Boot 的 ApplicationContext 没有被正常关闭——@PreDestroy 方法不会被调用,正在执行的事务可能丢失,连接池不会被关闭。这就是为什么 Runner 中不应该调用 System.exit()——如果需要异常退出,应该抛出异常让 Spring Boot 的 ExitCodeGenerator 机制处理,它会在 SpringApplication.exit() 后返回一个错误码给调用方。

问 5:Spring Boot 3.x 的 AOT 处理对启动流程有什么影响?它在哪些阶段做了优化?

答:AOT(Ahead-of-Time)处理在编译期完成了原本在运行时做的事情,主要影响三个阶段:第一,条件评估被提前到编译期——ConditionEvaluator 的逻辑在编译期执行,结果直接写入生成的代码,运行时跳过条件判断。第二,Bean 定义被提前生成——ConfigurationClassPostProcessor 的解析逻辑在编译期完成,运行时直接注册 Bean 定义而不是解析 @Configuration 类。第三,spring.factories 和 AutoConfiguration.imports 的加载被提前——编译期读取所有候选自动配置类,过滤掉条件不满足的,只保留生效的类。这些优化在 JVM 部署模式下效果有限(因为条件评估本身很快),但在 GraalVM Native Image 部署模式下,AOT 处理是必需的——Native Image 不支持运行时反射和动态类加载,条件评估必须在编译期完成。