Spring Boot 4.1.1 深度源码解析-第 05 章 · `ApplicationProperties` 与 `spring.main.*`:启动配置

17 阅读23分钟

Spring Boot 4.1.1 深度源码解析-第 05 章 · ApplicationProperties 与 spring.main.*:启动配置的统一载体

本章代码基准:spring-boot-4.1.1(Spring Boot 4.1.1 / Spring Framework 7.0.9) 主要源文件:

  • core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java
  • core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java
  • core/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json
  • core/spring-boot/src/main/java/org/springframework/boot/cloud/CloudPlatform.java

本章导言:为什么一个"只有 9 个字段的类"值得写 9 万字

如果只看类的规模,ApplicationProperties 是 Spring Boot 源码里最不起眼的一批类之一:去掉注释和 import,有效代码不到 60 行,9 个字段、9 组 getter/setter、一个内部静态 RuntimeHintsRegistrar。任何一个熟练的 Java 程序员花三分钟就能读完。

但这个类在 Spring Boot 4.1.1 的启动流程中处在一个极其特殊的位置:

  1. 它是唯一一个"在 ApplicationContext 存在之前就被绑定"的配置类——所有其他 @ConfigurationProperties 都是在 refresh() 期间由 ConfigurationPropertiesBindingPostProcessor 绑定的,而 ApplicationProperties 是在 prepareEnvironment() 里、连 ApplicationContext 都还没创建的时候绑定的。
  2. 它的值决定了后面所有事情的形状——web-application-type 决定创建哪种 Environment、创建哪种 ApplicationContext;lazy-initialization 决定给容器挂一个什么样的 BeanFactoryPostProcessor;register-shutdown-hook 决定 JVM 退出时的行为;sources 决定了 IoC 容器要加载哪些 Bean 定义。
  3. 它参与了一个经典的"鸡生蛋"问题——要绑定 spring.main.* 必须先把 Environment 造出来,而造 Environment 又需要知道 spring.main.web-application-type。Spring Boot 对这个矛盾的处理方式非常值得学习。
  4. 它是 Spring Boot 4.x 相对 3.x 的一次结构性重构的产物——在 3.x 中这些属性是 SpringApplication 自己的散字段(private field + setter),4.x 把它们抽成了一个独立的包级私有类。这次重构的驱动力是 AOT / GraalVM Native,而非代码整洁。

本章将围绕这四条线索展开。我们会:

  • 逐行解剖 ApplicationProperties 类本身,解释每一个字段、每一个访问器、每一个为 AOT 存在的边角
  • 追着 Binder.get(environment).bind("spring.main", Bindable.ofInstance(this.properties)) 这一行代码,讲清楚"绑定到已有实例"与"绑定到新实例"的语义差异,以及为什么这里必须用前者
  • 把 9 个属性逐个拆开:默认值、生效时机、生效代码路径、真实影响边界、容易踩的坑
  • 分析 spring.main.environment-prefix 这条"被明确禁止通过配置文件设置"的属性,以及它背后隐藏的引导顺序约束
  • 分析 spring.main.cloud-platform:一个在元数据里写着 spring.main.*、却根本不在 ApplicationProperties 类里的"幽灵属性"
  • 给出完整的时序图、验证清单与反直觉结论

本章不谈 Environment 的构建细节(那是第 06 章的主题),也不谈 Binder 的内部实现(第 11 章)。本章只关注一件事:spring.main.* 这组属性是怎么从一行配置变成影响整个启动过程的行为的。


第一部分 · ApplicationProperties 的诞生:一次被 AOT 驱动的重构

1.1 先看清当前形态

// core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java
package org.springframework.boot;

import java.util.LinkedHashSet;
import java.util.Set;

import org.jspecify.annotations.Nullable;

import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.boot.Banner.Mode;
import org.springframework.boot.context.properties.bind.BindableRuntimeHintsRegistrar;
import org.springframework.boot.logging.LoggingSystemProperty;
import org.springframework.core.env.Environment;
import org.springframework.util.Assert;

/**
 * Spring application properties.
 *
 * @author Moritz Halbritter
 */
class ApplicationProperties {
    // ...
}

几个第一眼就能读出来的信息:

观察点结论
class ApplicationProperties(无 public)包级私有。外部代码无法引用它,只能通过设置 spring.main.* 属性来间接影响
无 @ConfigurationProperties 注解它不走 ConfigurationPropertiesBindingPostProcessor,而是被 SpringApplication 手工绑定
@author Moritz Halbritter单一作者,说明这是一次"新建类"而非"逐步演化"的产物
无 @since 标签佐证了"内部实现类"的定位(@since 通常只给公开 API 写)
使用 org.jspecify.annotations.NullableSpring 7 全面引入 JSpecify 空值注解,这是 4.x 代码风格的标志(第 01 章讲过)
内部有 RuntimeHintsRegistrar为 GraalVM Native 服务,这是它存在的重要原因

为什么"包级私有"很重要?

3.x 及更早版本,这些属性的宿主是 SpringApplication 本身(公开 API,大量 public setter)。这些 setter 有两个副作用:

  1. 它们是 API 承诺 —— 一旦公开就必须永久维护签名,行为变更要走弃用周期。
  2. 它们可被任意反射调用 —— 对 AOT 分析来说,public setter 意味着"外部代码可能在任何时候调用它",静态分析不得不保守处理。

抽到包级私有的 ApplicationProperties 之后,SpringApplication 上的 public setter 依然保留(兼容性),但变成委托方法:

// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:1001
public void setLazyInitialization(boolean lazyInitialization) {
    this.properties.setLazyInitialization(lazyInitialization);
}

真正的状态存在封闭类里,公开 API 只是一层薄转发。这是教科书级的"以委托收敛状态"重构。

1.2 真正的驱动力:AOT 与 BindableRuntimeHintsRegistrar

看这个类的最后一段:

// core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java
static class ApplicationPropertiesRuntimeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
        BindableRuntimeHintsRegistrar.forTypes(ApplicationProperties.class).registerHints(hints, classLoader);
    }

}

要理解它,先记住 GraalVM Native 的基本约束:

native image 在构建期做静态可达性分析。任何通过反射访问的成员(字段、方法、构造器)必须在构建期显式登记,否则运行期失败。

而配置绑定(Binder)天然是反射密集的:JavaBeanBinder 通过反射拿 setter、做类型转换、解析集合泛型;构造器绑定还要反射拿参数名。所以 native 场景下必须先登记 ApplicationProperties 自身、Banner.Mode、WebApplicationType 的反射。BindableRuntimeHintsRegistrar.forTypes(...) 就是递归扫描类型并登记的类(详见 5.3 节)。

这就是必须把属性集中到一个独立类的原因。

如果这 9 个属性仍散落在 SpringApplication 上,要为它们登记 hints 就得对整个 SpringApplication 做反射登记——而它有 1700+ 行、几十个方法,字段涉及 ResourceLoader、ClassLoader、ApplicationContext 等"native 下不该被反射触及"的类型。为一个 9 字段的配置载体暴露整个 SpringApplication 的反射面,代价太大。

抽出来之后,ApplicationProperties 是纯粹的数据载体(只额外依赖两个枚举),hints 可以精确、保守地登记。

反直觉结论 #1:ApplicationProperties 的存在,"代码整洁"只是附带收益,真正的驱动力是 GraalVM Native 的反射登记粒度。它是一个为了控制 AOT 反射面而生的类。

1.3 为什么它没有 @ConfigurationProperties

这是最容易产生误解的地方。很多人看到 spring.main.* 会以为 ApplicationProperties 上有 @ConfigurationProperties(prefix = "spring.main")。没有。

原因有三:

(1)时序不允许

@ConfigurationProperties 的绑定发生在 ApplicationContext 刷新阶段(第 8 步),由 ConfigurationPropertiesBindingPostProcessor 触发;而 spring.main.* 必须在第 5 步就可用——那时 ApplicationContext 还不存在。

SpringApplication.run()
  ├─ 2. configureEnvironment(...)               ← Environment 成形
  ├─ 4. listeners.environmentPrepared(...)      ← ConfigData 加载到这里
  ├─ 5. bindToSpringApplication(environment)    ← ★ spring.main.* 在这里绑定
  ├─ 6. createApplicationContext()              ← 用到 spring.main.web-application-type
  ├─ 7. prepareContext(...)
  └─ 8. refreshContext(context)                 ← @ConfigurationProperties 在这里才绑定

差了整整三个大阶段。

(2)它绑定到"已存在的实例",不是"新建实例"

@ConfigurationProperties 的语义是"容器创建新实例 → 填属性 → 注册成 Bean"。而这里需要的是"把已存在的 ApplicationProperties 实例(SpringApplication 的 final 字段)填进去"。Binder 对两种需求提供不同 API:

  • bindOrCreate(name, Class<T>) → 新建实例
  • bind(name, Bindable.ofInstance(existingInstance)) → 填充已有实例

(3)它不需要成为 Bean

ApplicationProperties 只服务于启动过程,启动完成后就没价值了。把它变成容器里的 Bean 只会污染 BeanFactory,并在条件评估时造成干扰。

1.4 final 字段与"绑定后不再变"

// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:247
final ApplicationProperties properties = new ApplicationProperties();

注意是 final,且在字段声明处直接 new。这意味着:

  1. SpringApplication 对象一构造完,properties 就存在了(默认值生效)
  2. 之后无论怎么调用 setter 或绑定,都是修改这同一个对象的内部状态,不会换引用
  3. ApplicationProperties 没有 equals/hashCode/toString,说明它从不被用于比较或日志输出

这个设计配合 prepareEnvironment() 中的绑定时机,产生了一个重要性质:

在 run() 被调用之前,SpringApplication 的 setter 生效;在 run() 被调用之后(具体是 prepareEnvironment() 之后),spring.main.* 属性的值会覆盖 setter 的值。

注意是"覆盖"而非"忽略 setter"。看 bindToSpringApplication:

// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:550
protected void bindToSpringApplication(ConfigurableEnvironment environment) {
    try {
        Binder.get(environment).bind("spring.main", Bindable.ofInstance(this.properties));
    }
    catch (Exception ex) {
        throw new IllegalStateException("Cannot bind to SpringApplication", ex);
    }
}

Binder.bind(name, Bindable) 对"已有实例"的语义是只覆盖"配置里明确出现了"的属性,其余保持原值。所以:

SpringApplication app = new SpringApplication(MyConfig.class);
app.setLazyInitialization(true);          // setter 设置 → properties.lazyInitialization = true
app.run("--spring.main.banner-mode=off"); // 只绑定 banner-mode
// 结果:lazyInitialization 仍为 true(未被覆盖),bannerMode = OFF

这是一个非常容易搞错的点。很多开发者以为"一旦用了配置文件,setter 就失效了"——不对,是逐属性覆盖。

反过来的组合:

SpringApplication app = new SpringApplication(MyConfig.class);
app.setWebApplicationType(WebApplicationType.SERVLET);
app.run("--spring.main.web-application-type=none");
// 结果:最终是 NONE,命令行参数覆盖了 setter

优先级总结:

命令行参数 `--spring.main.X`
  > 系统属性 `-Dspring.main.X`
    > 环境变量 `SPRING_MAIN_X`
      > application.properties / application.yml
        > SpringApplication.setXxx(...)
          > ApplicationProperties 字段默认值

这个优先级不是 spring.main.* 独有的,它就是 Environment 的 PropertySource 优先级(第 06 章详解)。spring.main.* 只是恰好复用了这个通用机制。


第二部分 · 类解剖:逐字段、逐方法

2.1 字段全景

#字段类型默认值对应属性名
1allowBeanDefinitionOverridingbooleanfalsespring.main.allow-bean-definition-overriding
2allowCircularReferencesbooleanfalsespring.main.allow-circular-references
3bannerModeBanner.@Nullable Modenull(延迟计算)spring.main.banner-mode
4keepAlivebooleanfalsespring.main.keep-alive
5lazyInitializationbooleanfalsespring.main.lazy-initialization
6logStartupInfobooleantruespring.main.log-startup-info
7registerShutdownHookbooleantruespring.main.register-shutdown-hook
8sourcesSet<String>new LinkedHashSet<>()spring.main.sources
9webApplicationType@Nullable WebApplicationType由构造器设为 WebApplicationType.deduce()spring.main.web-application-type

注意几个细节:

(a)只有两个字段是非默认初始值

private boolean logStartupInfo = true;
private boolean registerShutdownHook = true;
private Set<String> sources = new LinkedHashSet<>();

其余 6 个 boolean/对象字段都依赖 Java 的零值初始化。这是一个有意的、但略显危险的选择——它意味着**"默认值"这个信息在 Java 代码里是隐式的**,只有 additional-spring-configuration-metadata.json 里才显式写着 "defaultValue": false。

工程观察:这就是 Spring Boot 为什么要维护一份 JSON 元数据文件。IDE 的自动补全(application.properties 里输入 spring.main. 时弹出的提示)读的是 JSON,不是 Java 源码。Java 源码里的默认值对 IDE 是"不可见"的。

(b)sources 用 LinkedHashSet 而非 HashSet

private Set<String> sources = new LinkedHashSet<>();

Set<String> getSources() {
    return this.sources;
}

void setSources(Set<String> sources) {
    this.sources = new LinkedHashSet<>(sources);
}

LinkedHashSet 保证插入顺序。这对 sources 至关重要——sources 里的内容会按顺序被加载成 Bean 定义,加载顺序会影响 Bean 定义的注册顺序,进而影响 @ConditionalOnMissingBean 的判定结果(虽然 Boot 通常用 @Order 而非注册顺序来控制,但在边界情况下顺序确实有可观察的差异)。

setter 里 new LinkedHashSet<>(sources) 是一次防御性拷贝:即使调用方传入的是一个有奇怪行为的 Set 实现(比如不可变 Set、或者后续会被修改的 Set),ApplicationProperties 内部持有的也是自己的副本。

这是一个值得学习的细节:写 setter 时对可变集合做防御性拷贝,可以避免"外部持有引用导致内部状态被意外修改"的问题。Spring Boot 源码里这种写法很常见。

(c)bannerMode 的类型是 Banner.@Nullable Mode

这是 JSpecify 的类型使用注解(TYPE_USE annotation)语法。Banner.@Nullable Mode 的意思是"Banner.Mode 这个类型的使用是可空的",而不是"Banner 这个外层类可空"。对比:

@Nullable Banner.Mode x;        // 如果写成这样,语法上注解的是 Banner(错误)
Banner.@Nullable Mode x;        // 正确:注解的是 Mode 这个类型使用

JSpecify 的 @Nullable 是 @Target(ElementType.TYPE_USE),所以必须写在"类型"的位置上。这是 Spring Framework 7 全量迁移 JSpecify 后 4.x 代码里的标准写法(第 01 章讲过)。

2.2 唯一一个"非平凡"的 getter:getBannerMode(Environment)

这是整个类里唯一一个有逻辑的 getter,也是 4.x 的一个新行为:

