Spring Boot:启动时序与条件装配
一个默认 Bean 没出现,可能根本没成为候选、被条件否决、向用户 Bean 退让,也可能条件通过后创建失败。应用打印 started,也可能随后 runner 失败。把这两组问题分开,才能选对证据。既有机制固定 Boot v3.3.3 / Framework v6.1.12;新增工程另用 Boot4.0.9 / Framework7.0.10 / JDK21,依赖和证据分开;容器内部阶段由生命周期正文统一解释,不再复制 refresh 教程。
运行配置继续阅读Boot 配置:外部来源、绑定与失败和Boot 运行:日志、JSON、TLS、资源与停机。两篇使用独立Boot4.0.9/JDK21工程验证F12/EX1;本篇旧启动源码、NONE实测与类型推导的证明边界保持各自版本。
BOM、starter 与插件各负责什么
以下新增工程使用 JDK21 / Boot4.0.9 / Framework7.0.10,依据docs/design/java-depth-engineering-baseline.md(已锁兼容基线),与下文3.3.3源码推演、旧实验分开执行。它是可复现教学版本组合,支持日期见基线,不把旧版当生产选型。主归属仍为本页;配置优先级与绑定错误分期归U23、Actuator/readiness归U24、AOT/native与恢复归U28。
从一个没有Web需求的定时批处理进程出发,先回答三件事:哪些库被引入、版本由谁决定、运行产物由谁组织。
| 对象 | 输入与作用 | 不负责的事与常见反例 |
|---|---|---|
spring-boot-dependencies BOM | 在 Maven dependencyManagement 里按坐标给依赖版本约束 | 导入 BOM 不会自动把 JDBC、WebServer 或驱动放到 classpath;也不配置全部构建插件 |
spring-boot-starter-parent | 继承依赖管理、插件管理及编译/资源等约定 | 继承后仍须声明应用使用的 starter 和 Boot 插件;与企业 parent 冲突时可只导入 BOM,但要自行锁定编译/repackage 插件 |
| starter | 用依赖集合组合某种能力;由传递依赖影响应用类型和自动配置条件 | starter 不是“扫描第三方包的注解”;加上某 starter 也不证明外部服务可连或业务正确 |
spring-boot-maven-plugin | package 阶段的 repackage 执行,把普通归档变成 Boot 可执行归档 | 仅 pluginManagement 中存在插件版本不等于插件已执行;不要把普通 jar 缺 main manifest 当业务 Bean 错误 |
本实验的完整pom继承4.0.9 parent,声明基础 starter 与可选配置处理器,构建中明确声明 Boot 插件,利用 parent 已配置的 repackage execution;java.version=21 是本工程编译目标。MVC、Reactive 用独立 Maven profile 改变真实类路径,仅在类型推导场景启用。应用本身不使用 Cloud、数据库或安全,因此不引入它们的 jar。
JAVA_HOME=$(/usr/libexec/java_home -v21) mvn -f labs/boot-startup/pom.xml dependency:tree
JAVA_HOME=$(/usr/libexec/java_home -v21) mvn -f labs/boot-startup/pom.xml help:effective-pom
JAVA_HOME=$(/usr/libexec/java_home -v21) mvn -f labs/boot-startup/pom.xml clean package
java -jar labs/boot-startup/target/boot-startup-1.jar最后一条 java 也必须来自JDK21(先将同一JAVA_HOME/bin加入PATH,或使用绝对路径),否则可能发生 UnsupportedClassVersionError。版本冲突先查 dependency:tree、effective-pom 和归档中的实际 jar,不凭声明的顶层 Boot 版本推断所有传递依赖。显式覆盖受管理库是一项兼容变更,需要说明理由并重跑对应集成,而不是“最新版自然最好”。官方依赖管理说明、可执行打包说明分别约束这两条链。
应用类型在什么条件下确定
NONE、SERVLET、REACTIVE 是启动上下文的选择,不是业务是否含有 Controller 的统计。构造SpringApplication时推导类型,随后Environment中的 spring.main.web-application-type 或显式API还能影响选择。4.0.9已不同于3.3.3的直接类路径检查:4.0.1起通过Deducer SPI扩展,各模块声明自己的判断;没有扩展得出结果时才以Servlet指示类回退。
原码:WebApplicationType.deduce,v4.0.9,51–59行,固定来源。
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;
}循环按SpringFactoriesLoader排序结果选首个非null值。官方MVC Deducer是Order(10),守卫Servlet、DispatcherServlet和ConfigurableWebApplicationContext三类齐全后返回SERVLET;WebFlux Deducer是Order(20),守卫Mono与DispatcherHandler齐全后返回REACTIVE。因此两模块正常同时存在时MVC先胜出;不能继续用旧版“一个方法直接检查MVC/Jersey”解释新版,也不能凭项目用了Mono断言Reactive容器。
实验从真实不同依赖组合构造 SpringApplication,通过公共 getWebApplicationType() 观察:基础starter→NONE;仅webflux→REACTIVE;仅webmvc→SERVLET;二者都有→SERVLET。这些四组只验证推导,不启动服务器。正常/失败事件实验显式设NONE,没有监听端口。强行声明SERVLET也不会凭空产生Servlet容器工厂;应先修正依赖与服务模型,再决定是否显式指定类型。WebFlux真实背压、Servlet/Reactive容器运行和端口探针分别由U27、U24覆盖。
批任务加web starter会扩大启动/内存/攻击面及停机责任;统一Web基座可复用运维设施,但团队需接受额外依赖与端口。两者都可合理,选择要围绕该进程是否处理HTTP、部署探针和资源预算;不要只为了修复一个缺类异常导入整个Web栈。
可执行归档、分层与恢复验证
编译成功、普通jar、Boot可执行jar是三种证据。repackage 消费 package 产生的源归档,添加 Boot Loader、依赖和启动清单;原归档默认保留 .original。本工程4.0.9实测:
| 归档位置 | 观察与作用 |
|---|---|
META-INF/MANIFEST.MF | Main-Class是org.springframework.boot.loader.launch.JarLauncher;Start-Class是业务study.startup.StartupLab |
BOOT-INF/classes | 业务class;不应拿此jar直接当普通库依赖,让标准类加载器误找嵌套类 |
BOOT-INF/lib | 嵌套依赖,检查实际含spring-boot-4.0.9.jar、Framework7.0.10 |
META-INF/spring/...AutoConfiguration.imports、META-INF/spring-configuration-metadata.json | 本次4.0.9产物保留在归档根;不能硬套“所有资源都在BOOT-INF/classes” |
BOOT-INF/layers.idx | 描述默认dependencies、spring-boot-loader、snapshot-dependencies、application四层;分层索引不是容器或缓存命中本身 |
打包调用链是Maven repackage目标→Repackager.repackage→按layout写入启动清单/Loader/应用条目/嵌套库;不是把所有依赖class扁平合并到同一包。运行时JarLauncher以归档布局建立包含业务类与嵌套依赖的类加载路径,再转交Start-Class的main;普通JDK类加载器不会自动搜索任意jar中的嵌套jar,所以改manifest指向业务main不能代替Boot Loader。
Boot3.3.3的旧工程用 -Djarmode=layertools ... list/extract;新增4.0.9工程用 -Djarmode=tools ... list-layers/extract。两条命令分别实跑,不能把旧入口复制到新版后宣称验证完成。tools提取后的jar由 java -jar 提取目录/boot-startup-1.jar 再次启动并断言默认Bean和完整事件;恢复验证包括回到默认依赖构建、再次正常启动、最后定向删除本工程唯一提取目录。
# 以下 java 必须来自同一JDK21;完整自动入口会设置并校验
java -Djarmode=tools -jar labs/boot-startup/target/boot-startup-1.jar list-layers
java -Djarmode=tools -jar labs/boot-startup/target/boot-startup-1.jar extract --destination /tmp/u22-example-extract
java -jar /tmp/u22-example-extract/boot-startup-1.jar手工运行时选择空的自有目录并只清理该目录,自动入口使用工程target下目录。若提取后找不到启动类,先检查提取命令布局和启动方式;若归档缺依赖,查插件执行、scope和exclude;若换JDK启动失败,查编译目标与运行时。不得以重新打包掩盖用户数据恢复问题:本实验无数据库,无资格证明迁移回滚。WAR可由插件组织WEB-INF/lib-provided来区分外部容器依赖,但本票选择可执行JAR作为完整实跑路径;WAR部署不在本票运行承诺,真实容器交付归U65。
分层的取舍可以计算:假设依赖层80MiB、应用层4MiB,10个节点每天部署8次,全部重传上界 84×10×8=6720MiB/日;理想依赖缓存复用只变应用层则 4×10×8=320MiB/日,另计首次拉取及缓存失效。这里是明确假设的传输算例,不是实际镜像测量。稳定分层减少重复传输,但保留老层占磁盘,依赖升级仍需拉取;单一归档发布更简单,无法推出相同层复用收益。负责人应看制品hash、依赖SBOM、缓存命中与磁盘水位,灰度保留可回退制品,别把分层当容量凭证。
启动失败诊断与定义覆盖反例
FailureAnalyzer把可识别的异常转换为原因与行动建议,LoggingFailureAnalysisReporter负责输出;不能覆盖所有异常。--debug的条件报告回答候选为何匹配/否决,异常链回答为何创建失败,二者分工不同。官方启动失败说明给出分析器扩展点和条件报告入口。
旧3.3.3模块的PackagingDiagLab faildiag真实触发缺失token的配置校验失败;它限定异常根因是BindValidationException且字段token的NotBlank约束,shell再断言报告Description/Action,避免“缺任何jar也当验证通过”。此例只演示诊断器,不代替U23对绑定阶段的完整分类。补齐配置后恢复正常启动,runner失败则由既有runner-fail路径观察:refreshed→started→runner→failed→closed,无ready。
4.0.9新增两个普通配置类都定义sameName。默认spring.main.allow-bean-definition-overriding=false,启动以非零退出,报告冲突名和修复建议;显式设true允许同名定义被后登记定义替换,实测结果为first(该工程注册顺序,不把@Import文本顺序推广为通用保证)。允许覆盖后只有一个同名Bean,与MissingBean让默认定义不登记完全不同;正常用户替换NoticeClient使用不同名字userClient且按类型退让,不需要放开全局覆盖。
| 触发输入 | 证据 | 修复后复验 |
|---|---|---|
| 无用户Bean | auto且默认定义存在 | 用户扩展后变user,默认定义不存在 |
| 用户Bean+禁用开关 | user仍存在 | 开关只约束自动配置,用户配置自行治理 |
| 两个普通配置同名 | 默认失败、非零、sameName诊断 | 重命名/删除误注册;开启覆盖只作实验对照 |
| runner抛指定异常 | 已started但无ready,failed/closed | 移除失败输入,完整ready后关闭 |
| 必填token缺失 | 字段约束异常链+诊断报告 | 补齐合法配置,不能仅关掉分析器日志 |
排障先保留制品、脱敏启动参数和异常链,再定位资源注册、条件、定义冲突或实例创建。不要因看见“找不到Bean”就同时开覆盖、循环依赖和全包扫描,三个开关会改变问题本身,增加恢复时的归因成本。
自定义自动配置与配置元数据
本页既有3.3.3 imports/延迟条件调用链继续有效于其标签;新增4.0.9实现单独在实验工程维护。完整最小配置包含AutoConfiguration、属性条件、EnableConfigurationProperties、按NoticeClient类型的MissingBean,以及真正打包的imports。真实应用源刻意没有ComponentScan,从而验证注册资源是候选入口,不靠扫描碰巧工作。
properties对象只负责接收结构化配置:lab.notice.enabled默认true、lab.notice.message默认hello,用DefaultValue显式定义构造绑定默认值。启用开关的缺省逻辑也由ConditionalOnProperty的matchIfMissing表达,二者责任不能混为一谈。类型声明返回具体NoticeClient,使后续类型条件可以预测;资源关闭策略和外部连接由真实组件的AutoCloseable/生命周期处理,本实验客户端没有线程或连接资源。打包starter时通常把依赖聚合与自动配置代码分模块;课程最小工程合在一个制品,外部发布时仍应遵守用户依赖可选、无强迫扫描与可替换接口契约。
spring-autoconfigure-metadata.properties是自动配置条件预筛的提示,由独立的spring-boot-autoconfigure-processor生成;它不是候选名单,也不是IDE属性提示,未生成它不等于imports发现必然失败。本实验不把该优化文件列为正确性前提。
annotation processor的process方法在编译轮次扫描ConfigurationProperties类型,收集属性描述;processingOver时写出元数据文件,不是等SpringApplication.run才执行。annotation processor在编译期生成spring-configuration-metadata.json,供IDE补全、描述和默认值提示;它不会把配置加载到Environment,不会自动启用Bean,也不执行运行时校验。编译失败、处理器没运行或包内无metadata时,IDE可能无提示,但这与运行时绑定是否成功是不同轴。additional-spring-configuration-metadata.json可补人工提示,不能把自动生成文件叫additional文件。
实验在JDK21使用配置处理器依赖生成元数据,并以读取真实jar JSON断言两个属性存在。JDK23默认注解处理策略变化改变处理器自动发现条件,不能外推该pom的隐式发现;跨JDK构建应在maven-compiler-plugin的annotationProcessorPaths显式声明处理器并重验。完整绑定来源、缺失/类型/约束失败见U23,不能拿IDE补全正确证明运行配置正确。依据4.0.9配置处理器源码与官方生成说明(该文档线缓存可能为4.0.8,具体运行锁定4.0.9)。
run:refresh、started、runner、ready
SpringApplication 构造记录 primarySources,根据类路径推断应用类型并加载初始化器和监听器。run 开始准备 bootstrap context、参数与 Environment,绑定主开关,再建立并准备 Context、加载入口定义。不是先创建全部业务对象,再读取 application.yml。
配置数据还受 active profiles、外部位置与属性源优先级影响。生产排障应看有效属性及来源并脱敏,不从仓库默认值推断运行值。例如是否允许循环引用要在准备工厂时应用,不能等业务 Bean 初始化再设置。Servlet/NONE 等 Context 类型也在业务扫描前确定,不是“找到 Controller 才知道要启动服务器”。
原码:SpringApplication.run,v3.3.3,328–355 行,连续节选。固定来源。
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
Banner printedBanner = printBanner(environment);
context = createApplicationContext();
context.setApplicationStartup(this.applicationStartup);
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
afterRefresh(context, applicationArguments);
startup.started();
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), startup);
}
listeners.started(context, startup.timeTakenToStarted());
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
throw handleRunFailure(context, ex, listeners);
}
try {
if (context.isRunning()) {
listeners.ready(context, startup.ready());
}
}
catch (Throwable ex) {
throw handleRunFailure(context, ex, null);
}
return context;顺序明确:refreshContext → afterRefresh → started 回调 → callRunners → 检查 isRunning → ready 回调。runner 抛错走第一处失败出口,不再进入 ready;runner 返回但 Context 已不 running 也跳过 ready。因此 run 返回不能单独证明 ready 发生过。第二处 catch 传 null listeners,不能把所有失败都概括成相同的 failed 回调路径。
EventPublishingRunListener 101–111 行将 started 映射为 ApplicationStartedEvent,再发布 liveness CORRECT;ready 映射为 ApplicationReadyEvent,再发布 readiness ACCEPTING_TRAFFIC。callRunners 收集排序后逐个调用;同一对象若同时实现 ApplicationRunner 和 CommandLineRunner,两条 if 分支都可执行,不能误以为二选一。
候选来自 imports,不是扫描碰巧发现
SpringBootApplication 组合配置类、组件扫描、EnableAutoConfiguration;AutoConfigurationPackage 的包范围与“自动配置候选来自哪里”不是同一问题。候选发现路径为:
@EnableAutoConfiguration
→ @Import(AutoConfigurationImportSelector)
→ DeferredImportSelector 分组处理
→ getAutoConfigurationEntry
→ ImportCandidates.load(AutoConfiguration.class, classLoader)
→ META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsImportCandidates读取类路径同名资源,每行配置类全名,支持空行和注释,不仅取第一个 jar。自动配置可以位于主应用扫描包之外;starter 不应要求用户扫描第三方包。反过来,普通扫描意外扫入自动配置可能破坏延迟处理的先后关系。
“Boot 不再用 spring.factories”也不准确:此版本自动配置候选使用 imports,但初始化器、监听器、导入过滤器等 SPI 仍可能使用 SpringFactoriesLoader。必须指明讨论的是哪一类扩展。
保留旧文原码:AutoConfigurationImportSelector.getAutoConfigurationEntry 局部,v3.3.3,125–134 行。固定来源。
AnnotationAttributes attributes = getAttributes(annotationMetadata);
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
configurations = removeDuplicates(configurations);
Set<String> exclusions = getExclusions(annotationMetadata, attributes);
checkExcludedClasses(configurations, exclusions);
configurations.removeAll(exclusions);
configurations = getConfigurationClassFilter().filter(configurations);
fireAutoConfigurationImportEvents(configurations, exclusions);
return new AutoConfigurationEntry(configurations, exclusions);
}这时处理的是候选类名,不是马上构造默认 Bean。上游启用检查后,得到候选、去重、计算和校验排除、删除排除、过滤,再发导入事件。分组还会按 before/after 等约束排序;这是定义处理顺序,不保证所有实例的构造顺序。需要先有连接池再有客户端,应通过依赖关系等机制表达,不仅写 AutoConfigureAfter。
条件评估看到的是已处理定义
导入元数据预筛、配置解析、Bean 定义登记不是同一次条件判断。类路径条件关注所需类型,属性条件关注有效值,Bean 条件关注已知定义与类型等,三者不能互换。类存在且条件匹配不保证连接参数有效,真正创建时仍可能失败。
原码:OnBeanCondition,v3.3.3,76–82 行。来源。
@Order(Ordered.LOWEST_PRECEDENCE)
class OnBeanCondition extends FilteringSpringBootCondition implements ConfigurationCondition {
@Override
public ConfigurationPhase getConfigurationPhase() {
return ConfigurationPhase.REGISTER_BEAN;
}REGISTER_BEAN 是添加普通 Bean 定义时的配置条件阶段,不表示所有 Bean 已实例化,也不因 LOWEST_PRECEDENCE 就变成“等整个容器全部定义登记完再判断”。ConditionalOnMissingBean 53–56 行明确只能匹配当时已处理的定义;若依赖另一自动配置产生候选,需安排处理顺序。
这个阶段声明也不否认同类还有预过滤逻辑:OnBeanCondition 的 getOutcomes 在元数据层可按缺失类筛选候选。不能把“REGISTER_BEAN”扩大为“从未参与早期过滤”,或把预筛与最终 Bean 条件匹配混成一次扫描。
用户 Bean 退让不是同名覆盖
MissingBean 条件使默认定义不登记,不是默认实例启动后再换成用户实现。BeanDefinition overriding 处理的是同名定义替换许可;开放覆盖容易掩盖名字冲突,不能替代有意设计的退让。
实验中 NoticeAutoConfiguration 只在 study.notice.enabled=true(缺省也启用)时参与,默认 noticeClient 方法又要求没有用户 NoticeClient。主配置提供 userClient 时默认方法退让;若属性禁用自动配置,用户 Bean 仍可存在——自动配置的启用开关不天然控制用户自己的定义。
诊断按证据分支进行:
| 证据 | 结论 | 恢复方向 |
|---|---|---|
| jar 中缺 imports / 未列类名 | 未成为候选 | 修复资源打包注册,不加大业务扫描包 |
| 条件报告显示属性不满足 | 候选被条件否决 | 核对有效属性来源及预期 |
| 条件报告发现用户同类型 Bean | 正常退让或误注册 | 确认用户实现与类型边界 |
| 条件通过,构造连接池抛错 | 实例创建失败 | 修复连接/权限/资源参数,不开定义覆盖 |
SDK/starter 还应定义资源关闭、必要类、启用开关和替换接口。依赖可能缺失的类型应隔离到带类条件的配置,防止条件判断前 JVM 就解析不可用方法签名。Bean 方法声明类型影响类型预测,应验证接口/实现的返回类型是否足够明确;不要在静态块里联网或加载所有驱动。ConfigurationProperties 负责结构化绑定,范围/必填校验负责值有效性,二者也不代替线程池与连接池容量预算。
Servlet 启动的源码边界
本节是固定源码推演,不是 NONE 实验的端口实测。普通嵌入式 Servlet 路径下,ServletWebServerApplicationContext.onRefresh / createWebServer 158–193 行创建服务器并登记生命周期对象。父类 AbstractApplicationContext.finishRefresh 经默认 LifecycleProcessor.onRefresh 启动自动生命周期,再发布 ContextRefreshedEvent。
原码:Servlet 版本 WebServerStartStopLifecycle.start,v3.3.3,42–48 行。来源。
@Override
public void start() {
this.webServer.start();
this.running = true;
this.applicationContext
.publishEvent(new ServletWebServerInitializedEvent(this.webServer, this.applicationContext));
}先 webServer.start,成功返回后置 running=true,再发布 ServletWebServerInitializedEvent。start 抛错不执行后两步;发布事件抛错则不能反推 running 从未置 true。结合finishRefresh 979–994 行、默认生命周期启动,在普通嵌入式 Servlet 与默认生命周期处理器前提下,该事件位于 refresh 内,先于 started、runner、ready。
创建、服务启动、初始化事件、应用 ready 与平台路由流量不是同一个时点。这里没有追踪 Tomcat/Jetty/Undertow 连接器,也不推断端口开始 accept 的精确瞬间;更不能外推到 reactive、外置 Servlet 容器、自定义 lifecycleProcessor 或特殊退出路径。
ready、异步预热与外部探针
runner 同步阻塞 8 秒会推迟 ready;runner 只提交后台任务然后返回,则主线程可以先 ready。该事件不知道任务什么时候真正完成,也不替外部健康检查或真实请求验证作结论。需要关键缓存才能服务时,明确 warming/ready/failed 状态和流量门禁,不能让“启动耗时更短”掩盖冷请求故障。
外部探针看到的是它执行时、它检查范围内的状态,可能与应用事件存在传播延迟。启动观测应分环境、定义、实例、服务器、runner 与业务预热;若配置探针只测端口,又可能漏过业务依赖不可用。管理端点及条件报告包含有效配置与类型信息,也需授权、网络边界和脱敏,不能因内网就全部公开。
实验:NONE 模式的事件和退让
执行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh boot,单模式命令见Boot 实验说明。现有模块固定依赖,无嵌入式 WebServer,不增加 web starter。
六种输入为 default、user、disabled、excluded、user+disabled、runner-fail。前五种断言 Bean 数量/实现、默认定义是否存在、条件报告与 refreshed → started → runner → ready → closed 真实事件。user+disabled 证明用户 Bean 不受默认配置开关控制。runner-fail 验证异常根因为指定 runner 失败、failed/closed 出现、ready 从未出现,并在进入实验 finally 之前断言 Context 已 inactive 且恰好收到一次 closed,输出 frameworkClosedBeforeCleanup=true。这里的失败关闭来自框架 handleRunFailure;实验 finally 再次 close 是兜底,不能把兜底后的 inactive 当成框架关闭证据。成功模式的关闭才由实验 finally 发起。
本批六模式已运行通过;不是打印预期序列充当回调。BootLab 的监听器采集真实事件,ApplicationRunner 产生真实失败。单一真实源文件如下;自动配置和 imports 内容在实验说明中就近嵌入。
package study.boot;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.boot.*;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.*;
import org.springframework.boot.context.event.*;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.*;
import org.springframework.context.event.*;
@Configuration(proxyBeanMethods=false)
@EnableAutoConfiguration
@org.springframework.boot.context.properties.EnableConfigurationProperties(NoticeProperties.class)
public class BootLab {
static final List<String> events = new ArrayList<>();
@Bean
@ConditionalOnProperty(name="lab.user", havingValue="true")
NoticeAutoConfiguration.NoticeClient userClient() { return () -> "user"; }
@Bean
ApplicationRunner probeRunner(org.springframework.core.env.Environment environment) {
return args -> {
events.add("runner");
if (environment.getProperty("lab.runner-fail", Boolean.class, false))
throw new IllegalStateException("synthetic runner failure");
};
}
public static void main(String[] args) {
events.clear();
var context = new AtomicReference<ConfigurableApplicationContext>();
boolean expectedFailure = Arrays.asList(args).contains("--lab.runner-fail=true");
boolean failed = false;
try {
var c = new SpringApplicationBuilder(BootLab.class).web(WebApplicationType.NONE)
.initializers(context::set)
.listeners(event -> {
if (event instanceof ContextRefreshedEvent) events.add("refreshed");
if (event instanceof ApplicationStartedEvent) events.add("started");
if (event instanceof ApplicationReadyEvent) events.add("ready");
if (event instanceof ApplicationFailedEvent) events.add("failed");
if (event instanceof ContextClosedEvent) events.add("closed");
}).run(args);
if (expectedFailure) throw new AssertionError("expected runner failure");
if (!events.equals(List.of("refreshed", "started", "runner", "ready"))) throw new AssertionError(events);
var clients = c.getBeansOfType(NoticeAutoConfiguration.NoticeClient.class);
boolean user = c.getEnvironment().getProperty("lab.user", Boolean.class, false);
boolean enabled = c.getEnvironment().getProperty("study.notice.enabled", Boolean.class, true);
boolean excluded = c.getEnvironment().getProperty("spring.autoconfigure.exclude", "").contains("NoticeAutoConfiguration");
int expected = user || enabled && !excluded ? 1 : 0;
if (clients.size() != expected) throw new AssertionError(clients);
if (expected == 1 && !clients.values().iterator().next().kind().equals(user ? "user" : "auto"))
throw new AssertionError(clients);
if (c.containsBeanDefinition("noticeClient") != (!user && enabled && !excluded))
throw new AssertionError("default definition did not back off");
var report = ConditionEvaluationReport.get(c.getBeanFactory());
var outcomes = report.getConditionAndOutcomesBySource();
String source = NoticeAutoConfiguration.class.getName();
if (excluded) {
if (!report.getExclusions().contains(source)) throw new AssertionError("missing exclusion");
} else {
if (outcomes.get(source) == null || outcomes.get(source).isFullMatch() != enabled)
throw new AssertionError("property condition");
if (enabled && (outcomes.get(source + "#noticeClient") == null
|| outcomes.get(source + "#noticeClient").isFullMatch() == user))
throw new AssertionError("missing-bean condition");
}
System.out.println("Boot conditions candidates=" + clients.size() + " user=" + user + " enabled=" + enabled + " excluded=" + excluded);
} catch (RuntimeException e) {
failed = true;
Throwable cause = e;
while (cause.getCause() != null) cause = cause.getCause();
if (!expectedFailure || !(cause instanceof IllegalStateException)
|| !"synthetic runner failure".equals(cause.getMessage())) throw e;
if (events.contains("ready") || !events.contains("failed")) throw new AssertionError(events);
// Check before our finally cleanup, otherwise it could hide a framework leak.
if (context.get() == null || context.get().isActive()
|| Collections.frequency(events, "closed") != 1)
throw new AssertionError("framework did not close failed context before lab cleanup: " + events);
System.out.println("frameworkClosedBeforeCleanup=true");
} finally {
if (context.get() != null) context.get().close();
}
if (failed != expectedFailure || context.get() == null || context.get().isActive())
throw new AssertionError("failure/close contract");
var expectedEvents = expectedFailure
? List.of("refreshed", "started", "runner", "failed", "closed")
: List.of("refreshed", "started", "runner", "ready", "closed");
if (!events.equals(expectedEvents)) throw new AssertionError(events);
System.out.println("PASS Boot runnerFail=" + failed + " events=" + events);
}
}未测 Servlet 端口、异步 warmup、外部探针、双 runner 接口、自定义生命周期、AOT/native。条件报告示例只覆盖此最小自动配置,不冒充第三方 starter 全部条件。
架构师推演:默认配置也有容量成本
给定 80 个服务,每个 4 实例,默认连接池从 10 调为 30,理论连接上限从 80×4×10=3200 增为 80×4×30=9600。这是上界算例,不是实际连接数或压测值。装配完全成功仍可超过数据库预算。
方案一统一大池、长超时和多重试,接入省事却放大下游压力;方案二保守默认、显式开关、可替换接口与有效配置观测,让服务承担预算,接入成本稍高但可审查。应把默认变化视为行为变更,不仅检查二进制兼容。
升级要固定版本、分批部署、可回滚,灰度看 active/pending、SQL 延迟和错误率以及生效属性来源。验证用户 Bean 仍使默认定义退让,避免无声启动第二个线程池;Context 关闭后资源可回收。AOT/native 的运行时扫描和反射假设会改变,JVM 模式通过不等于原生镜像通过。
口述时依次回答“环境先准备 → imports 发现 → 条件登记 → 对象创建 → runner 与 ready”,连续追问将缺失文件、错误属性、用户定义、构造异常和 runner 异常逐一替换,训练的是定位边界而不是背 SPI 名单。
闭卷验收
本页 14 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
既有四处摘录固定Boot3.3.3/Framework6.1.12,来源及hash就近保留;U22新工程固定Boot4.0.9/Framework7.0.10/JDK21,官方文档线与固定tag分别注明,查询2026-10-26。docs/audits/java-depth-u22-scope.md(范围审读)、docs/audits/java-depth-u22-verification.md(本票验收)区分真实运行、源码推演与未测边界;新工程入口列命令和实际产物。历史事件实验与新版本矩阵不会拼成一次执行。