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.javacore/spring-boot/src/main/java/org/springframework/boot/SpringApplication.javacore/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.jsoncore/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 的启动流程中处在一个极其特殊的位置:
- 它是唯一一个"在
ApplicationContext存在之前就被绑定"的配置类——所有其他@ConfigurationProperties都是在refresh()期间由ConfigurationPropertiesBindingPostProcessor绑定的,而ApplicationProperties是在prepareEnvironment()里、连ApplicationContext都还没创建的时候绑定的。 - 它的值决定了后面所有事情的形状——
web-application-type决定创建哪种Environment、创建哪种ApplicationContext;lazy-initialization决定给容器挂一个什么样的BeanFactoryPostProcessor;register-shutdown-hook决定 JVM 退出时的行为;sources决定了 IoC 容器要加载哪些 Bean 定义。 - 它参与了一个经典的"鸡生蛋"问题——要绑定
spring.main.*必须先把Environment造出来,而造Environment又需要知道spring.main.web-application-type。Spring Boot 对这个矛盾的处理方式非常值得学习。 - 它是 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.Nullable | Spring 7 全面引入 JSpecify 空值注解,这是 4.x 代码风格的标志(第 01 章讲过) |
内部有 RuntimeHintsRegistrar | 为 GraalVM Native 服务,这是它存在的重要原因 |
为什么"包级私有"很重要?
3.x 及更早版本,这些属性的宿主是 SpringApplication 本身(公开 API,大量 public setter)。这些 setter 有两个副作用:
- 它们是 API 承诺 —— 一旦公开就必须永久维护签名,行为变更要走弃用周期。
- 它们可被任意反射调用 —— 对 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。这意味着:
SpringApplication对象一构造完,properties就存在了(默认值生效)- 之后无论怎么调用 setter 或绑定,都是修改这同一个对象的内部状态,不会换引用
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 字段全景
| # | 字段 | 类型 | 默认值 | 对应属性名 |
|---|---|---|---|---|
| 1 | allowBeanDefinitionOverriding | boolean | false | spring.main.allow-bean-definition-overriding |
| 2 | allowCircularReferences | boolean | false | spring.main.allow-circular-references |
| 3 | bannerMode | Banner.@Nullable Mode | null(延迟计算) | spring.main.banner-mode |
| 4 | keepAlive | boolean | false | spring.main.keep-alive |
| 5 | lazyInitialization | boolean | false | spring.main.lazy-initialization |
| 6 | logStartupInfo | boolean | true | spring.main.log-startup-info |
| 7 | registerShutdownHook | boolean | true | spring.main.register-shutdown-hook |
| 8 | sources | Set<String> | new LinkedHashSet<>() | spring.main.sources |
| 9 | webApplicationType | @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),不是 gettergetBannerMode(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 方法 | bindOrCreate | bind |
| 无匹配属性时 | 返回 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 在起作用:
- Binder 需要把字符串
"false"转成Banner.Mode - 它查找
ConversionService里有没有String → Banner.Mode的转换器 ApplicationConversionService内部有一个回退逻辑:先尝试标准的String → Enum("false"不匹配OFF/CONSOLE/LOG,失败)- 然后 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);
}
}
三点:
protected— 这是一个扩展点。子类可以覆盖它来做自定义绑定(虽然实践中很少有人这么做)。- 捕获
Exception(不是Throwable) — 让Error继续传播,符合惯例。 - 包装成
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);
}
}
}
// ...
}
值得注意的细节:
type.equals(environment.getClass())用equals而非isAssignableFrom—— 要求精确类型匹配。即便ApplicationEnvironment与StandardEnvironment有继承关系也会触发转换,实践中可能多一次转换,但行为可预测。createEnvironment失败时兜底new ApplicationEnvironment()—— 优雅降级:宁可用基础 Environment 继续启动,也不要崩掉。copyPropertySources排除三个 servlet PropertySource(servletContextInitParams、servletConfigInitParams、jndiProperties)—— 它们绑定具体ServletContext/ServletConfig实例,搬到新 Environment 会造成旧引用悬挂;它们会在ApplicationContext初始化时由容器重新注入。- 保留
activeProfiles与conversionService—— 这两项在绑定前可能已被 ConfigData 的 profile 激活逻辑设置过。 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)三类默认排除:
-
用户提供的
LazyInitializationExcludeFilterBean —— 通过getBeansOfType(..., false, false)获取。注意后两个false:allowEagerInit=false、allowFactoryBeanInit=false。注释写得很清楚:"Take care not to force the eager init of factory beans when getting filters"——在这个阶段(还没到实例化单例的时刻)如果强制初始化 FactoryBean,会破坏懒加载的初衷,甚至引发循环。 -
SmartInitializingSingleton类型的 Bean —— 因为SmartInitializingSingleton.afterSingletonsInstantiated()回调依赖"所有单例已实例化"这个前提。如果一个SmartInitializingSingleton被设为懒加载,它可能永远不会被实例化,回调也就永远不会执行——这会静默地破坏功能。所以必须排除。 -
基础设施角色的 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 类,这会:
- 触发外层配置类的依赖注入 → 可能拉起一大堆 Bean
- 与懒加载的目标直接冲突
这是一个写 BeanFactoryPostProcessor 相关 Bean 时必须记住的规则:用 static @Bean 方法。
4.4.4 懒加载的真实收益与代价
收益:
- 启动更快(不实例化用不到的 Bean)
- 内存占用更低
- 某些循环依赖问题"消失"(因为 Bean 不需要在启动时就构造完成)
代价(这些是官方文档不会强调的):
- 问题延迟暴露 —— 原本启动就失败的配置错误,现在要到第一次请求才炸。生产环境这是灾难性的。
- 首次请求延迟 —— 第一个请求要承担全部初始化成本。
- 与某些功能冲突 —— 任何"启动时需要扫描所有 Bean"的功能都会失效:
- Actuator 的
/beans端点只能看到已实例化的 SmartInitializingSingleton(已排除)- 某些健康检查、指标注册
@Scheduled任务——注意:ScheduledAnnotationBeanPostProcessor依赖ApplicationReadyEvent或上下文刷新完成事件来启动调度,如果调度 Bean 本身懒加载,任务可能不会启动
- Actuator 的
- AOT/native 下收益降低 —— native image 的启动时间本来就极短,懒加载的边际收益小,但分析复杂度上升。
实践建议:
spring.main.lazy-initialization=true最适合本地开发和测试(加快反馈循环)。生产环境启用前,务必评估"配置错误延迟暴露"的风险。折中方案:只在devprofile 开启。
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,就当它不存在。
这是一个真实的、可以从源码里挖出来的工程故事。它说明:
- Spring Boot 有大量这类"为了兼容性/现实问题"的补丁代码
- 通用框架在真实世界里不得不为具体的第三方库做特判
- 读源码时能发现这些"故事",是理解框架成熟度的最好方式
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);
}
}
}
三个要点:
-
isImmutable() == true—— 来自PropertySourceInfo接口(4.x 新增)。标记内容永不变,ConfigData 等机制可据此做缓存/优化。 -
moveToEnd—— 在prepareEnvironment里调用。因为configurePropertySources里先addLast了它,但之后environmentPrepared阶段 ConfigData 会插入很多 PropertySource(在末尾之前)。挪到真正的末尾才能保证最低优先级——用户可以覆盖spring.application.version。 -
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"));
}
}
// ...
}
两步设计的理由:
- "先启用、后注册" ——
register-shutdown-hook=false时shutdownHookAdditionEnabled永远为 false,一个 JVM hook 都不加;若用户后续调getShutdownHandlers().add(...),Handlers.add()也会调addRuntimeShutdownHookIfNecessary()补上 hook(前提是开关已开)。 AtomicBoolean.compareAndSet(false, true)—— 保证只注册一次 JVM shutdown hook,即便有多个ApplicationContext(父子/兄弟上下文)。- 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);
}
执行顺序:
- 关闭所有活跃的上下文
- 关闭所有已标记关闭的上下文(
closedContexts,弱引用集合) - 逆序执行用户注册的 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 分钟
}
三个关键:
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."TIMEOUT = TimeUnit.MINUTES.toMillis(10)—— 每个上下文最多阻塞 10 分钟。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。
典型场景:
- 测试 —— JUnit 测试里如果每个测试都注册 JVM shutdown hook,会累积大量 hook(JVM 不会移除),导致测试套件结束时卡住或内存问题。Spring Boot 的测试框架内部通常会处理这个。
- 嵌入式场景 —— 你的应用是某个更大进程的插件,JVM 关闭由宿主管理。
- 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。收到信号后:
- shutdown hook 运行 → 关闭 ApplicationContext
ContextClosedEvent触发 →stopKeepAliveThread()→ interrupt- keep-alive 线程结束
- 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.MyConfig | AnnotatedBeanDefinitionReader |
| XML 资源位置 | classpath:beans.xml | XmlBeanDefinitionReader |
| Groovy 脚本 | beans.groovy | GroovyBeanDefinitionReader(4.x 是否仍支持待核实) |
| 包名 | com.example.service | ClassPathBeanDefinitionScanner |
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 完全不生效。
这是一个必须理解的限制。原因:
- AOT 在构建期运行应用,扫描 classpath,生成 Bean 定义的 Java 源码
- 构建期无法预知运行期会通过
spring.main.sources传入什么 - 所以 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 对集合类型的标准处理。)
风险:
- 类名拼写错误只在加载时炸,无提前校验
- AOT 下失效(见 4.9.4)
- 破坏"单一入口"原则,Bean 来源分散难追踪
- 包名拼错会静默扫描 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,那么:
environmentPrepared阶段,前缀还是null(还没绑定)OriginAwareSystemEnvironmentPropertySource用null前缀创建- 之后绑定
spring.main.environment-prefix=my,environmentPrefix字段变成"my" - 但
systemEnvironmentPropertySource 已经用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-prefix | environmentPrepared 阶段(绑定前) | ❌ 不能(有断言) |
web-application-type | getOrCreateEnvironment(绑定前用推断值)+ createApplicationContext(绑定后) | ✅ 能(有纠正机制) |
banner-mode | printBanner(绑定后) | ✅ 能 |
lazy-initialization | prepareContext(绑定后) | ✅ 能 |
| 其余 | 绑定后 | ✅ 能 |
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_FOUNDRY | VCAP_APPLICATION 或 VCAP_SERVICES 存在 | |
HEROKU | DYNO 存在 | |
SAP | HC_LANDSCAPE 存在 | |
NOMAD | NOMAD_ALLOC_ID 存在 | @since 3.1.0 |
KUBERNETES | 见下 | 两级检测 |
AWS_ECS | AWS_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 里
推测(源码未明确说明,标注为推测):
- 它需要
Binder-free 的读取 ——CloudPlatform.isEnforced(Binder)和isEnforced(Environment)都提供了,说明它在不同上下文里被读。有些地方(如 ConfigData 激活判断)发生在ApplicationProperties绑定之前或与之并行。 - 它是"环境事实"而非"应用配置" ——
cloud-platform描述的是"我在哪跑",不是"我要怎么跑"。语义上更接近Environment的固有属性。 - 历史遗留 ——
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.* 元数据
| name | type | sourceType | defaultValue | 备注 |
|---|---|---|---|---|
spring.main.allow-bean-definition-overriding | java.lang.Boolean | SpringApplication | false | |
spring.main.allow-circular-references | java.lang.Boolean | SpringApplication | false | |
spring.main.banner-mode | org.springframework.boot.Banner$Mode | SpringApplication | — | 描述里写明条件默认值 |
spring.main.cloud-platform | org.springframework.boot.cloud.CloudPlatform | (无) | — | 幽灵属性 |
spring.main.keep-alive | java.lang.Boolean | SpringApplication | false | |
spring.main.lazy-initialization | java.lang.Boolean | SpringApplication | false | |
spring.main.log-startup-info | java.lang.Boolean | SpringApplication | true | |
spring.main.register-shutdown-hook | java.lang.Boolean | SpringApplication | true | |
spring.main.show-banner | java.lang.Boolean | SpringApplication | true | 弃用(since 1.3.0)→ banner-mode |
spring.main.sources | java.util.Set<java.lang.String> | SpringApplication | — | |
spring.main.web-application-type | org.springframework.boot.WebApplicationType | SpringApplication | — | 推断得出 |
spring.main.web-environment | java.lang.Boolean | SpringApplication | — | 弃用(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-modespring.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():479deduceEnvironmentClass():371createApplicationContext():581 | 造 Environment(推断值)+ 造 Context(真值) | 抛 Cannot bind to SpringApplication |
allow-bean-definition-overriding | false | prepareContext():390 | refresh() 前,设到 DefaultListableBeanFactory | 静默忽略 |
allow-circular-references | false | prepareContext():388 | 同上,设到 AbstractAutowireCapableBeanFactory | 静默忽略 |
lazy-initialization | false | prepareContext():405 | 注册 LazyInitializationBeanFactoryPostProcessor(最低优先级 BFPP) | 静默忽略 |
banner-mode | 条件计算 | printBanner():560,566 | Environment 就绪后、Context 创建前 | 静默忽略 |
log-startup-info | true | prepareContext():396run():324 | 两处:Starting / Started | 静默忽略 |
register-shutdown-hook | true | run():306(开关)refreshContext():442(注册) | run 最开始 + refresh 前 | 静默忽略 |
keep-alive | false | prepareContext():408 | 注册 KeepAlive 监听器 | 静默忽略 |
sources | 空 Set | getAllSources():1194 → load():414 | prepareContext(),且仅非 AOT 模式 | 加载时报错 |
特殊成员:
| 名字 | 类型 | 设置方式 | 说明 |
|---|---|---|---|
environment-prefix | String | 只能 setEnvironmentPrefix(...) | 配置方式被 Assert.state 禁止 |
cloud-platform | String | 配置 | 不在 ApplicationProperties 里,CloudPlatform 自己用 environment.getProperty 读 |
7.2 反直觉结论汇总
ApplicationProperties是 AOT 产物 —— 存在的主要理由是控制 GraalVM 反射登记粒度,不是代码整洁。banner-mode的默认值不是常量 —— 启用结构化日志时默认为OFF。用null表达"未决策",推迟到 getter 时计算。spring.main.*写错不报错 ——Binder.bind()忽略未知属性,与@ConfigurationProperties行为相反。REACTIVE推断不出来 ——deduce()只有 SERVLET/NONE 两个兜底分支,REACTIVE 必须靠DeducerSPI 或显式设置。- 覆盖自动配置的 Bean 不需要
allow-bean-definition-overriding——@ConditionalOnMissingBean根本不会注册第二个定义。 keep-alive=true会让测试永不结束 —— 它创建一个非守护的keep-alive线程。spring.main.sources在 native 下失效 ——if (!AotDetector.useGeneratedArtifacts())把它整个包住了。spring.main.*不等于"都能用配置设置" ——environment-prefix是例外,因为在EnvironmentPostProcessor阶段就要用。cloud-platform是幽灵属性 —— 享受命名空间却不享受绑定机制,用原始字符串比较,下划线/连字符写错会静默失效。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 |
| KeepAlive | SpringApplication.java:1699 内部类 KeepAlive |
| Banner | Banner.java、SpringApplicationBannerPrinter.java、ResourceBanner.java、SpringBootBanner.java |
| 启动日志 | StartupInfoLogger.java、ApplicationInfoPropertySource.java |
| 云平台 | cloud/CloudPlatform.java、context/config/ConfigDataActivationContext.java |
| 环境前缀 | support/SystemEnvironmentPropertySourceEnvironmentPostProcessor.java |
| AOT hints | context/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.x | 4.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.Nullable | JSpecify 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 版本之间都可能变。
第九部分 · 本章未逐行核实 / 待你实测的项
keep-alive、sources两个属性的运行时行为未在本机实测。additional-spring-configuration-metadata.json的完整内容未逐行列出。BindableRuntimeHintsRegistrar的反射登记粒度细节在第 27 章。SpringApplicationBuilder与属性的映射只列了主要项。banner-mode条件默认值的官方测试断言未在本机跑过。
下一章:第 06 章进入 Environment 本身——PropertySource 次序、两个 ConversionService,以及"哪个配置最终生效"的规则。
本章小结
ApplicationProperties 只有 9 个字段,却是理解 Spring Boot 启动时序的最佳入口:
- AOT 对框架设计的反向塑造 —— 一个纯数据类,因反射登记粒度而被创造出来。
- "引导元数据"与"应用配置"的分界 ——
environment-prefix被禁止通过配置设置,因为它属于前者。 - Boot 处理循环依赖的经典手法 ——
web-application-type的"先猜后纠"(EnvironmentConverter)。 - "延迟决策"模式 ——
bannerMode用null表达未决策,把默认值计算推迟到Environment可用时。 - 历史兼容痕迹 ——
show-banner、web-environment的弃用元数据,以及liquibase-core特判。
理解这 9 个属性,就理解了 SpringApplication.run() 前半程的所有分支。第 06 章进入 Environment 本身:PropertySource 次序、ConversionService 装配,以及决定"哪个配置最终生效"的规则。