// core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java
Mode getBannerMode(Environment environment) {
    if (this.bannerMode != null) {
        return this.bannerMode;
    }
    String applicationPropertyName = LoggingSystemProperty.CONSOLE_STRUCTURED_FORMAT.getApplicationPropertyName();
    Assert.state(applicationPropertyName != null, "applicationPropertyName must not be null");
    boolean structuredLoggingEnabled = environment.containsProperty(applicationPropertyName);
    return (structuredLoggingEnabled) ? Mode.OFF : Banner.Mode.CONSOLE;
}

这段代码要分三层读:

第一层:显式优先

如果用户明确设置了 spring.main.banner-mode(或调用了 setBannerMode),this.bannerMode != null,直接返回。

第二层:结构化日志检测

用户没设置时,看 LoggingSystemProperty.CONSOLE_STRUCTURED_FORMAT.getApplicationPropertyName() 对应的属性是否被设置。LoggingSystemProperty 是 4.x 日志系统的枚举(org.springframework.boot.logging 包),每个常量同时携带系统属性名与应用属性名——这个"双名"机制存在的理由是:日志初始化极早,需要在 Environment 完全就绪前就能读配置,所以它同时支持 System.getProperty(...) 与 Environment 两种来源。

第三层:Mode.OFF vs Mode.CONSOLE

return (structuredLoggingEnabled) ? Mode.OFF : Banner.Mode.CONSOLE;

如果启用了结构化日志(如 ECS、Logstash JSON、GELF),Banner 默认关闭。

这个设计的理由非常实在:结构化日志的输出目标(通常是 stdout,被日志采集器逐行解析)里混进一段 ASCII 艺术字,会导致解析器报错或产生脏数据。Spring Boot 4.x 把结构化日志作为一等公民(第 23 章详述),就必须处理这个冲突。

反直觉结论 #2:spring.main.banner-mode 的默认值不是常量,它是一个随运行时状态变化的函数。bannerMode 字段的默认值是 null,而 null 的语义是"我还不知道,等我拿到 Environment 再算"。这是本章最精巧的设计点之一——用 null 表达"未决策",把决策推迟到信息完备的时刻。

这个"延迟决策"模式在 Spring Boot 4.x 里不是孤例。它的通用形式是:

字段默认值 = null(未决策标记)
    ↓
getter(Environment) 被调用
    ↓
if (字段 != null) return 字段;             // 用户显式指定,尊重之
else return 基于 Environment 的推断;         // 自动决策

值得注意:这个 getter 需要 Environment 参数,所以它不能被标准的 JavaBean 属性机制使用(java.beans.Introspector 只认无参 getter)。这也就意味着:

  • Binder 绑定 spring.main.banner-mode 时用的是 setter(setBannerMode),不是 getter
  • getBannerMode(Environment) 只在读取时被调用(在 printBanner 里)
  • 这个类的"JavaBean 属性"和"实际使用方式"是分离的

这是一个有意的、但容易被误读的设计。如果你在做类似的设计,务必在 Javadoc 里说清楚,否则后来者会试图把它当标准 JavaBean 用。

2.3 Assert.state 在这里的作用

Assert.state(applicationPropertyName != null, "applicationPropertyName must not be null");

getApplicationPropertyName() 的返回类型是 @Nullable String,但在这个常量上必然非空。为了不让 NullAway / JSpecify 检查器报警、同时保留运行期安全网,Boot 的惯例是加一句 Assert.state。

Assert.state vs Assert.notNull:前者表达"这是本程序内部的不变式,违反即代码有 bug"(抛 IllegalStateException);后者表达"调用方传入的参数可能为 null"(抛 IllegalArgumentException)。这里用 state 是准确的——CONSOLE_STRUCTURED_FORMAT 有无应用属性名是 Boot 自己的实现细节。

所有方法都是包级私有(无 public),再次确认其内部类定位。外部只能通过 spring.main.* 属性或 SpringApplication 的 public setter 影响它。

注意 boolean 的 getter 前缀是 is 而不是 get——这是 JavaBean 规范对原始 boolean 的要求,JavaBeanBinder 依赖这个约定发现属性(见 5.3.2 节)。


第三部分 · 绑定机制:Bindable.ofInstance 的语义深潜

3.1 那一行代码

Binder.get(environment).bind("spring.main", Bindable.ofInstance(this.properties));

拆成三步:

(1)Binder.get(environment)

// core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java:572
public static Binder get(Environment environment, @Nullable BindHandler defaultBindHandler) {
    // ...
    return new Binder(sources, placeholdersResolver, null, null, defaultBindHandler);
}

Binder.get(Environment) 会:① 取出 ConfigurationPropertySources;② 用 PropertySourcesPlaceholdersResolver 处理 ${...};③ 使用 Environment 自己的 ConversionService(configureEnvironment 会设 ApplicationConversionService)。

关键点:Binder.get(Environment) 依赖 ConfigurationPropertySources.attach(environment) 已被调用。所以 prepareEnvironment() 里 attach 必须早于 bindToSpringApplication;而且末尾还会第二次 attach——因为 convertEnvironmentIfNecessary 可能换掉整个 Environment 对象(如换成 StandardServletEnvironment),新对象上没挂 ConfigurationPropertySource。这个细节决定了"为什么 spring.main.* 在 servlet 环境下也能正确绑定"。

(2)Bindable.ofInstance(this.properties)

// core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java:257
public static <T> Bindable<T> ofInstance(T instance) {
    // ...
}

ofInstance 创建一个绑定到已有实例的 Bindable。它与 of(Class) 的关键区别:

维度Bindable.of(Class)Bindable.ofInstance(instance)
绑定目标新建实例已有实例(原地修改)
需要的 Binder 方法bindOrCreatebind
无匹配属性时返回 Optional.empty()返回 Optional.empty(),但实例保持原样
已知类型只有 Class有 Class + instance
@Nullable 处理按类型推导按实例当前值推导

看 Bindable.java 里的一段断言:

// core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java:197
Assert.state(this.bindMethod != BindMethod.VALUE_OBJECT, ...);

以及:

// core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java:241
public Bindable<T> withBindMethod(@Nullable BindMethod bindMethod) {
    Assert.state(bindMethod != BindMethod.VALUE_OBJECT || this.value == null, ...);
}

这第二条断言很关键:如果 Bindable 已经持有一个实例(this.value != null),就不能再用 VALUE_OBJECT 绑定方式。因为 VALUE_OBJECT(构造器绑定 / record 绑定)的本质是"用构造参数新建一个不可变对象",对已存在的实例原地修改是没有意义的。

ApplicationProperties 用的是 bind() + ofInstance(),走的是 JavaBean 绑定(JavaBeanBinder):发现 setter → 转换值 → 调用 setter。这也是为什么它必须有 setter、必须有无参构造(隐式的默认构造器)。

(3)bind(...) 而不是 bindOrCreate(...)

Binder 提供了两组 API:

public <T> BindResult<T> bind(String name, Bindable<T> target)              // 填充已有实例
public <T> T bindOrCreate(String name, Bindable<T> target)                  // 创建并填充新实例

这里用 bind,因为目标是 final 的 this.properties,必须原地修改。

3.2 "未绑定的元素"为什么不会报错

一个非常实际的问题:如果我在 application.properties 里写了一个不存在的属性 spring.main.foo=bar,为什么启动不报错?

答案是:bind() 的语义是"尽力而为",它不做"未知属性"校验。

对比一下:

  • Binder.bind(name, Bindable) — 只读取它认识的属性,不认识的忽略
  • ConfigurationPropertiesBindingPostProcessor + @ConfigurationProperties(ignoreUnknownFields = false) — 会对未知属性报 NotWritablePropertyException / 绑定失败

ApplicationProperties 走的是前者。所以:

spring.main.foo=bar              # 静默忽略
spring.main.web-application-type=NONE   # 生效
spring.main.banner-mode=OFF      # 生效

反直觉结论 #3:spring.main.* 的属性名写错时没有任何错误提示。这跟 @ConfigurationProperties 的行为完全不同。如果你设置的 spring.main.xxx 没生效,第一件事应该是检查拼写——不会有日志告诉你拼错了。

这也解释了为什么元数据文件里会有 spring.main.show-banner 这种"已弃用但仍记录"的条目:即使 Java 类里早已没有 showBanner 字段,元数据里保留它可以在 IDE 里给出"已弃用,请使用 spring.main.banner-mode"的提示,而不是让开发者完全无感。

3.3 类型转换:spring.main.banner-mode=false 为什么能工作

注意测试用例里的这行:

// core/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java:332
this.context = application.run("--spring.main.banner-mode=false");

banner-mode 的类型是 Banner.Mode(枚举),但这里传的是 false!测试断言它等价于 OFF。

这是 ApplicationConversionService 在起作用:

  1. Binder 需要把字符串 "false" 转成 Banner.Mode
  2. 它查找 ConversionService 里有没有 String → Banner.Mode 的转换器
  3. ApplicationConversionService 内部有一个回退逻辑:先尝试标准的 String → Enum("false" 不匹配 OFF/CONSOLE/LOG,失败)
  4. 然后 Boot 的 LenientObjectToEnumConverterFactory(在 ApplicationConversionService 里)会做宽松匹配:把目标值做规范化(去 -、去 _、转小写),再和枚举名比对;此外对 boolean 值有特殊处理

具体来说,Spring Boot 的枚举宽松转换规则包括:

  • 大小写不敏感:off / OFF / Off 都可以
  • 分隔符宽松:banner-mode 对应 BANNER_MODE... 但这里枚举值就是 OFF
  • 对 true/false 的特殊映射:false → OFF,true → CONSOLE

最后这条是历史遗留的兼容:spring.main.show-banner 是 boolean 类型,弃用后改为 banner-mode,为了不让老配置炸掉,Boot 在转换器层面保留了 boolean → Mode 的映射。

这个细节在 Spring Boot 官方文档里没有明确写,但在测试用例里被明确断言。这印证了我们第 01 章讲过的"第三定律:用测试确认行为"。

同理 --spring.main.web-application-type=none 中的 none → WebApplicationType.NONE 也是宽松枚举转换。

3.4 bindToSpringApplication 的异常处理

protected void bindToSpringApplication(ConfigurableEnvironment environment) {
    try {
        Binder.get(environment).bind("spring.main", Bindable.ofInstance(this.properties));
    }
    catch (Exception ex) {
        throw new IllegalStateException("Cannot bind to SpringApplication", ex);
    }
}

三点:

  1. protected — 这是一个扩展点。子类可以覆盖它来做自定义绑定(虽然实践中很少有人这么做)。
  2. 捕获 Exception(不是 Throwable) — 让 Error 继续传播,符合惯例。
  3. 包装成 IllegalStateException,保留 cause — 绑定失败时,异常链里能看到原始的转换错误。

什么时候会绑定失败?典型场景:

spring.main.web-application-type=GRPC-SERVER   # 不存在的枚举值

会抛:

java.lang.IllegalStateException: Cannot bind to SpringApplication
    Caused by: org.springframework.boot.context.properties.bind.BindException:
        Failed to bind properties under 'spring.main.web-application-type' to org.springframework.boot.WebApplicationType

诊断提示:看到 Cannot bind to SpringApplication 这个异常信息,100% 是 spring.main.* 的某个属性写错了(类型不匹配、枚举值不存在)。去查 cause 里的 BindException 就能定位到具体属性。

3.5 绑定时序的完整图景

把 prepareEnvironment(SpringApplication.java:351)按步标注:

// ① 创建 Environment(用的是"构造期推断"的 web 类型,尚未被 spring.main.* 覆盖)
ConfigurableEnvironment environment = getOrCreateEnvironment();
// ② 装配基础 PropertySource:defaultProperties、commandLineArgs、applicationInfo
configureEnvironment(environment, applicationArguments.getSourceArgs());
// ③ 挂 ConfigurationPropertySource 适配器(让 Binder 能按 ConfigurationPropertyName 读)
ConfigurationPropertySources.attach(environment);
// ④ 广播 environmentPrepared —— ConfigData(application.properties/yml)在这里加载
listeners.environmentPrepared(bootstrapContext, environment);
// ⑤ 把 applicationInfo / defaultProperties 挪到末尾(保证不覆盖用户配置)
ApplicationInfoPropertySource.moveToEnd(environment);
DefaultPropertiesPropertySource.moveToEnd(environment);
// ⑥ 断言:环境前缀不能通过属性设置(见第 5 节)
Assert.state(!environment.containsProperty("spring.main.environment-prefix"),
        "Environment prefix cannot be set via properties.");
// ⑦ ★ 绑定 spring.main.*
bindToSpringApplication(environment);
// ⑧ Environment 类型不对则转换(例如需要 Servlet 环境但当前是 StandardEnvironment)
if (!this.isCustomEnvironment) {
    EnvironmentConverter environmentConverter = new EnvironmentConverter(getClassLoader());
    environment = environmentConverter.convertEnvironmentIfNecessary(environment, deduceEnvironmentClass());
}
// ⑨ 重新 attach(因为 ⑧ 可能换了对象)
ConfigurationPropertySources.attach(environment);
return environment;

这一步是理解整个启动流程的关键枢纽。请特别注意 ⑦ 与 ⑧ 的关系,我们在第 4 节详细展开。

第四部分 · 九大属性逐一深挖(上)

这一部分是本章的核心。每个属性我们都按同一个模板展开:

① 属性全表项(名字 / 类型 / 默认值 / 元数据描述)
② 生效代码路径(精确到文件行号)
③ 生效时机(在 run() 的哪一步)
④ 真实影响边界(它到底改变了什么、不改变什么)
⑤ 陷阱与反直觉行为

4.1 spring.main.web-application-type —— 影响面最大的一个

元数据定义:

{
  "name": "spring.main.web-application-type",
  "type": "org.springframework.boot.WebApplicationType",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Flag to explicitly request a specific type of web application. If not set, auto-detected based on the classpath."
}

注意:这个属性没有 defaultValue——因为默认值是"推断出来的",不是常量。

4.1.1 枚举本体
// core/spring-boot/src/main/java/org/springframework/boot/WebApplicationType.java
public enum WebApplicationType {

    NONE,      // 不作为 web 应用运行,不启动内嵌服务器
    SERVLET,   // servlet web 应用,启动内嵌 servlet 服务器
    REACTIVE;  // 响应式 web 应用,启动内嵌响应式服务器

    private static final String[] SERVLET_INDICATOR_CLASSES = {
        "jakarta.servlet.Servlet",
        "org.springframework.web.context.ConfigurableWebApplicationContext"
    };

    public static WebApplicationType deduce() {
        for (Deducer deducer : SpringFactoriesLoader.forDefaultResourceLocation().load(Deducer.class)) {
            WebApplicationType deduced = deducer.deduceWebApplicationType();
            if (deduced != null) {
                return deduced;
            }
        }
        return isServletApplication() ? WebApplicationType.SERVLET : WebApplicationType.NONE;
    }
    // ...
}

三个要点:

(a)REACTIVE 不在默认推断路径里

return isServletApplication() ? WebApplicationType.SERVLET : WebApplicationType.NONE;

纯靠 classpath 推断,永远推断不出 REACTIVE。要得到它,必须走 Deducer SPI 或显式设置 spring.main.web-application-type=reactive。

反直觉结论 #4:引入 spring-boot-starter-webflux 却没有 servlet API、且 webflux 模块的 Deducer 未生效时,应用会被推断为 NONE 而非 REACTIVE,WebFlux 自动配置不会启动服务器。遇到"WebFlux 应用没起端口",第一件事是打印 webApplicationType。

(b)Deducer 是 4.x 新增 SPI(@since 4.0.1)

@FunctionalInterface
public interface Deducer {
    @Nullable WebApplicationType deduceWebApplicationType();
}

这是 4.x 模块化的直接后果。3.x 时代 deduceFromClasspath() 里硬编码了 WebFlux 探测(ClassUtils.isPresent("org.springframework.web.reactive.DispatcherHandler", ...));4.x 把 WebFlux 拆到 module/spring-boot-webflux,核心 jar 不能再硬编码它的类名,于是改为 SPI(SpringFactoriesLoader 加载)。这是**"能力模块化 → 核心被迫 SPI 化"**的又一例(同类模式见第 01、03 章:AutoConfiguration.imports、ApplicationContextFactory)。

(c)isServletApplication() 要求两个类同时存在

jakarta.servlet.Servlet 与 org.springframework.web.context.ConfigurableWebApplicationContext 缺一即非 servlet 应用。ClassUtils.isPresent(x, null) 的第二个参数 null 表示使用 ClassUtils.getDefaultClassLoader()。

实践陷阱:把 jakarta.servlet-api 声明为 providedRuntime 而容器不在 classpath 时,可能推断出 SERVLET 但实际起不了服务器。

4.1.2 它在 SpringApplication 里的三个使用点
// 使用点 1:构造器,初始化为推断值
// SpringApplication.java:278
this.properties.setWebApplicationType(WebApplicationType.deduce());

// 使用点 2:getOrCreateEnvironment() —— 决定 Environment 类型
// SpringApplication.java:479
private ConfigurableEnvironment getOrCreateEnvironment() {
    if (this.environment != null) {
        return this.environment;
    }
    WebApplicationType webApplicationType = this.properties.getWebApplicationType();
    ConfigurableEnvironment environment = this.applicationContextFactory.createEnvironment(webApplicationType);
    if (environment == null && this.applicationContextFactory != ApplicationContextFactory.DEFAULT) {
        environment = ApplicationContextFactory.DEFAULT.createEnvironment(webApplicationType);
    }
    return (environment != null) ? environment : new ApplicationEnvironment();
}

// 使用点 3:createApplicationContext() —— 决定 ApplicationContext 类型
// SpringApplication.java:581
protected ConfigurableApplicationContext createApplicationContext() {
    ConfigurableApplicationContext context = this.applicationContextFactory
        .create(this.properties.getWebApplicationType());
    Assert.state(context != null, "ApplicationContextFactory created null context");
    return context;
}

// 使用点 4(隐含):deduceEnvironmentClass()
// SpringApplication.java:371
private Class<? extends ConfigurableEnvironment> deduceEnvironmentClass() {
    WebApplicationType webApplicationType = this.properties.getWebApplicationType();
    Class<? extends ConfigurableEnvironment> environmentType = this.applicationContextFactory
        .getEnvironmentType(webApplicationType);
    if (environmentType == null && this.applicationContextFactory != ApplicationContextFactory.DEFAULT) {
        environmentType = ApplicationContextFactory.DEFAULT.getEnvironmentType(webApplicationType);
    }
    return (environmentType != null) ? environmentType : ApplicationEnvironment.class;
}
4.1.3 那个"鸡生蛋"问题——这是本节最重要的内容

仔细看 prepareEnvironment 的调用顺序,会发现一个循环依赖:

getOrCreateEnvironment()  需要  properties.getWebApplicationType()   ← 用到 spring.main.web-application-type
        ↓
...
bindToSpringApplication()  才绑定  spring.main.*                    ← 定义 spring.main.web-application-type

要造 Environment,先要知道 web 类型;要知道 web 类型,得先绑定属性;要绑定属性,得先有 Environment。

Spring Boot 是怎么打破这个环的?答案是:它接受"Environment 的类型在绑定前可能不准",然后在绑定后用 EnvironmentConverter 修正。

完整流程:

① 构造 SpringApplication
   properties.webApplicationType = WebApplicationType.deduce()     ← 基于 classpath 的"猜测值"
                                                                       (或用户调用 setWebApplicationType 的显式值)

② prepareEnvironment() 步骤 ①:getOrCreateEnvironment()
   用【猜测值】创建 Environment
   - 若猜测 SERVLET 且 webmvc 模块在 → 创建 StandardServletEnvironment
   - 否则 → 创建 ApplicationEnvironment(StandardEnvironment 子类)

③ prepareEnvironment() 步骤 ④:listeners.environmentPrepared(...)
   ConfigData 加载 application.properties / yml / 环境变量 / 命令行
   此时【真正的 spring.main.web-application-type】出现在 Environment 里

④ prepareEnvironment() 步骤 ⑦:bindToSpringApplication(environment)
   properties.webApplicationType 被更新为【真实值】
   ★ 此时 Environment 对象已经创建好了,类型还是旧的

⑤ prepareEnvironment() 步骤 ⑧:convertEnvironmentIfNecessary(...)
   deduceEnvironmentClass() 用【真实值】算出"应该是什么类型"
   如果不匹配 → EnvironmentConverter 把属性源全部搬到新 Environment 里
   ★ 这一步修正了 ② 的错误猜测

⑥ createApplicationContext()
   用【真实值】创建 ApplicationContext
   ★ 这一步没有被"污染",因为它在绑定之后

结论:Environment 的类型可能被"先造后换",ApplicationContext 的类型则一定是正确的(因为它在绑定之后才创建)。

这是一个极其务实的工程决策:不在绑定前冒险猜测,而是先猜一个、绑定后再纠正。纠正的成本(新建一个 Environment,搬运 PropertySource)远低于"阻塞等待"的成本(无解)。

4.1.4 EnvironmentConverter:如何"换掉"一个 Environment
// core/spring-boot/src/main/java/org/springframework/boot/EnvironmentConverter.java
final class EnvironmentConverter {

    private static final String CONFIGURABLE_WEB_ENVIRONMENT_CLASS =
        "org.springframework.web.context.ConfigurableWebEnvironment";

    private static final Set<String> SERVLET_ENVIRONMENT_SOURCE_NAMES;

    static {
        Set<String> names = new HashSet<>();
        names.add(StandardServletEnvironment.SERVLET_CONTEXT_PROPERTY_SOURCE_NAME);
        names.add(StandardServletEnvironment.SERVLET_CONFIG_PROPERTY_SOURCE_NAME);
        names.add(StandardServletEnvironment.JNDI_PROPERTY_SOURCE_NAME);
        SERVLET_ENVIRONMENT_SOURCE_NAMES = Collections.unmodifiableSet(names);
    }

    ConfigurableEnvironment convertEnvironmentIfNecessary(ConfigurableEnvironment environment,
            Class<? extends ConfigurableEnvironment> type) {
        if (type.equals(environment.getClass())) {
            return environment;                    // 类型已对,直接返回(常见路径)
        }
        return convertEnvironment(environment, type);
    }

    private ConfigurableEnvironment convertEnvironment(ConfigurableEnvironment environment,
            Class<? extends ConfigurableEnvironment> type) {
        ConfigurableEnvironment result = createEnvironment(type);
        result.setActiveProfiles(environment.getActiveProfiles());
        result.setConversionService(environment.getConversionService());
        copyPropertySources(environment, result);
        return result;
    }

    private ConfigurableEnvironment createEnvironment(Class<? extends ConfigurableEnvironment> type) {
        try {
            Constructor<? extends ConfigurableEnvironment> constructor = type.getDeclaredConstructor();
            ReflectionUtils.makeAccessible(constructor);
            return constructor.newInstance();
        }
        catch (Exception ex) {
            return new ApplicationEnvironment();     // ★ 失败兜底
        }
    }

    private void copyPropertySources(ConfigurableEnvironment source, ConfigurableEnvironment target) {
        removePropertySources(target.getPropertySources(), isServletEnvironment(target.getClass(), this.classLoader));
        for (PropertySource<?> propertySource : source.getPropertySources()) {
            if (!SERVLET_ENVIRONMENT_SOURCE_NAMES.contains(propertySource.getName())) {
                target.getPropertySources().addLast(propertySource);
            }
        }
    }
    // ...
}

值得注意的细节:

  1. type.equals(environment.getClass()) 用 equals 而非 isAssignableFrom —— 要求精确类型匹配。即便 ApplicationEnvironment 与 StandardEnvironment 有继承关系也会触发转换,实践中可能多一次转换,但行为可预测。
  2. createEnvironment 失败时兜底 new ApplicationEnvironment() —— 优雅降级:宁可用基础 Environment 继续启动,也不要崩掉。
  3. copyPropertySources 排除三个 servlet PropertySource(servletContextInitParams、servletConfigInitParams、jndiProperties)—— 它们绑定具体 ServletContext/ServletConfig 实例,搬到新 Environment 会造成旧引用悬挂;它们会在 ApplicationContext 初始化时由容器重新注入。
  4. 保留 activeProfiles 与 conversionService —— 这两项在绑定前可能已被 ConfigData 的 profile 激活逻辑设置过。
  5. isServletEnvironment 用字符串类名 + ClassUtils.forName —— EnvironmentConverter 在 core/spring-boot 里,不能直接引用 spring-web 的 ConfigurableWebEnvironment(可选依赖)。又一次看到"模块化导致的字符串化"。
4.1.5 实战:什么时候需要显式设置
场景推荐做法
单元测试,不希望起服务器@SpringBootTest(webEnvironment = WebEnvironment.NONE)(优先)或 --spring.main.web-application-type=none
有 servlet API 但不想起 web--spring.main.web-application-type=none
WebFlux 应用没起端口显式 --spring.main.web-application-type=reactive
gRPC-only 应用4.x 的 gRPC 模块可能提供 Deducer;若没有,用 none
一个 jar 里同时有 webmvc 和 webflux必须显式设置,否则推断结果取决于 classpath 顺序(不可靠)

最后一条特别重要。Spring Boot 官方对"同时引入 spring-boot-starter-web 和 spring-boot-starter-webflux"的官方说法是:默认会走 SERVLET(因为 WebApplicationType.deduce() 的兜底逻辑只检查 servlet),除非 webflux 模块提供了优先的 Deducer。这个行为在 3.x 和 4.x 之间可能因为 Deducer 的引入而改变——建议显式设置,不要依赖推断。


4.2 spring.main.allow-bean-definition-overriding

元数据:

{
  "name": "spring.main.allow-bean-definition-overriding",
  "type": "java.lang.Boolean",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Whether bean definition overriding, by registering a definition with the same name as an existing definition, is allowed.",
  "defaultValue": false
}
4.2.1 生效路径
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:386
private void prepareContext(DefaultBootstrapContext bootstrapContext, ConfigurableApplicationContext context,
        ConfigurableEnvironment environment, SpringApplicationRunListeners listeners,
        ApplicationArguments applicationArguments, @Nullable Banner printedBanner) {
    context.setEnvironment(environment);
    postProcessApplicationContext(context);
    addAotGeneratedInitializerIfNecessary(this.initializers);
    ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
    if (beanFactory instanceof AbstractAutowireCapableBeanFactory autowireCapableBeanFactory) {
        autowireCapableBeanFactory.setAllowCircularReferences(this.properties.isAllowCircularReferences());
        if (beanFactory instanceof DefaultListableBeanFactory listableBeanFactory) {
            listableBeanFactory.setAllowBeanDefinitionOverriding(this.properties.isAllowBeanDefinitionOverriding());
        }
    }
    // ...
}

注意嵌套的 instanceof 模式匹配(Java 17+ 语法):

  • 外层判断 AbstractAutowireCapableBeanFactory → 设置循环引用
  • 内层判断 DefaultListableBeanFactory → 设置定义覆盖

这个嵌套是必要的,因为:

  • setAllowCircularReferences 定义在 AbstractAutowireCapableBeanFactory 上
  • setAllowBeanDefinitionOverriding 定义在 DefaultListableBeanFactory 上

而 DefaultListableBeanFactory extends AbstractAutowireCapableBeanFactory,所以内层一定能成立(如果外层成立的话)——但编译器不知道这点,所以要写嵌套。

4.2.2 "Bean 定义覆盖"到底是什么

Spring 的 DefaultListableBeanFactory.registerBeanDefinition() 里:

// (示意,非本仓库代码,来自 spring-beans)
BeanDefinition existingDefinition = this.beanDefinitionMap.get(beanName);
if (existingDefinition != null) {
    if (!isAllowBeanDefinitionOverriding()) {
        throw new BeanDefinitionOverrideException(beanName, beanDefinition, existingDefinition);
    }
    // ... 允许覆盖的逻辑
}

触发场景:两个 @Configuration 里有同名 @Bean;组件扫描到同名类(@Component("sameName"));XML 与 Java 配置混用同名;自动配置与用户代码同名(但见下)。

Spring Boot 2.1 起默认值从 true 改为 false——历史上影响面最大的行为变更之一,大量"靠覆盖来定制"的老代码因此炸掉。

4.2.3 关键误解:它与"覆盖自动配置的 Bean"无关

这是最常见的误解。很多人以为"要覆盖自动配置提供的 Bean,需要打开 allow-bean-definition-overriding"。错。

自动配置的 Bean 覆盖机制依赖的是 @ConditionalOnMissingBean,而不是 Bean 定义覆盖:

@Bean
@ConditionalOnMissingBean        // ← 只有当用户没定义时才注册
public MyService myService() { ... }

这个条件在自动配置类被处理时(用户 Bean 定义已注册完成)判断。如果用户定义了同名 Bean,@ConditionalOnMissingBean 不成立,自动配置的那个根本不会被注册成 Bean 定义——压根没有"覆盖"发生。

所以:

场景需要 allow-bean-definition-overriding=true 吗
覆盖自动配置提供的 Bean(有 @ConditionalOnMissingBean)不需要
两个自己写的 @Bean 方法同名需要(或改名)
组件扫描到同名类需要(或改名)
用 BeanDefinitionRegistryPostProcessor 替换已有定义需要

反直觉结论 #5:spring.main.allow-bean-definition-overriding=true 是一个应该尽量避免的配置。它的正当用途极窄(主要是遗留 XML 迁移、某些测试框架)。如果你需要它才能启动,更可能的问题是 Bean 命名冲突——先去读异常里指定的 beanName 和冲突的两个来源。

4.2.4 与 AOT 的交互

在 AOT 模式下(spring.aot.enabled=true 或 native 构建),AOT 引擎会在构建期真实启动一个 ApplicationContext(不 refresh 到实例化单例的阶段),把 Bean 定义固化成 Java 源码。此时若发生 Bean 定义覆盖,该开关的取值会被"烘焙"进生成的行为里。

更实际的问题是:AOT 生成的代码假设 Bean 定义是静态的。运行期的动态注册(BeanDefinitionRegistryPostProcessor)在 AOT 下受限,行为可能与 JVM 模式不同(第 27 章详述)。


4.3 spring.main.allow-circular-references

元数据:

{
  "name": "spring.main.allow-circular-references",
  "type": "java.lang.Boolean",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Whether to allow circular references between beans and automatically try to resolve them.",
  "defaultValue": false
}
4.3.1 生效路径

同上,SpringApplication.java:388:

autowireCapableBeanFactory.setAllowCircularReferences(this.properties.isAllowCircularReferences());
4.3.2 循环引用 vs 循环依赖:一个必须澄清的术语

这里是中文技术社区里混淆最严重的概念之一。区分:

类型描述Spring 能否处理
字段注入循环A 的 @Autowired 字段指向 B,B 的 @Autowired 字段指向 A能(Spring 天然支持,无需任何开关)
构造器注入循环A 的构造器参数要 B,B 的构造器参数要 A不能(除非开 allow-circular-references)
Setter 注入循环类似字段注入能

关键点:Spring 的三级缓存(singletonFactories)天然支持字段/setter 注入的循环。allowCircularReferences 控制的是构造器注入的循环。

为什么构造器循环不行?因为构造器注入要求"参数的实例在构造前就准备好",而 A 的构造需要 B 的完整实例,B 的构造又需要 A 的完整实例——死锁。

AbstractAutowireCapableBeanFactory 的 allowCircularReferences 开启后,Spring 会走"早期引用"(early reference)路径:允许把一个尚未完成构造的对象暴露出去。这需要代理(通常是 CGLIB 或 JDK 动态代理)参与,因此:

开启 allow-circular-references 会强制某些 Bean 被代理,这可能导致:

  • AOT / native 下需要额外的代理 hints
  • final 类无法被代理 → 循环依然失败
  • @Async、@Transactional 等已有代理的 Bean 出现双重代理问题
4.3.3 Spring Boot 2.6 的默认值变更

默认值在 2.6 从 true 改为 false——一次"强制改善代码质量"的变更:Spring 团队认为构造器注入循环是设计问题的信号,应被显式暴露而非默默容忍。

报错信息(BeanCurrentlyInCreationException 的增强版)会画出循环链,并在 Action: 段落直接给出 spring.main.allow-circular-references to true 这个"last resort"建议——体现了 Boot 在错误信息设计上的用心(第 01 章讲过的 FailureAnalyzer 体系,虽然这个来自 spring-beans)。

4.3.4 什么时候真的需要它

✓ 合理:遗留代码迁移期;第三方库内部有构造器循环(你改不了)。 ✗ 应该避免:自己的业务代码有循环 → 应重构(提取第三个 Service、用 @Lazy 打断点、改 setter 注入、用 ObjectProvider 延迟获取)。

实践建议:@Lazy 是比全局开关更好的局部解法——它只在一个注入点上打破循环:

public OrderService(@Lazy PaymentService paymentService) { ... }

4.4 spring.main.lazy-initialization

元数据:

{
  "name": "spring.main.lazy-initialization",
  "type": "java.lang.Boolean",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Whether initialization should be performed lazily.",
  "defaultValue": false
}
4.4.1 生效路径
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:405
if (this.properties.isLazyInitialization()) {
    context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor());
}

注意:它注册的是一个 BeanFactoryPostProcessor,而不是直接设置每个 Bean 的 lazyInit。这是一个重要区别——BeanFactoryPostProcessor 在"所有 Bean 定义注册完毕、但尚未实例化任何单例"的时刻运行,此时它能看到完整的 Bean 定义集合,包括自动配置贡献的那些。

4.4.2 LazyInitializationBeanFactoryPostProcessor 的实现
// core/spring-boot/src/main/java/org/springframework/boot/LazyInitializationBeanFactoryPostProcessor.java
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
    Collection<LazyInitializationExcludeFilter> filters = getFilters(beanFactory);
    for (String beanName : beanFactory.getBeanDefinitionNames()) {
        BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
        if (beanDefinition instanceof AbstractBeanDefinition abstractBeanDefinition) {
            postProcess(beanFactory, filters, beanName, abstractBeanDefinition);
        }
    }
}

private void postProcess(...) {
    Boolean lazyInit = beanDefinition.getLazyInit();
    if (lazyInit != null) {
        return;                     // ★ 已显式设置的,不覆盖
    }
    Class<?> beanType = getBeanType(beanFactory, beanName);
    if (!isExcluded(filters, beanName, beanDefinition, beanType)) {
        beanDefinition.setLazyInit(true);
    }
}

三个关键设计:

(a)Ordered 接口 —— 这个 BeanFactoryPostProcessor 有顺序。看类的末尾:

@Override
public int getOrder() {
    return Ordered.LOWEST_PRECEDENCE;
}

(从源码末尾的方法可见,它实现了 Ordered 并返回最低优先级,即最后执行)。这样其他 BeanFactoryPostProcessor(如 ConfigurationClassPostProcessor)先跑完,它才能看到完整的 Bean 定义集合。

(b)lazyInit != null 就跳过 —— 尊重显式设置。注意 getLazyInit() 返回 Boolean(包装类型),null 表示"未设置",true/false 表示显式设置。这是一个用 Boolean 而非 boolean 来表达三态的经典用法。

(c)三类默认排除:

  1. 用户提供的 LazyInitializationExcludeFilter Bean —— 通过 getBeansOfType(..., false, false) 获取。注意后两个 false:allowEagerInit=false、allowFactoryBeanInit=false。注释写得很清楚:"Take care not to force the eager init of factory beans when getting filters"——在这个阶段(还没到实例化单例的时刻)如果强制初始化 FactoryBean,会破坏懒加载的初衷,甚至引发循环。

  2. SmartInitializingSingleton 类型的 Bean —— 因为 SmartInitializingSingleton.afterSingletonsInstantiated() 回调依赖"所有单例已实例化"这个前提。如果一个 SmartInitializingSingleton 被设为懒加载,它可能永远不会被实例化,回调也就永远不会执行——这会静默地破坏功能。所以必须排除。

  3. 基础设施角色的 Bean —— InfrastructureRoleLazyInitializationExcludeFilter 排除 BeanDefinition.ROLE_INFRASTRUCTURE。这些是 Spring 自己用的内部 Bean(如 @Configuration 的处理基础设施、AOP 基础设施),懒加载它们会导致容器行为异常。

4.4.3 LazyInitializationExcludeFilter 接口
// core/spring-boot/src/main/java/org/springframework/boot/LazyInitializationExcludeFilter.java
@FunctionalInterface
public interface LazyInitializationExcludeFilter {

    boolean isExcluded(String beanName, BeanDefinition beanDefinition, Class<?> beanType);

    static LazyInitializationExcludeFilter forBeanTypes(Class<?>... types) {
        return (beanName, beanDefinition, beanType) -> {
            for (Class<?> type : types) {
                if (type.isAssignableFrom(beanType)) {
                    return true;
                }
            }
            return false;
        };
    }
}

Javadoc 里的用法示例:

@Bean
public static LazyInitializationExcludeFilter integrationLazyInitializationExcludeFilter() {
    return LazyInitializationExcludeFilter.forBeanTypes(IntegrationFlow.class);
}

注意 static!Javadoc 明确强调:

NOTE: Beans of this type will be instantiated very early in the spring application lifecycle so they should generally be declared static and not have any dependencies.

因为 BeanFactoryPostProcessor 在 @Configuration 类本身被完全处理之前就需要拿到这些 filter(getBeansOfType 会实例化它们)。如果 @Bean 方法不是 static,就需要先实例化外层 @Configuration 类,这会:

  1. 触发外层配置类的依赖注入 → 可能拉起一大堆 Bean
  2. 与懒加载的目标直接冲突

这是一个写 BeanFactoryPostProcessor 相关 Bean 时必须记住的规则:用 static @Bean 方法。

4.4.4 懒加载的真实收益与代价

收益:

  • 启动更快(不实例化用不到的 Bean)
  • 内存占用更低
  • 某些循环依赖问题"消失"(因为 Bean 不需要在启动时就构造完成)

代价(这些是官方文档不会强调的):

  1. 问题延迟暴露 —— 原本启动就失败的配置错误,现在要到第一次请求才炸。生产环境这是灾难性的。
  2. 首次请求延迟 —— 第一个请求要承担全部初始化成本。
  3. 与某些功能冲突 —— 任何"启动时需要扫描所有 Bean"的功能都会失效:
    • Actuator 的 /beans 端点只能看到已实例化的
    • SmartInitializingSingleton(已排除)
    • 某些健康检查、指标注册
    • @Scheduled 任务——注意:ScheduledAnnotationBeanPostProcessor 依赖 ApplicationReadyEvent 或上下文刷新完成事件来启动调度,如果调度 Bean 本身懒加载,任务可能不会启动
  4. AOT/native 下收益降低 —— native image 的启动时间本来就极短,懒加载的边际收益小,但分析复杂度上升。

实践建议:spring.main.lazy-initialization=true 最适合本地开发和测试(加快反馈循环)。生产环境启用前,务必评估"配置错误延迟暴露"的风险。折中方案:只在 dev profile 开启。

4.4.5 与 AOT 模式的差异

AOT 模式下 LazyInitializationBeanFactoryPostProcessor 依然运行(setLazyInit(true) 改的是 BeanDefinition 的运行时状态),但 AOT 生成的代码已把 Bean 定义固化,并引入了 InstanceSupplier 优化。懒加载与 InstanceSupplier 的组合路径与 JVM 模式不完全一致——若 native 下懒加载行为异常,优先排查这一点。

4.5 spring.main.banner-mode —— 唯一一个"非恒定默认值"的属性

元数据:

{
  "name": "spring.main.banner-mode",
  "type": "org.springframework.boot.Banner$Mode",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Mode used to display the banner when the application runs. Defaults to 'off' if structured logging is enabled or to 'console' otherwise"
}

注意这个描述本身就写出了条件默认值——这是整个 spring.main.* 家族里唯一一个。

4.5.1 Banner.Mode 的三个取值
// core/spring-boot/src/main/java/org/springframework/boot/Banner.java
enum Mode {
    OFF,       // 不打印
    CONSOLE,   // 打印到 System.out
    LOG        // 打印到日志文件(通过 logger)
}
4.5.2 生效路径
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:559
private @Nullable Banner printBanner(ConfigurableEnvironment environment) {
    if (this.properties.getBannerMode(environment) == Banner.Mode.OFF) {
        return null;                                       // ← 不打印,也不返回 Banner 对象
    }
    ResourceLoader resourceLoader = (this.resourceLoader != null) ? this.resourceLoader
            : new DefaultResourceLoader(null);
    SpringApplicationBannerPrinter bannerPrinter = new SpringApplicationBannerPrinter(resourceLoader, this.banner);
    if (this.properties.getBannerMode(environment) == Mode.LOG) {
        return bannerPrinter.print(environment, this.mainApplicationClass, logger);
    }
    return bannerPrinter.print(environment, this.mainApplicationClass, System.out);
}

注意 getBannerMode(environment) 被调用了两次(OFF 判断一次,LOG 判断一次)。这是一个轻微的冗余——每次都会重新读取 LoggingSystemProperty.CONSOLE_STRUCTURED_FORMAT 并调用 environment.containsProperty(...)。

代码观察:这里本可以先取一次局部变量。之所以没有,可能是为了保持代码的可读性(两处判断语义清晰),也可能是历史演进的结果。性能上,containsProperty 遍历 PropertySource 链,在启动路径上被调用两次的代价可以忽略。但这是一个值得注意的"小瑕疵"——它说明即便是 Spring Boot 这种级别的代码库,也有不完美的地方。

4.5.3 返回的 Banner 对象去了哪里
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:401
beanFactory.registerSingleton("springApplicationArguments", applicationArguments);
if (printedBanner != null) {
    beanFactory.registerSingleton("springBootBanner", printedBanner);   // ★ 注册成单例
}

所以容器里有一个名为 springBootBanner 的单例(实际类型 SpringApplicationBannerPrinter.PrintedBanner,支持在运行期再次打印)。

隐藏副作用:设置 spring.main.banner-mode=off 时 printBanner 返回 null,容器里就没有 springBootBanner 这个 Bean——任何依赖它的代码会失败。

4.5.4 Banner 内容的来源优先级
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplicationBannerPrinter.java
static final String BANNER_LOCATION_PROPERTY = "spring.banner.location";
static final String DEFAULT_BANNER_LOCATION = "banner.txt";

private static final Banner DEFAULT_BANNER = new SpringBootBanner();

private Banner getBanner(Environment environment) {
    Banner textBanner = getTextBanner(environment);        // ① 自定义文本文件
    if (textBanner != null) {
        return textBanner;
    }
    if (this.fallbackBanner != null) {                     // ② SpringApplication.setBanner(...) 设的
        return this.fallbackBanner;
    }
    return DEFAULT_BANNER;                                 // ③ 内置 SpringBootBanner
}

private @Nullable Banner getTextBanner(Environment environment) {
    String location = environment.getProperty(BANNER_LOCATION_PROPERTY, DEFAULT_BANNER_LOCATION);
    Resource resource = this.resourceLoader.getResource(location);
    try {
        if (resource.exists() && !resource.getURL().toExternalForm().contains("liquibase-core")) {
            return new ResourceBanner(resource);
        }
    }
    catch (IOException ex) {
        // Ignore
    }
    return null;
}

优先级:spring.banner.location 指定的资源 > SpringApplication.setBanner() > 内置 SpringBootBanner

4.5.5 那个神秘的 liquibase-core 判断
if (resource.exists() && !resource.getURL().toExternalForm().contains("liquibase-core"))

这是一段极其罕见的、写死在 Spring Boot 核心里的"第三方库特判"。

背景:Liquibase 的 jar 里打包了一个 banner.txt(Liquibase 自己的 CLI 用的)。如果应用引入了 Liquibase,且 banner.txt 恰好从 Liquibase 的 jar 里被解析到,那么 Spring Boot 启动时会打印 Liquibase 的 ASCII 艺术字而不是 Spring Boot 的。

Boot 团队没有更好的通用解法(无法判断"这个 banner.txt 是不是用户自己的"),于是用了最粗暴的办法:如果资源 URL 里包含 liquibase-core,就当它不存在。

这是一个真实的、可以从源码里挖出来的工程故事。它说明:

  1. Spring Boot 有大量这类"为了兼容性/现实问题"的补丁代码
  2. 通用框架在真实世界里不得不为具体的第三方库做特判
  3. 读源码时能发现这些"故事",是理解框架成熟度的最好方式
4.5.6 ResourceBanner 的占位符解析
// core/spring-boot/src/main/java/org/springframework/boot/ResourceBanner.java
for (PropertyResolver resolver : getPropertyResolvers(environment, sourceClass)) {
    banner = resolver.resolvePlaceholders(banner);
}
out.println(banner);

protected List<PropertyResolver> getPropertyResolvers(Environment environment, @Nullable Class<?> sourceClass) {
    List<PropertyResolver> resolvers = new ArrayList<>();
    resolvers.add(new PropertySourcesPropertyResolver(createNullDefaultSources(environment, sourceClass)));
    resolvers.add(new PropertySourcesPropertyResolver(createEmptyDefaultSources(environment, sourceClass)));
    return resolvers;
}

两遍占位符解析:第一遍缺属性时替换成 null(MapWithNullsPropertySource 允许存 null),第二遍替换成空字符串 ""。这用于区分"属性存在但值为空"与"属性完全不存在"。

内置变量(SpringBootBanner 提供)包括 ${application.version}、${application.title}、${spring-boot.version}、${spring-boot.formatted-version}、${AnsiColor.RED} 等。字符集由 spring.banner.charset 控制,默认 UTF-8。

4.5.7 打印失败不阻断启动
catch (Exception ex) {
    logger.warn(LogMessage.format("Banner not printable: %s (%s: '%s')", ...), ex);
}

Banner 打印失败只记 warn,不影响启动。这是一个明确的设计决策:Banner 是装饰性的,绝不能因为它导致应用起不来。


4.6 spring.main.log-startup-info

元数据:

{
  "name": "spring.main.log-startup-info",
  "type": "java.lang.Boolean",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Whether to log information about the application when it starts.",
  "defaultValue": true
}
4.6.1 两个使用点
// 使用点 1:prepareContext —— 打印"Starting ..."信息
// SpringApplication.java:396
if (this.properties.isLogStartupInfo()) {
    logStartupInfo(context);
    logStartupProfileInfo(context);
}

// 使用点 2:run —— 打印"Started ... in X seconds"
// SpringApplication.java:324
Duration timeTakenToStarted = startup.started();
if (this.properties.isLogStartupInfo()) {
    new StartupInfoLogger(this.mainApplicationClass, environment).logStarted(getApplicationLog(), startup);
}

分别对应那两行熟悉的日志:

2026-10-17T12:00:00.000+08:00  INFO 12345 --- [main] com.example.MyApp : Starting MyApp v1.0.0 using Java 25 with PID 12345 (/path/to/app started by user in /path/to/dir)
2026-10-17T12:00:02.345+08:00  INFO 12345 --- [main] com.example.MyApp : Started MyApp in 2.345 seconds (process running for 2.8)
4.6.2 StartupInfoLogger 的延迟日志
// core/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java
void logStarting(Log applicationLog) {
    applicationLog.info(LogMessage.of(this::getStartingMessage));     // ★ 延迟求值
    applicationLog.debug(LogMessage.of(this::getRunningMessage));
}

void logStarted(Log applicationLog, Startup startup) {
    if (applicationLog.isInfoEnabled()) {                              // ★ 显式检查
        applicationLog.info(getStartedMessage(startup));
    }
}

LogMessage.of(Supplier) 只有当日志级别真的会输出时才调用 Supplier。这是必要的优化:拼装启动信息要读 System.getProperty、ApplicationHome(涉及文件系统)、ApplicationPid,成本不低。

4.6.3 启动信息的组成
private CharSequence getStartingMessage() {
    StringBuilder message = new StringBuilder();
    message.append("Starting");
    appendAotMode(message);              // "AOT-processed"(如果启用 AOT)
    appendApplicationName(message);      // 主类短名
    appendApplicationVersion(message);   // spring.application.version
    appendJavaVersion(message);          // using Java 25
    appendPid(message);                  // with PID 12345
    appendContext(message);              // (/path started by user in /dir)
    return message;
}

版本号取自 source.getPackage().getImplementationVersion(),即 jar 的 MANIFEST.MF 里的 Implementation-Version。

在 IDE 里直接运行时(classes 目录而非 jar),getImplementationVersion() 返回 null,版本号不显示。 这是"为什么本地跑没有版本号"的原因。

Running with Spring Boot vx, Spring vy 这行是 DEBUG 级别,需要开 debug 才看得到——它是排查"我到底跑的哪个版本"的最快手段。

4.6.4 spring.application.pid 从哪来
private void appendPid(StringBuilder message) {
    append(message, "with PID ", () -> this.environment.getProperty("spring.application.pid"));
}

这个属性由 ApplicationInfoPropertySource 提供:

// core/spring-boot/src/main/java/org/springframework/boot/ApplicationInfoPropertySource.java
class ApplicationInfoPropertySource extends MapPropertySource implements PropertySourceInfo {

    static final String NAME = "applicationInfo";

    private static Map<String, Object> getProperties(@Nullable String applicationVersion) {
        Map<String, Object> result = new HashMap<>();
        if (StringUtils.hasText(applicationVersion)) {
            result.put("spring.application.version", applicationVersion);
        }
        ApplicationPid applicationPid = new ApplicationPid();
        Long pid = applicationPid.toLong();
        if (pid != null) {
            result.put("spring.application.pid", pid);
        }
        return result;
    }

    private static @Nullable String readVersion(@Nullable Class<?> applicationClass) {
        Package sourcePackage = (applicationClass != null) ? applicationClass.getPackage() : null;
        return (sourcePackage != null) ? sourcePackage.getPackageName() != null
                ? sourcePackage.getImplementationVersion() : null : null;
    }

    @Override
    public boolean isImmutable() {
        return true;
    }

    static void moveToEnd(ConfigurableEnvironment environment) {
        MutablePropertySources propertySources = environment.getPropertySources();
        PropertySource<?> propertySource = propertySources.remove(NAME);
        if (propertySource != null) {
            propertySources.addLast(propertySource);
        }
    }
}

三个要点:

  1. isImmutable() == true —— 来自 PropertySourceInfo 接口(4.x 新增)。标记内容永不变,ConfigData 等机制可据此做缓存/优化。

  2. moveToEnd —— 在 prepareEnvironment 里调用。因为 configurePropertySources 里先 addLast 了它,但之后 environmentPrepared 阶段 ConfigData 会插入很多 PropertySource(在末尾之前)。挪到真正的末尾才能保证最低优先级——用户可以覆盖 spring.application.version。

  3. ApplicationPid(org.springframework.boot.system.ApplicationPid)通过解析 RuntimeMXBean.getName()(通常格式 pid@hostname)或 JNA/POSIX 获取 PID。某些受限容器里可能返回 null,此时日志里就没有 PID。

4.6.5 什么时候关闭它
  • 日志采集严格的场景:启动信息是固定的非结构化文本,某些日志规范要求全部结构化
  • 性能极端敏感:虽然是延迟求值,但仍有成本(可忽略量级)
  • 隐藏信息:PID、用户目录、jar 路径属于轻度敏感信息

注意:log-startup-info=false 不会关闭 Banner(那是 banner-mode),也不会关闭其他应用的日志。它只控制这两行 Spring Boot 自己打的启动/启动完成信息。


4.7 spring.main.register-shutdown-hook

元数据:

{
  "name": "spring.main.register-shutdown-hook",
  "type": "java.lang.Boolean",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Whether the application should have a shutdown hook registered.",
  "defaultValue": true
}

这是 9 个属性里实现最复杂的一个。它有两个使用点,且分别在不同阶段。

4.7.1 使用点 1:run() 的最开始
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:304
public ConfigurableApplicationContext run(String... args) {
    Startup startup = Startup.create();
    if (this.properties.isRegisterShutdownHook()) {
        SpringApplication.shutdownHook.enableShutdownHookAddition();
    }
    // ...
}

注意:这里只是"允许添加",还没有真正注册 JVM shutdown hook。

4.7.2 使用点 2:refreshContext() 之前
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:441
private void refreshContext(ConfigurableApplicationContext context) {
    if (this.properties.isRegisterShutdownHook()) {
        shutdownHook.registerApplicationContext(context);
    }
    refresh(context);
}
4.7.3 为什么要分两步?

看 SpringApplicationShutdownHook 的实现:

// core/spring-boot/src/main/java/org/springframework/boot/SpringApplicationShutdownHook.java
class SpringApplicationShutdownHook implements Runnable {

    private static final int SLEEP = 50;
    private static final long TIMEOUT = TimeUnit.MINUTES.toMillis(10);

    private final Handlers handlers = new Handlers();
    private final Set<ConfigurableApplicationContext> contexts = new LinkedHashSet<>();
    private final Set<ConfigurableApplicationContext> closedContexts = Collections.newSetFromMap(new WeakHashMap<>());
    private final AtomicBoolean shutdownHookAdded = new AtomicBoolean();
    private volatile boolean shutdownHookAdditionEnabled;
    private boolean inProgress;

    void enableShutdownHookAddition() {
        this.shutdownHookAdditionEnabled = true;
    }

    void registerApplicationContext(ConfigurableApplicationContext context) {
        addRuntimeShutdownHookIfNecessary();
        synchronized (SpringApplicationShutdownHook.class) {
            assertNotInProgress();
            context.addApplicationListener(this.contextCloseListener);
            this.contexts.add(context);
        }
    }

    private void addRuntimeShutdownHookIfNecessary() {
        if (this.shutdownHookAdditionEnabled && this.shutdownHookAdded.compareAndSet(false, true)) {
            Runtime.getRuntime().addShutdownHook(new Thread(this, "SpringApplicationShutdownHook"));
        }
    }
    // ...
}

两步设计的理由:

  1. "先启用、后注册" —— register-shutdown-hook=false 时 shutdownHookAdditionEnabled 永远为 false,一个 JVM hook 都不加;若用户后续调 getShutdownHandlers().add(...),Handlers.add() 也会调 addRuntimeShutdownHookIfNecessary() 补上 hook(前提是开关已开)。
  2. AtomicBoolean.compareAndSet(false, true) —— 保证只注册一次 JVM shutdown hook,即便有多个 ApplicationContext(父子/兄弟上下文)。
  3. JVM 全局单例 —— SpringApplication.shutdownHook 是 static final,同进程内所有 SpringApplication 共享。这是必要的:JVM shutdown hook 是进程级的。
4.7.4 hook 执行时的行为
@Override
public void run() {
    Set<ConfigurableApplicationContext> contexts;
    Set<ConfigurableApplicationContext> closedContexts;
    List<Handler> handlers;
    synchronized (SpringApplicationShutdownHook.class) {
        this.inProgress = true;
        contexts = new LinkedHashSet<>(this.contexts);
        closedContexts = new LinkedHashSet<>(this.closedContexts);
        handlers = new ArrayList<>(this.handlers.getActions());
        Collections.reverse(handlers);
    }
    contexts.forEach(this::closeAndWait);
    closedContexts.forEach(this::closeAndWait);
    handlers.forEach(Handler::run);
}

执行顺序:

  1. 关闭所有活跃的上下文
  2. 关闭所有已标记关闭的上下文(closedContexts,弱引用集合)
  3. 逆序执行用户注册的 handler(Collections.reverse(handlers))

为什么 handler 要逆序? 这是"栈式"语义:先注册的 handler 后执行,符合"资源申请的逆序释放"原则。如果 handler A 依赖 handler B 管理的资源,通常 A 在 B 之后注册,逆序保证 B 先清理、A 后清理... 实际上反过来。总之,逆序是为了让"最后注册的(最外层的)先执行"。

4.7.5 closeAndWait 的 10 分钟超时
private void closeAndWait(ConfigurableApplicationContext context) {
    if (!context.isActive()) {
        return;
    }
    context.close();
    // 轮询 isActive(),SLEEP=50ms,TIMEOUT=10 分钟
}

三个关键:

  1. context.close() 返回不代表真的 inactive —— 源码注释:"We can't assume that just because the close method returns that the context is actually inactive. It could be that another thread is still in the process of disposing beans."
  2. TIMEOUT = TimeUnit.MINUTES.toMillis(10) —— 每个上下文最多阻塞 10 分钟。
  3. SLEEP = 50ms 轮询,CPU 友好。

生产提示:docker stop 默认 10 秒后 SIGKILL。若上下文关闭超过 10 秒,优雅关闭形同虚设。要么调大 --stop-timeout,要么确保 @PreDestroy / DisposableBean 快速返回。这是"优雅关闭不生效"最常见的原因。

4.7.6 closedContexts 弱引用集合的巧思
private final Set<ConfigurableApplicationContext> closedContexts =
    Collections.newSetFromMap(new WeakHashMap<>());

// ApplicationContextClosedListener.onApplicationEvent:
//   contexts.remove(context);  closedContexts.add(context);

背景:ContextClosedEvent 在 close() 的开始就发布,此时上下文可能仍 active(其他线程正在销毁 Bean)。

  • 直接从 contexts 移除 → 没人再等它关闭完成
  • 留在 contexts 里 → run() 会重复 close()

解法:移到第三个集合 closedContexts,run() 也对它调 closeAndWait(开头 if (!context.isActive()) return; 会跳过已关闭的)。

而 closedContexts 用 WeakHashMap 支撑的 Set:一旦 close() 完成且上下文不再被别处引用,它可以被 GC——避免因 shutdown hook 持有强引用导致 ApplicationContext 内存泄漏。ApplicationContext 是重量级对象(持有 BeanFactory 与全部单例),在测试/嵌入式场景反复创建关闭时,强引用会造成严重泄漏。

4.7.7 assertNotInProgress 与并发保护
private void assertNotInProgress() {
    Assert.state(!SpringApplicationShutdownHook.this.inProgress, "Shutdown in progress");
}

在 registerApplicationContext 与 Handlers.add/remove 中调用:JVM 正在关闭时不能再注册上下文或 handler,抛 IllegalStateException。所有修改操作都在 synchronized (SpringApplicationShutdownHook.class) 内——锁类对象,因为它是 JVM 全局单例。

4.7.8 Handlers 与 Handler record 的 identity 语义
private final class Handlers implements SpringApplicationShutdownHandlers, Runnable {

    private final Set<Handler> actions = new LinkedHashSet<>();

    @Override
    public void add(Runnable action) {
        Assert.notNull(action, "'action' must not be null");
        addRuntimeShutdownHookIfNecessary();          // ★ 补注册 JVM hook
        synchronized (SpringApplicationShutdownHook.class) {
            assertNotInProgress();
            this.actions.add(new Handler(action));
        }
    }
    // ...
}

record Handler(Runnable runnable) {

    @Override
    public int hashCode() {
        return System.identityHashCode(this.runnable);
    }

    @Override
    public boolean equals(Object obj) {
        // ... 省略 null / class 检查
        return this.runnable == ((Handler) obj).runnable;      // ★ 身份比较
    }
}

Handler 是 record,但故意覆盖 equals/hashCode 使用对象身份(==)语义。因为 Runnable 通常是 lambda,两个"内容相同"的 lambda 是不同对象;用身份语义保证 remove(action) 只移除那一个特定实例。

注意 Handlers.add() 里调用了 addRuntimeShutdownHookIfNecessary()——这意味着即使 register-shutdown-hook=false,只要你调用了 getShutdownHandlers().add(...),JVM hook 也不会被添加(因为 shutdownHookAdditionEnabled 仍为 false)。这是"两步开关"设计的直接后果。

值得学习的设计:需要"集合元素按身份去重"时,System.identityHashCode + == 重写 equals/hashCode 是一种清晰写法(另一种是用 Collections.newSetFromMap(new IdentityHashMap<>()))。

4.7.9 什么时候关闭它
SpringApplication app = new SpringApplication(MyConfig.class);
app.setRegisterShutdownHook(false);

或者 --spring.main.register-shutdown-hook=false。

典型场景:

  1. 测试 —— JUnit 测试里如果每个测试都注册 JVM shutdown hook,会累积大量 hook(JVM 不会移除),导致测试套件结束时卡住或内存问题。Spring Boot 的测试框架内部通常会处理这个。
  2. 嵌入式场景 —— 你的应用是某个更大进程的插件,JVM 关闭由宿主管理。
  3. Web 容器部署(WAR) —— 老式部署到外部 Tomcat 时,容器的生命周期管理已经覆盖了,Boot 自己注册 hook 会重复。

4.8 spring.main.keep-alive

元数据:

{
  "name": "spring.main.keep-alive",
  "type": "java.lang.Boolean",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Whether to keep the application alive even if there are no more non-daemon threads.",
  "defaultValue": false
}
4.8.1 生效路径
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:408
if (this.properties.isKeepAlive()) {
    context.addApplicationListener(new KeepAlive());
}
4.8.2 KeepAlive 的实现
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:1699
private static final class KeepAlive implements ApplicationListener<ApplicationContextEvent> {

    private final AtomicReference<@Nullable Thread> thread = new AtomicReference<>();

    @Override
    public void onApplicationEvent(ApplicationContextEvent event) {
        if (event instanceof ContextRefreshedEvent) {
            startKeepAliveThread();
        }
        else if (event instanceof ContextClosedEvent) {
            stopKeepAliveThread();
        }
    }

    private void startKeepAliveThread() {
        Thread thread = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(Long.MAX_VALUE);
                }
                catch (InterruptedException ex) {
                    break;
                }
            }
        });
        if (this.thread.compareAndSet(null, thread)) {
            thread.setDaemon(false);          // ★ 非守护线程
            thread.setName("keep-alive");
            thread.start();
        }
    }
    // ...
}
4.8.3 JVM 退出的基本规则(理解 keep-alive 的前提)

JVM 在所有非守护(non-daemon)线程结束时退出。

内嵌 Web 服务器(Tomcat/Jetty/Netty)会创建非守护线程接受连接——这就是 main() 返回后 Spring Boot 应用不退出原因。但以下情况 JVM 会立即退出:批处理跑完;服务器启动失败或未配置;gRPC-only 且线程是 daemon;所有业务线程都是 daemon。

keep-alive=true 的做法:创建名为 keep-alive 的非守护线程,执行 Thread.sleep(Long.MAX_VALUE)(≈2.92 亿年,即"永远睡眠",比 while(true){} 更省 CPU)。它被 interrupt() 时抛 InterruptedException → break → 线程结束:

private void stopKeepAliveThread() {
    Thread thread = this.thread.getAndSet(null);
    if (thread != null) {
        thread.interrupt();
    }
}
4.8.4 生命周期与并发保护
ContextRefreshedEvent  → 启动 keep-alive 线程(非守护)
ContextClosedEvent     → interrupt 之,线程结束

AtomicReference + compareAndSet(null, thread) 保证只创建一个线程(即使多次收到刷新事件);getAndSet(null) 原子取出并清空,避免"关闭后又有新事件进来启动线程"。

它监听父类 ApplicationContextEvent 再用 instanceof 判断具体类型——这是 Spring 4.2+ 推荐的"单一监听器处理多事件"写法。

4.8.7 什么时候用它

✓ 合理:

  • 纯消息消费者(Kafka/Rabbit listener)且容器线程是 daemon
  • gRPC-only 服务
  • 需要在 main() 返回后保持运行的命令行工具
  • 调试:想让应用"停在那里"以便连上调试器

✗ 不合理:

  • 常规 web 应用(Tomcat 线程已经是非守护的,不需要)
  • 批处理任务(跑完应该退出)
  • 测试(会导致测试套件挂起!)

反直觉结论 #6:spring.main.keep-alive=true 会让你的测试永远不结束。如果在测试里误开,表现为"测试跑完了但 JVM 不退出"。这时去看线程 dump,会有一个名为 keep-alive 的非守护线程。

4.8.8 与 register-shutdown-hook 的交互

如果同时设置了:

  • keep-alive=true(非守护线程永不结束)
  • register-shutdown-hook=true

那么 JVM 不会自己退出(因为 keep-alive 线程),只能靠 SIGTERM/SIGINT。收到信号后:

  1. shutdown hook 运行 → 关闭 ApplicationContext
  2. ContextClosedEvent 触发 → stopKeepAliveThread() → interrupt
  3. keep-alive 线程结束
  4. JVM 退出

顺序是对的:shutdown hook 线程和 keep-alive 线程是并行的,但 hook 关闭上下文后 keep-alive 线程才会被 interrupt。这里存在一个理论上的竞态:如果 hook 线程因为 closeAndWait 卡住超过 10 分钟,keep-alive 线程可能还在跑。实践中不用担心。


4.9 spring.main.sources

元数据:

{
  "name": "spring.main.sources",
  "type": "java.util.Set<java.lang.String>",
  "sourceType": "org.springframework.boot.SpringApplication",
  "description": "Sources (class names, package names, or XML resource locations) to include in the ApplicationContext."
}
4.9.1 生效路径
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:1194
protected Set<Object> getAllSources() {
    Set<Object> allSources = new LinkedHashSet<>();
    if (!CollectionUtils.isEmpty(this.primarySources)) {
        allSources.addAll(this.primarySources);
    }
    if (!CollectionUtils.isEmpty(this.properties.getSources())) {
        allSources.addAll(this.properties.getSources());
    }
    // ...
    return allSources;
}

(注意 getSources() 返回 Set<String>,而 allSources 是 Set<Object>——因为 primarySources 是 Set<Class<?>>。)

然后在 prepareContext 里:

// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:414
if (!AotDetector.useGeneratedArtifacts()) {
    // Load the sources
    Set<Object> sources = getAllSources();
    Assert.state(!ObjectUtils.isEmpty(sources), "No sources defined");
    load(context, sources.toArray(new Object[0]));
}
4.9.2 三种 source 类型

类级 Javadoc 说明得很清楚:

/**
 * {@link SpringApplication}s can read beans from a variety of different sources. It is
 * generally recommended that a single {@code @Configuration} class is used to bootstrap
 * your application, however, you may also set {@link #getSources() sources} from:
 * <ul>
 * <li>The fully qualified class name to be loaded by
 * {@link AnnotatedBeanDefinitionReader}</li>
 * <li>The location of an XML resource to be loaded by {@link XmlBeanDefinitionReader}, or
 * a groovy script to be loaded by {@link GroovyBeanDefinitionReader}</li>
 * <li>The name of a package to be scanned by {@link ClassPathBeanDefinitionScanner}</li>
 * </ul>
 */
类型例子处理方式
全限定类名com.example.MyConfigAnnotatedBeanDefinitionReader
XML 资源位置classpath:beans.xmlXmlBeanDefinitionReader
Groovy 脚本beans.groovyGroovyBeanDefinitionReader(4.x 是否仍支持待核实)
包名com.example.serviceClassPathBeanDefinitionScanner
4.9.3 BeanDefinitionLoader 的判定逻辑

load() 委托给 BeanDefinitionLoader(第 12 章详述),按类型分派:

Class              → AnnotatedBeanDefinitionReader.register(...)
Resource           → XmlBeanDefinitionReader.loadBeanDefinitions(...)
Package            → ClassPathBeanDefinitionScanner.scan(...)
CharSequence(String) → 启发式:Class.forName 成功→当 Class;Resource.exists→当 Resource;否则当 Package 扫描

因为 spring.main.sources 绑定的是 Set<String>,它走的是 String 启发式路径;而 SpringApplication(Class...) 传的是 Class 对象,走直接路径。两者行为等价,但 String 路径多一次 Class.forName——类名写错会得到"类找不到"异常,发生在 source 加载阶段。

4.9.4 与 AOT 的冲突
if (!AotDetector.useGeneratedArtifacts()) {
    // Load the sources
    Set<Object> sources = getAllSources();
    Assert.state(!ObjectUtils.isEmpty(sources), "No sources defined");
    load(context, sources.toArray(new Object[0]));
}

在 AOT 模式下,spring.main.sources 完全不生效。

这是一个必须理解的限制。原因:

  1. AOT 在构建期运行应用,扫描 classpath,生成 Bean 定义的 Java 源码
  2. 构建期无法预知运行期会通过 spring.main.sources 传入什么
  3. 所以 AOT 模式下,sources 必须在构建期就确定(即 SpringApplication(Class...) 的 primary sources)

反直觉结论 #7:spring.main.sources 在 native image 下是无效的。如果你依赖它来动态加载配置类,native 构建会静默忽略。测试用例 SimpleMainTests 里用 --spring.main.sources=... 只在 JVM 模式下有意义。

4.9.5 典型用法与风险
java -jar app.jar --spring.main.sources=com.example.ExtraConfig
# 或 CSV:spring.main.sources=com.example.A,com.example.B

(Set<String> 绑定支持逗号分隔,这是 Binder 对集合类型的标准处理。)

风险:

  1. 类名拼写错误只在加载时炸,无提前校验
  2. AOT 下失效(见 4.9.4)
  3. 破坏"单一入口"原则,Bean 来源分散难追踪
  4. 包名拼错会静默扫描 0 个类 —— ClassPathBeanDefinitionScanner 对不存在的包不报错。这是最危险的:你以为加了 source,实际什么都没加载,且无任何警告。

实践建议:除非有明确需求(插件化、命令行动态加载),否则不要用。优先用 @ComponentScan、@Import、AutoConfiguration.imports。


第五部分 · 三个特殊议题:environment-prefix 禁令、cloud-platform 幽灵属性、AOT 反射登记


5.1 spring.main.environment-prefix:一个被明确禁止通过配置设置的属性

5.1.1 那条断言
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:359
Assert.state(!environment.containsProperty("spring.main.environment-prefix"),
        "Environment prefix cannot be set via properties.");

这是整个 spring.main.* 体系里唯一一条显式禁止的属性。而且它用的措辞是 "cannot be set"(不能),不是 "should not be set"(不应该)。

对应的测试:

// core/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java:1305
@Test
void settingEnvironmentPrefixViaPropertiesThrowsException() {
    assertThatIllegalStateException()
        .isThrownBy(() -> new SpringApplication().run("--spring.main.environment-prefix=my"));
}
5.1.2 environment-prefix 是什么
// SpringApplication.java:241 / 1216 / 1225
private @Nullable String environmentPrefix;
public @Nullable String getEnvironmentPrefix() { return this.environmentPrefix; }
public void setEnvironmentPrefix(String environmentPrefix) { this.environmentPrefix = environmentPrefix; }

它不在 ApplicationProperties 里——是 SpringApplication 自己的字段,只能编程设置。

作用是给系统环境变量的读取加前缀过滤。唯一消费点:

// core/spring-boot/src/main/java/org/springframework/boot/support/SystemEnvironmentPropertySourceEnvironmentPostProcessor.java
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
    postProcessEnvironment(environment, application.getEnvironmentPrefix());
}
// → 用 OriginAwareSystemEnvironmentPropertySource 替换 systemEnvironment PropertySource

private @Nullable String determinePrefix(@Nullable String environmentPrefix) {
    if (!StringUtils.hasText(environmentPrefix)) {
        return null;
    }
    if (environmentPrefix.endsWith(".") || environmentPrefix.endsWith("_") || environmentPrefix.endsWith("-")) {
        return environmentPrefix.substring(0, environmentPrefix.length() - 1);   // 去掉尾部分隔符
    }
    return environmentPrefix;
}

用途:多应用共享进程环境、或多个 Spring Boot 应用跑在同一容器时,用前缀隔离环境变量。典型场景是 SpringApplicationBuilder 构建父子/兄弟上下文时给不同子应用不同前缀。

5.1.3 为什么它不能通过属性设置——一个真正的引导顺序约束

这是本节的核心。理由不是"设计者不喜欢",而是物理上不可能。

追溯时间线:

① SpringApplication 构造
   environmentPrefix = null

② run() → prepareEnvironment()
   ├─ getOrCreateEnvironment()
   ├─ configureEnvironment()          ← 系统环境变量的 PropertySource 在这里被创建
   ├─ ConfigurationPropertySources.attach()
   ├─ listeners.environmentPrepared()  ← ★ EnvironmentPostProcessor 在这里执行!
   │                                     ★ SystemEnvironmentPropertySourceEnvironmentPostProcessor
   │                                       在这里读 application.getEnvironmentPrefix()
   ├─ ...
   ├─ Assert.state(!environment.containsProperty("spring.main.environment-prefix"), ...)
   └─ bindToSpringApplication()        ← spring.main.* 在这里才绑定

关键:SystemEnvironmentPropertySourceEnvironmentPostProcessor 在 environmentPrepared 阶段运行,它需要 application.getEnvironmentPrefix() 的最终值。而 spring.main.* 的绑定发生在这之后。

如果允许 spring.main.environment-prefix=my,那么:

  1. environmentPrepared 阶段,前缀还是 null(还没绑定)
  2. OriginAwareSystemEnvironmentPropertySource 用 null 前缀创建
  3. 之后绑定 spring.main.environment-prefix=my,environmentPrefix 字段变成 "my"
  4. 但 systemEnvironment PropertySource 已经用 null 前缀建好了 —— 前缀不生效

结果就是一个"静默失效"的配置:用户设置了,但没用,且没有任何提示。这比直接抛异常糟糕得多。

Boot 的选择:在绑定之后立刻断言,如果发现了这个属性就抛异常。

Assert.state(!environment.containsProperty("spring.main.environment-prefix"),
        "Environment prefix cannot be set via properties.");

注意这个断言的位置——在 bindToSpringApplication() 之前。这样无论绑定是否会真正成功,只要环境里有这个属性就报错。错误信息里也明确说了 "cannot be set via properties",并暗示了正确做法(用 setEnvironmentPrefix(...))。

反直觉结论 #8:spring.main.* 这个前缀并不意味着"所有 spring.main.X 都可以用配置文件设置"。environment-prefix 是一个例外——它是 SpringApplication 的引导元数据(bootstrap metadata),而不是应用配置(application configuration)。区分这两者是理解 Spring Boot 启动时序的关键。

判断标准很简单:如果一个值在 Environment 完全就绪之前就需要被使用,它就不能来自 Environment。 这类值包括:

  • environmentPrefix(EnvironmentPostProcessor 需要)
  • 各种 SpringApplication 的 setter(environment、resourceLoader、applicationContextFactory...)
  • defaultProperties 的一部分
5.1.4 与 spring.main.* 其他属性的对比
属性何时被消费能否通过配置设置
environment-prefixenvironmentPrepared 阶段(绑定前)❌ 不能(有断言)
web-application-typegetOrCreateEnvironment(绑定前用推断值)+ createApplicationContext(绑定后)✅ 能(有纠正机制)
banner-modeprintBanner(绑定后)✅ 能
lazy-initializationprepareContext(绑定后)✅ 能
其余绑定后✅ 能

web-application-type 是唯一一个"跨越绑定点"的属性——它在绑定前被用了一次(造 Environment),绑定后被用了第二次(造 ApplicationContext)。这解释了 4.1.3 节讲的"先猜后纠"机制为什么只针对它,而不是普遍存在。


5.2 spring.main.cloud-platform:一个不在 ApplicationProperties 里的"幽灵属性"

5.2.1 现象

元数据里有它:

{
  "name": "spring.main.cloud-platform",
  "type": "org.springframework.boot.cloud.CloudPlatform",
  "description": "Override the Cloud Platform auto-detection."
}

但 ApplicationProperties 类里没有 cloudPlatform 字段。

这意味着:spring.main.cloud-platform 不会被 bindToSpringApplication() 绑定。它是一个"借用 spring.main 命名空间、但由别的代码自己读取"的属性。

5.2.2 谁在读它
// core/spring-boot/src/main/java/org/springframework/boot/cloud/CloudPlatform.java
private static final String PROPERTY_NAME = "spring.main.cloud-platform";

public boolean isActive(Environment environment) {
    String platformProperty = environment.getProperty(PROPERTY_NAME);
    return isEnforced(platformProperty) || (platformProperty == null && isDetected(environment));
}

public boolean isEnforced(Environment environment) {
    return isEnforced(environment.getProperty(PROPERTY_NAME));
}

public boolean isEnforced(Binder binder) {
    return isEnforced(binder.bind(PROPERTY_NAME, String.class).orElse(null));
}

private boolean isEnforced(@Nullable String platform) {
    return name().equalsIgnoreCase(platform);
}

public static @Nullable CloudPlatform getActive(@Nullable Environment environment) {
    if (environment != null) {
        for (CloudPlatform cloudPlatform : values()) {
            if (cloudPlatform.isActive(environment)) {
                return cloudPlatform;
            }
        }
    }
    return null;
}

直接用 environment.getProperty(...) 读字符串,不走 Binder,不走 ApplicationProperties。

5.2.3 语义:isEnforced vs isDetected
return isEnforced(platformProperty) || (platformProperty == null && isDetected(environment));

这句代码的信息量很大:

  • 如果 spring.main.cloud-platform 被设置了且等于当前枚举的名字(忽略大小写)→ 强制激活
  • 如果 spring.main.cloud-platform 没设置 → 走自动检测
  • 如果设置了但不等于当前枚举名 → 既不强制也不检测,即不激活

注意第二个分支的条件是 platformProperty == null,不是 !isEnforced(...)。所以:

如果 spring.main.cloud-platform=kubernetes,那么 HEROKU 这个枚举的 isActive 会返回 false(因为 platformProperty != null,且 isEnforced("kubernetes") 对 HEROKU 为 false)——即使环境里真的有 DYNO 变量。

也就是说:一旦显式设置了 cloud-platform,自动检测就完全关闭。这是"显式覆盖"语义,不是"显式补充"。

5.2.4 各平台的检测条件
平台检测条件备注
NONE永不检测(isDetected 返回 false)用于关闭误判
CLOUD_FOUNDRYVCAP_APPLICATION 或 VCAP_SERVICES 存在
HEROKUDYNO 存在
SAPHC_LANDSCAPE 存在
NOMADNOMAD_ALLOC_ID 存在@since 3.1.0
KUBERNETES见下两级检测
AWS_ECSAWS_EXECUTION_ENV 以 AWS_ECS 开头

Kubernetes 的两级检测:

private boolean isAutoDetected(ConfigurableEnvironment environment) {
    PropertySource<?> environmentPropertySource = environment.getPropertySources()
        .get(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME);
    if (environmentPropertySource != null) {
        if (environmentPropertySource.containsProperty(KUBERNETES_SERVICE_HOST)
                && environmentPropertySource.containsProperty(KUBERNETES_SERVICE_PORT)) {
            return true;                                     // ① 直接匹配
        }
        if (environmentPropertySource instanceof EnumerablePropertySource<?> enumerablePropertySource) {
            return isAutoDetected(enumerablePropertySource);  // ② 模糊匹配
        }
    }
    return false;
}

第二级遍历所有环境变量,找形如 XXX_SERVICE_HOST / XXX_SERVICE_PORT 的成对变量——这是 Kubernetes 为每个 Service 注入的环境变量命名约定。

实践提示:这个"模糊匹配"可能误判。任何恰好有 FOO_SERVICE_HOST + FOO_SERVICE_PORT 环境变量的环境(比如 docker-compose 里手工设置)都会被判定为 Kubernetes。要禁用,设置 spring.main.cloud-platform=NONE。

5.2.5 它影响什么

CloudPlatform 的主要消费者是 ConfigDataActivationContext:

// core/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataActivationContext.java
private final @Nullable CloudPlatform cloudPlatform;

ConfigDataActivationContext(Environment environment, Binder binder) {
    // ...
    this.cloudPlatform = deduceCloudPlatform(environment, binder);
    // ...
}

进而在 ConfigDataProperties 里用于条件激活:

// core/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataProperties.java:126
CloudPlatform cloudPlatform = activationContext.getCloudPlatform();
boolean activate = isActive((cloudPlatform != null) ? cloudPlatform : CloudPlatform.NONE);
// ...
private boolean isActive(CloudPlatform cloudPlatform) {
    return this.onCloudPlatform == null || this.onCloudPlatform == cloudPlatform;
}

这对应的是 spring.config.activate.on-cloud-platform 这个配置(第 10 章详述):

spring:
  config:
    activate:
      on-cloud-platform: kubernetes
---
spring:
  datasource:
    url: jdbc:postgresql://...

用途:在 Kubernetes 里加载一套配置,在本地加载另一套。

5.2.6 为什么它不在 ApplicationProperties 里

推测(源码未明确说明,标注为推测):

  1. 它需要 Binder-free 的读取 —— CloudPlatform.isEnforced(Binder) 和 isEnforced(Environment) 都提供了,说明它在不同上下文里被读。有些地方(如 ConfigData 激活判断)发生在 ApplicationProperties 绑定之前或与之并行。
  2. 它是"环境事实"而非"应用配置" —— cloud-platform 描述的是"我在哪跑",不是"我要怎么跑"。语义上更接近 Environment 的固有属性。
  3. 历史遗留 —— CloudPlatform 自 1.3.0 就有,ApplicationProperties 是 4.x 才抽出来的。抽出时可能有意把它留在原地(因为它不需要 setter/hints)。

反直觉结论 #9:spring.main.cloud-platform 享受了 spring.main.* 的命名空间,但不享受 ApplicationProperties 的绑定机制、AOT hints、类型安全。它用 environment.getProperty(String) 读原始字符串,类型错误(比如 spring.main.cloud-platform=k8s)会静默不生效(不会抛异常,只是所有枚举都不匹配,最终 getActive 返回 null)。

可用的值只有:none、cloud_foundry/cloud-foundry(见下)、heroku、sap、nomad、kubernetes、aws_ecs(以及各自的忽略大小写形式)。

注意枚举名的连字符/下划线:Binder 的宽松转换会把 cloud-foundry 映射成 CLOUD_FOUNDRY,但这里不走 Binder(environment.getProperty 返回原始字符串,name().equalsIgnoreCase(platform) 比较的是 "CLOUD_FOUNDRY" 与输入)。所以 spring.main.cloud-platform=cloud-foundry 不会生效,必须写 CLOUD_FOUNDRY(或 cloud_foundry?——equalsIgnoreCase("CLOUD_FOUNDRY", "cloud_foundry") 为 false,因为下划线≠连字符)。

安全写法:spring.main.cloud-platform=CLOUD_FOUNDRY(直接用枚举名,全大写)。

⚠️ 待核实:CloudPlatform 的 isEnforced(String) 用的是 name().equalsIgnoreCase(platform),name() 返回 CLOUD_FOUNDRY(带下划线)。因此输入必须规范化为下划线形式。是否 ConfigData 路径(isEnforced(Binder),走 bind(PROPERTY_NAME, String.class))会做宽松转换?bind(..., String.class) 不做枚举转换,返回原始字符串。所以两条路径都是原始字符串比较。结论:用下划线或全大写枚举名。


5.3 AOT 反射登记:BindableRuntimeHintsRegistrar 深潜

5.3.1 ApplicationProperties 的 hints 声明
static class ApplicationPropertiesRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
        BindableRuntimeHintsRegistrar.forTypes(ApplicationProperties.class).registerHints(hints, classLoader);
    }
}
5.3.2 BindableRuntimeHintsRegistrar.Processor 的处理流程
void process(ReflectionHints hints) {
    if (this.seen.contains(this.type)) {
        return;                                     // 防止循环
    }
    this.seen.add(this.type);
    handleConstructor(hints);
    if (this.bindConstructor != null) {
        handleValueObjectProperties(hints);          // 构造器绑定路径
    }
    else if (this.bean != null && !this.bean.getProperties().isEmpty()) {
        handleJavaBeanProperties(hints);             // JavaBean 绑定路径
    }
}

对 ApplicationProperties:

(1)handleConstructor —— 找到无参构造器,登记 ExecutableMode.INVOKE(允许反射调用)。

(2)handleJavaBeanProperties(因为 bindConstructor 为 null,走这条分支——测试断言 setBannerMode 被登记,印证了这一点)

private void handleJavaBeanProperties(ReflectionHints hints) {
    Map<String, BeanProperty> properties = this.bean.getProperties();
    properties.forEach((name, property) -> {
        Method getter = property.getGetter();
        if (getter != null) {
            hints.registerMethod(getter, ExecutableMode.INVOKE);
        }
        Method setter = property.getSetter();
        if (setter != null) {
            hints.registerMethod(setter, ExecutableMode.INVOKE);
        }
        Field field = property.getField();
        if (field != null) {
            hints.registerField(field);
        }
        handleProperty(hints, name, property.getType());
    });
}

(3)为什么不登记 getBannerMode

回顾 JavaBeanBinder.addProperties:

for (Method method : methods) {
    addMethodIfPossible(method, "is", 0, BeanProperty::addGetter);
}
for (Method method : methods) {
    addMethodIfPossible(method, "get", 0, BeanProperty::addGetter);
}
for (Method method : methods) {
    addMethodIfPossible(method, "set", 1, BeanProperty::addSetter);
}

private void addMethodIfPossible(@Nullable Method method, String prefix, int parameterCount,
        BiConsumer<BeanProperty, Method> consumer) {
    if (method != null && method.getParameterCount() == parameterCount
            && method.getName().startsWith(prefix) && method.getName().length() > prefix.length()) {
        String propertyName = Introspector.decapitalize(method.getName().substring(prefix.length()));
        consumer.accept(this.properties.computeIfAbsent(propertyName, this::getBeanProperty), method);
    }
}

getBannerMode(Environment) 有 1 个参数,parameterCount == 0 不成立 → 不被识别为 getter。

setBannerMode(Mode) 有 1 个参数,前缀 set → 被识别为 setter。

所以 bannerMode 这个 BeanProperty 是 getter == null, setter != null 的"只写属性"。

测试完美印证:

assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(ApplicationProperties.class, "setBannerMode"))
    .accepts(hints);
assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(ApplicationProperties.class, "getBannerMode"))
    .rejects(hints);          // ← ★ 被拒绝

反直觉结论 #10:bannerMode 在绑定层面是一个只写(write-only)属性。JavaBeanBinder 无法读取它的当前值(因为 getValue() 在 getter == null 时返回 null),只能写入。这对 Bindable.ofInstance 的"只覆盖已配置项"语义有影响——但因为 Binder 在 JavaBean 模式下是"逐属性尝试绑定",写入不需要先读,所以行为不受影响。

(4)类型递归

private void handleProperty(ReflectionHints hints, String propertyName, ResolvableType propertyType) {
    Class<?> propertyClass = propertyType.resolve();
    if (propertyClass == null) {
        return;
    }
    if (propertyClass.equals(this.type)) {
        return;                       // 防止无限递归
    }
    Class<?> componentType = getComponentClass(propertyType);
    if (componentType != null) {
        if (!isJavaType(componentType)) {
            processNested(componentType, hints);
        }
    }
    else if (isNestedType(propertyName, propertyClass)) {
        processNested(propertyClass, hints);
    }
}

对 ApplicationProperties 的 9 个属性:

  • boolean × 6 → isJavaType 为 true,不递归
  • Banner.Mode → 嵌套类型?isNestedType 判断(推测:非 java.* 包且不是简单类型)。Banner.Mode 是枚举 → 会被 processNested 登记。hints.reflection().registerType(Banner$Mode) 需要包含 values() 等
  • WebApplicationType → 同上
  • Set<String> → componentType = String → isJavaType(String) 为 true,不递归

所以总共登记的反射类型约 3 个:ApplicationProperties、Banner$Mode、WebApplicationType(后两个可能通过 processNested)。

工程价值:这一切的代价是——如果 ApplicationProperties 不是独立类,而是 SpringApplication 的 9 个字段,那么 BindableRuntimeHintsRegistrar.forTypes(SpringApplication.class) 会把 SpringApplication 的所有属性类型(包括 ResourceLoader、List<ApplicationContextInitializer<?>>、List<ApplicationListener<?>>、ConfigurableEnvironment、ApplicationContextFactory、ApplicationStartup、BeanNameGenerator...)全部登记反射。这在 native image 里意味着:

  • 大量不必要的类被保留
  • 生成的可达性元数据膨胀
  • 镜像体积增大
  • 构建时间变长

这就是 1.2 节讲的"重构的真实驱动力"。

5.3.3 另外两个相关的 hints
// WebApplicationType.java:82
static class WebApplicationTypeRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
        for (String servletIndicatorClass : SERVLET_INDICATOR_CLASSES) {
            registerTypeIfPresent(servletIndicatorClass, classLoader, hints);
        }
    }

    private void registerTypeIfPresent(String typeName, @Nullable ClassLoader classLoader, RuntimeHints hints) {
        if (ClassUtils.isPresent(typeName, classLoader)) {
            hints.reflection().registerType(TypeReference.of(typeName));
        }
    }
}

为什么需要这个? 因为 WebApplicationType.deduce() 会调用 ClassUtils.isPresent("jakarta.servlet.Servlet", null)。在 native 里,ClassUtils.isPresent 用的是 Class.forName(...),需要反射登记。如果不登记,isPresent 会返回 false(native 下类未登记时 ClassNotFoundException),导致误判为 NONE。

注意 registerTypeIfPresent 的守卫:只有类真的在 classpath 上才登记。这是必要的——否则登记一个不存在的类会让 native 构建失败(找不到类型)。

// SpringApplicationBannerPrinter.java:131
static class SpringApplicationBannerPrinterRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
        hints.resources().registerPattern(DEFAULT_BANNER_LOCATION);
    }
}

这个登记的是资源而非反射:banner.txt 必须被打包进 native image,否则 resource.exists() 永远为 false。

AOT 教训:native 下有三类"默认不可见"的东西需要显式登记:反射(类/方法/字段/构造器)、资源(classpath 文件)、序列化/代理/JNI。本章这三处 hints 恰好覆盖了前两类。


5.4 配置元数据体系:additional-spring-configuration-metadata.json

5.4.1 为什么需要它

ApplicationProperties 是包级私有类,且没有 @ConfigurationProperties。那么:

  • IDE 怎么知道 spring.main.banner-mode 有哪些可选值?
  • 配置元数据处理器(spring-boot-configuration-processor)怎么生成文档?

答案:手写 JSON。

文件位置:

core/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json

Spring Boot 的元数据体系分两类:

  • 自动生成的:spring-configuration-metadata.json,由 spring-boot-configuration-processor 在编译期扫描 @ConfigurationProperties 类生成
  • 手工补充的:additional-spring-configuration-metadata.json,用于补充无法自动生成的部分

spring.main.* 属于后者(因为 ApplicationProperties 没有注解,处理器扫不到)。

5.4.2 完整的 spring.main.* 元数据
nametypesourceTypedefaultValue备注
spring.main.allow-bean-definition-overridingjava.lang.BooleanSpringApplicationfalse
spring.main.allow-circular-referencesjava.lang.BooleanSpringApplicationfalse
spring.main.banner-modeorg.springframework.boot.Banner$ModeSpringApplication—描述里写明条件默认值
spring.main.cloud-platformorg.springframework.boot.cloud.CloudPlatform(无)—幽灵属性
spring.main.keep-alivejava.lang.BooleanSpringApplicationfalse
spring.main.lazy-initializationjava.lang.BooleanSpringApplicationfalse
spring.main.log-startup-infojava.lang.BooleanSpringApplicationtrue
spring.main.register-shutdown-hookjava.lang.BooleanSpringApplicationtrue
spring.main.show-bannerjava.lang.BooleanSpringApplicationtrue弃用(since 1.3.0)→ banner-mode
spring.main.sourcesjava.util.Set<java.lang.String>SpringApplication—
spring.main.web-application-typeorg.springframework.boot.WebApplicationTypeSpringApplication—推断得出
spring.main.web-environmentjava.lang.BooleanSpringApplication—弃用(1.x)

spring.main.environment-prefix 不在这个表里——它是被禁止的(见 5.1)。

5.4.3 三个值得注意的点

(a)sourceType 全是 SpringApplication,不是 ApplicationProperties

因为 ApplicationProperties 是包级私有的,暴露它没意义。元数据指向公开 API SpringApplication,IDE 跳转时能跳到那里(虽然状态实际存在 ApplicationProperties 里)。

(b)spring.main.cloud-platform 没有 sourceType —— 它甚至不假装来自 SpringApplication,是真的"孤儿属性"。

(c)两个弃用条目被永久保留

  • spring.main.show-banner:since: "1.3.0" 弃用,替换品 spring.main.banner-mode
  • spring.main.web-environment:1.x 时代弃用

它们在 Java 代码里早已不存在(ApplicationProperties 没有 showBanner / webEnvironment 字段)。保留的唯一目的是让 IDE 给出删除线 + replacement 指引。

工程洞察:Spring Boot 对配置属性的兼容性承诺强于对 Java API 的承诺。因为配置散落在 yaml / properties / 环境变量 / CI 脚本里,改一个属性名的破坏力远大于改一个方法名。

第六部分 · 与 SpringApplicationBuilder 的映射

SpringApplicationBuilder 是构建父子/兄弟上下文的流式 API。它对 spring.main.* 提供了对应的方法:

Builder 方法委托到对应属性
web(WebApplicationType)application.setWebApplicationType(...)spring.main.web-application-type
logStartupInfo(boolean)application.setLogStartupInfo(...)spring.main.log-startup-info
bannerMode(Banner.Mode)application.setBannerMode(...)spring.main.banner-mode
registerShutdownHook(boolean)application.setRegisterShutdownHook(...)spring.main.register-shutdown-hook
lazyInitialization(boolean)application.setLazyInitialization(...)spring.main.lazy-initialization
allowCircularReferences(boolean)application.setAllowCircularReferences(...)spring.main.allow-circular-references
sources(Class<?>...)application.addPrimarySources(...)⚠️见下
environmentPrefix(String)application.setEnvironmentPrefix(...)❌ 无对应(禁止)
properties(...)application.setDefaultProperties(...)设置 defaultProperties,非 spring.main

四个陷阱:

(a)sources() 走的是 primary sources,不是 spring.main.sources

builder.sources(Class...) 添加的是 primarySources(Set<Class<?>>),而 spring.main.sources 绑定的是 ApplicationProperties.sources(Set<String>)。getAllSources() 把两者合并,primarySources 在前:

protected Set<Object> getAllSources() {
    Set<Object> allSources = new LinkedHashSet<>();
    if (!CollectionUtils.isEmpty(this.primarySources)) {
        allSources.addAll(this.primarySources);          // ← builder.sources()
    }
    if (!CollectionUtils.isEmpty(this.properties.getSources())) {
        allSources.addAll(this.properties.getSources()); // ← spring.main.sources
    }
    return allSources;
}

优先用前者:Class 对象有编译期检查与 IDE 跳转;字符串没有。

(b)properties() 设置的是 defaultProperties,优先级最低

DefaultPropertiesPropertySource 会被 moveToEnd,所以 builder.properties("spring.main.lazy-initialization=true") 的优先级低于 application.properties。这通常是反直觉的。

(c)environmentPrefix 在 builder 里可用(@since 2.5.0)——这是唯一推荐的设置方式(属性方式被禁止)。

(d)父子上下文与 keepAlive / registerShutdownHook

  • 每个子应用都调 shutdownHook.registerApplicationContext(...),但 shutdownHookAdded 的 CAS 保证只注册一个 JVM hook;关闭时按顺序关闭所有上下文
  • keepAlive 则是每个上下文各自添加一个 KeepAlive 监听器——N 个上下文会产生 N 个同名 keep-alive 线程,调试时容易困惑

第七部分 · 速查、结论与验证

7.1 九大属性运行时全表

属性默认值绑定后消费点(文件:行)生效阶段若写错
web-application-type推断getOrCreateEnvironment():479
deduceEnvironmentClass():371
createApplicationContext():581
造 Environment(推断值)+ 造 Context(真值)抛 Cannot bind to SpringApplication
allow-bean-definition-overridingfalseprepareContext():390refresh() 前,设到 DefaultListableBeanFactory静默忽略
allow-circular-referencesfalseprepareContext():388同上,设到 AbstractAutowireCapableBeanFactory静默忽略
lazy-initializationfalseprepareContext():405注册 LazyInitializationBeanFactoryPostProcessor(最低优先级 BFPP)静默忽略
banner-mode条件计算printBanner():560,566Environment 就绪后、Context 创建前静默忽略
log-startup-infotrueprepareContext():396
run():324
两处:Starting / Started静默忽略
register-shutdown-hooktruerun():306(开关)
refreshContext():442(注册)
run 最开始 + refresh 前静默忽略
keep-alivefalseprepareContext():408注册 KeepAlive 监听器静默忽略
sources空 SetgetAllSources():1194 → load():414prepareContext(),且仅非 AOT 模式加载时报错

特殊成员:

名字类型设置方式说明
environment-prefixString只能 setEnvironmentPrefix(...)配置方式被 Assert.state 禁止
cloud-platformString配置不在 ApplicationProperties 里,CloudPlatform 自己用 environment.getProperty 读

7.2 反直觉结论汇总

  1. ApplicationProperties 是 AOT 产物 —— 存在的主要理由是控制 GraalVM 反射登记粒度,不是代码整洁。
  2. banner-mode 的默认值不是常量 —— 启用结构化日志时默认为 OFF。用 null 表达"未决策",推迟到 getter 时计算。
  3. spring.main.* 写错不报错 —— Binder.bind() 忽略未知属性,与 @ConfigurationProperties 行为相反。
  4. REACTIVE 推断不出来 —— deduce() 只有 SERVLET/NONE 两个兜底分支,REACTIVE 必须靠 Deducer SPI 或显式设置。
  5. 覆盖自动配置的 Bean 不需要 allow-bean-definition-overriding —— @ConditionalOnMissingBean 根本不会注册第二个定义。
  6. keep-alive=true 会让测试永不结束 —— 它创建一个非守护的 keep-alive 线程。
  7. spring.main.sources 在 native 下失效 —— if (!AotDetector.useGeneratedArtifacts()) 把它整个包住了。
  8. spring.main.* 不等于"都能用配置设置" —— environment-prefix 是例外,因为在 EnvironmentPostProcessor 阶段就要用。
  9. cloud-platform 是幽灵属性 —— 享受命名空间却不享受绑定机制,用原始字符串比较,下划线/连字符写错会静默失效。
  10. banner-mode 是"只写属性" —— getBannerMode(Environment) 带参数,不被 JavaBeanBinder 认作 getter,所以它的反射 hint 也未登记(测试明确断言 rejects)。

7.3 动手验证清单

全部命令在 spring-boot-4.1.1 下执行。

(1)确认类的形态

sed -n '17,60p' core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java
# 观察:无 public、无 @ConfigurationProperties、9 个字段

(2)列出所有绑定点

grep -n "properties\." core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java
# 观察:17 处使用点,其中 306/442 都是 register-shutdown-hook

(5)验证 boolean → Mode 的宽松转换

grep -n "spring.main.banner-mode=false" \
  core/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java

(6)看 cloud-platform 的读取方式

grep -n "PROPERTY_NAME\|getProperty(PROPERTY_NAME)\|isEnforced" \
  core/spring-boot/src/main/java/org/springframework/boot/cloud/CloudPlatform.java

(7)看 Deducer SPI 的加载

grep -rn "WebApplicationType\$Deducer\|Deducer.class" --include="*.java" --include="*.factories" . | grep -v "/build/"

(8)实践:验证 keep-alive 线程

java -jar app.jar --spring.main.keep-alive=true &
jstack <pid> | grep -A3 '"keep-alive"'
# 会看到一个名为 keep-alive、状态 TIMED_WAITING (sleeping) 的非守护线程

7.4 源码索引

主题文件
配置载体core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java
绑定入口SpringApplication.java:550 bindToSpringApplication
环境准备全流程SpringApplication.java:351 prepareEnvironment
Environment 类型纠正SpringApplication.java:371 deduceEnvironmentClass + EnvironmentConverter.java
Web 类型推断WebApplicationType.java(deduce()、Deducer)
上下文工厂ApplicationContextFactory.java、DefaultApplicationContextFactory.java
懒加载LazyInitializationBeanFactoryPostProcessor.java、LazyInitializationExcludeFilter.java
关闭钩子SpringApplicationShutdownHook.java、SpringApplicationShutdownHandlers.java
KeepAliveSpringApplication.java:1699 内部类 KeepAlive
BannerBanner.java、SpringApplicationBannerPrinter.java、ResourceBanner.java、SpringBootBanner.java
启动日志StartupInfoLogger.java、ApplicationInfoPropertySource.java
云平台cloud/CloudPlatform.java、context/config/ConfigDataActivationContext.java
环境前缀support/SystemEnvironmentPropertySourceEnvironmentPostProcessor.java
AOT hintscontext/properties/bind/BindableRuntimeHintsRegistrar.java
配置元数据core/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json
官方测试ApplicationPropertiesTests.java、SpringApplicationTests.java、CloudPlatformTests.java、SimpleMainTests.java
绑定机制context/properties/bind/Binder.java、Bindable.java、JavaBeanBinder.java

7.5 4.x 相对 3.x 的变化(本章相关部分)

维度3.x4.1.1
配置载体SpringApplication 散字段 + public setter抽为包级私有 ApplicationProperties,public setter 变为委托
banner-mode 默认值恒定 CONSOLE启用结构化日志时为 OFF
web-application-type 推断硬编码 WebFlux 探测改为 WebApplicationType.Deducer SPI(@since 4.0.1)
空值注解org.springframework.lang.NullableJSpecify org.jspecify.annotations.Nullable(TYPE_USE)
启动日志—新增 AOT-processed 标记、CRaC 的 Restored 措辞
PropertySource 标记—PropertySourceInfo.isImmutable()(ApplicationInfoPropertySource 返回 true)
环境前缀处理—SystemEnvironmentPropertySourceEnvironmentPostProcessor(@since 4.0.0)+ Origin 追踪

本章未涉及 Environment 的 PropertySource 优先级细节(第 06 章)、Binder 内部实现(第 11 章)、ConfigData(第 09–10 章)、AOT 引擎(第 27 章)。这些内容会在各自章节展开,本章只保留了理解 spring.main.* 所必需的部分。


第八部分 · 本章新增定律

第十定律:诊断能力是"提前布局"出来的,不是事后加的(第 5 章)

Cannot bind to SpringApplication 这条信息里只有 SpringApplication、没有属性名—— 这不是信息不全,而是刻意的:spring.main.* 的绑定发生在 prepareEnvironment 阶段(§3.5), 真正的定位信息在 cause 的 BindException 里(Failed to bind properties under 'xxx')。 要让一条错误信息自包含,必须在设计入口时就决定它携带什么—— 事后往里塞字段,塞的永远是"出问题的那一刻已经拿不到的东西"。

第十一定律:LinkedHashSet / TreeMap / 手写序列化 = 构建确定性(第 5 章)

sources 用 LinkedHashSet 而不是 HashSet(第二部分),因为它的内容会按顺序变成 Bean 定义,顺序差异在边界情况下会改变 @ConditionalOnMissingBean 的判定; 第五部分的 AOT 反射登记讲的是同一件事的另一面:native 下"默认不可见"的东西必须显式登记。 只要产物要参与比较(缓存、diff、字节比对),集合就必须选顺序稳定的实现—— HashSet 的迭代顺序连 JDK 版本之间都可能变。


第九部分 · 本章未逐行核实 / 待你实测的项

  1. keep-alive、sources 两个属性的运行时行为未在本机实测。
  2. additional-spring-configuration-metadata.json 的完整内容未逐行列出。
  3. BindableRuntimeHintsRegistrar 的反射登记粒度细节在第 27 章。
  4. SpringApplicationBuilder 与属性的映射只列了主要项。
  5. banner-mode 条件默认值的官方测试断言未在本机跑过。

下一章:第 06 章进入 Environment 本身——PropertySource 次序、两个 ConversionService,以及"哪个配置最终生效"的规则。


本章小结

ApplicationProperties 只有 9 个字段,却是理解 Spring Boot 启动时序的最佳入口:

  1. AOT 对框架设计的反向塑造 —— 一个纯数据类,因反射登记粒度而被创造出来。
  2. "引导元数据"与"应用配置"的分界 —— environment-prefix 被禁止通过配置设置,因为它属于前者。
  3. Boot 处理循环依赖的经典手法 —— web-application-type 的"先猜后纠"(EnvironmentConverter)。
  4. "延迟决策"模式 —— bannerMode 用 null 表达未决策,把默认值计算推迟到 Environment 可用时。
  5. 历史兼容痕迹 —— show-banner、web-environment 的弃用元数据,以及 liquibase-core 特判。

理解这 9 个属性,就理解了 SpringApplication.run() 前半程的所有分支。第 06 章进入 Environment 本身:PropertySource 次序、ConversionService 装配,以及决定"哪个配置最终生效"的规则。