同一份服务包,本机几秒就能启动,换到测试环境却等了很久;有时日志已经出现 Started,发布平台仍显示实例未就绪。先别急着调 JVM 参数,应该先确认时间究竟花在了哪一段。
一次发布可能经过包分发、进程创建、JVM 启动、Spring 初始化、启动任务和流量接入。Spring 日志里的一个耗时值,不能覆盖这条链路上的所有等待。本文以 Spring Boot 3.5、Spring Framework 6.2、HotSpot JDK 17/21 为例;线程采样命令使用 Linux Bash。示例是诊断方法,没有对本站生产服务重启、压测或给出性能改善结论。
先统一“启动完成”的定义
把启动过程拆成下面几个观察点,时间戳必须对应同一次启动、同一个实例:
| 观察点 | 可以说明什么 | 还不能说明什么 |
|---|---|---|
| 发布平台开始部署 | 发布流程已经开始 | Java 进程已经创建 |
| 目标 Java 进程出现 | JVM 已经开始运行 | Spring 上下文已经完成初始化 |
| Web 服务端口开始监听 | 网络入口已经建立 | 启动任务结束、依赖可用、业务可接流量 |
| 出现 Started 日志 / ApplicationStartedEvent | Spring 上下文已刷新 | ApplicationRunner、CommandLineRunner 已完成 |
| ApplicationReadyEvent 与 readiness 接受流量 | 启动任务已执行到相应就绪阶段 | 任意业务请求都一定成功 |
Spring Boot 的事件顺序是先完成上下文刷新,再调用 application / command-line runners,之后进入 ready 阶段。因此,排查“已经 Started 却一直未就绪”,应继续看启动任务和就绪条件,而不是只重复查看 Bean 初始化。事件顺序见 Spring Boot 应用事件文档。
如果服务运行在容器中,镜像拉取、调度和初始化容器的等待应单独计时;它们不属于 JVM 里的 Spring 启动步骤。业务已经就绪、外部却访问不到时,再核对探针、反向代理和切流过程,可接着看部署更新避免短暂 502。
保留能复现差异的对照
先记录实际部署包的版本或校验值、运行 JDK、活动 profile、配置来源、实例资源配额,以及本次启动访问的数据库和外部服务。比较本机和测试环境时,不能只确认代码分支相同,却忽略配置和依赖地址不同。
保留完整启动日志和上述观察点的时间,不要只截最后一行。优先查“从哪一个时间点开始偏离正常启动”,再看这一段里发生的动作。如果一段日志没有输出,也不能直接认定程序停住;它可能正在等待外部连接,也可能是在执行没有进度日志的初始化任务。
如果项目已有 Actuator/Micrometer,application.started.time 与 application.ready.time 可以帮助区分 started 和 ready 的耗时,但要按同一次启动、同一实例比较,它们不是每次请求的延迟,更不等于包含镜像分发的发布总耗时。指标语义见 Spring Boot 启动指标。
对照配置时,可按Spring Boot 配置文件与覆盖顺序确认最终生效值。不要为了做一次对比就同时更换 JDK、profile、数据库和启动参数,否则很难解释结果。
用缓冲步骤定位 Spring 初始化
问题集中在 Spring 上下文初始化阶段时,可以在诊断环境配置 BufferingApplicationStartup,把框架记录的启动步骤暂存在内存中。下面以已有 Spring Boot 应用的入口类为例,包名、类名需要按项目调整:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
@SpringBootApplication
public class ApiApplication {
public static void main(String[] args) {
SpringApplication bootstrap = new SpringApplication(ApiApplication.class);
BufferingApplicationStartup timeline = new BufferingApplicationStartup(4096);
bootstrap.setApplicationStartup(timeline);
bootstrap.run(args);
}
}
4096 是本例的步骤容量,不是毫秒、线程数或通用推荐值。达到容量上限后,新步骤不再被记录;数据缺失时要先考虑容量和采集范围,不能认定那个阶段没有发生。容量与缓冲行为见 BufferingApplicationStartup API。
这项改动需要进入实际运行的构建产物,并在新的诊断启动中生效。已经启动过的进程,不能靠事后改一个配置值恢复此前没有采集的步骤。若项目已有其他 ApplicationStartup 实现,也要先确认当前使用的是哪一种。
有条件地读取 startup 快照
通过 HTTP 读取步骤,还需要项目包含 org.springframework.boot:spring-boot-starter-actuator,并正确配置端点。下面只适用于诊断环境:使用独立的管理端口、仅绑定该进程所在网络环境的回环地址,保留项目原来的认证和访问控制。
management:
server:
port: 9081
address: 127.0.0.1
endpoints:
web:
exposure:
include: "health,startup"
endpoint:
startup:
access: read-only
端口必须与业务端口不同且未被占用。已有管理端口、TLS 或自定义管理路径时,应合并项目实际配置,不能直接覆盖。上述 access 写法按 Boot 3.5 文档使用;端点访问权限与 HTTP exposure 是两项不同设置,更高层的访问限制和 Spring Security 规则仍需一起核对。配置含义见 Actuator 端点访问与暴露规则和独立管理端口说明。
在目标进程对应的网络环境中读取一次快照,保存到新的本地目录。下面使用 Linux Bash,假设管理端点采用上述 HTTP 地址、当前访问已经得到授权;需要登录的项目应沿用现有安全访问方式,不要为了诊断关闭认证。
TASK_STAMP=$(date +%Y%m%d-%H%M%S)
TASK_DIR="./startup-$TASK_STAMP"
umask 077
mkdir "$TASK_DIR" || exit 1
curl --fail --silent --show-error --max-time 10 \
--output "$TASK_DIR/startup.json" \
'http://127.0.0.1:9081/actuator/startup'
如果上下文尚未完成初始化,不要依赖这个 HTTP 端点一定能访问;连接失败也不能直接说明采集配置错误。返回 404 时,再分别核对 Actuator 依赖、缓冲实现、访问权限、暴露设置和实际路径。这个端点需要 BufferingApplicationStartup,仅有 Actuator 依赖并不足够。
读取到 JSON 后,重点看 timeline.events 中的开始时间、结束时间、duration、步骤名称、parentId 和标签。Bean 名等标签可以帮助缩小对象范围;父子步骤存在包含关系,不能把所有 duration 直接相加当成总启动时间,也不能仅凭最外层步骤很长就认定那个 Bean 的某一行代码最慢。
这里使用 GET 获取快照。POST 会把已记录步骤从缓冲区取走,反复执行可能影响后续对照;本文的只读设置不开放这一写操作。请求行为与字段见 startup 端点 API。采集文件可能包含内部 Bean 名和类名,按诊断资料保管,不放进公开下载目录。
启动卡住时,改看线程在等什么
如果程序长时间停在某个阶段,HTTP 端点暂时不可用,可以先对仍在运行的 JVM 取少量线程栈。先确认 PID,使用匹配的 JDK 工具,并以目标 JVM 相同的有效用户和组运行;容器内还要核对 PID 命名空间及 Attach 所需路径。
# Linux Bash;PID 必须替换为本次待查 Java 进程
APP_PID=12345
TASK_STAMP=$(date +%Y%m%d-%H%M%S)
TASK_DIR="./startup-threads-$APP_PID-$TASK_STAMP"
umask 077
mkdir "$TASK_DIR" || exit 1
jcmd "$APP_PID" VM.version || exit 1
jcmd "$APP_PID" help Thread.print || exit 1
for TASK_SAMPLE in 1 2 3; do
jcmd "$APP_PID" Thread.print -l > "$TASK_DIR/threads-$TASK_SAMPLE.txt" || exit 1
if [ "$TASK_SAMPLE" -lt 3 ]; then sleep 5; fi
done
三份样本用于观察等待位置是否持续,不是高频监控。Thread.print 的影响与线程数量有关,官方标为中等影响,不能无限循环采样。工具条件和命令见 JDK 21 jcmd 文档。
先看负责初始化的调用链,再追它依赖的工作线程。主线程在等 Future 时,还要确认任务交给了哪个执行器、是否在排队;不能只因为主线程状态是 WAITING 就断定死锁。线程状态、业务调用链和连续样本应一起判断。
把耗时归回具体依赖与任务
拿到步骤和线程栈后,按等待对象继续查,而不是对所有启动慢的问题使用同一个参数:
| 线索 | 接着核对什么 |
|---|---|
| 尚未进入明显的 Bean 初始化,配置加载阶段长时间等待 | 实际 profile、配置导入来源、外部配置服务和重试等待 |
| 数据源相关步骤很长,栈中反复等待连接或数据库响应 | DNS、网络、连接参数、数据库可用性、迁移与初始化 SQL |
| 某个 Bean 初始化触发大量远程读取或文件扫描 | 该 Bean 的初始化逻辑、访问范围、超时与重复工作 |
| Started 之后到 ready 之间很长 | ApplicationRunner、CommandLineRunner、同步预热及监听器中的工作 |
| 主线程等异步任务,工作线程迟迟不能推进 | 执行器归属、队列、下游阻塞和任务间依赖 |
这些是排查方向,不能只靠出现某个类名就下结论。比如一次数据库迁移时间较长,不等于数据库连接池太小;启动时扩大连接数,也不一定能缩短串行迁移的耗时。
连接等待可接着看数据库连接池耗尽,远程调用等待可对照 Java HTTP 超时排查,异步任务积压可对照线程池排队与拒绝。这些文章用于继续缩小范围,不代表本次故障已经被证明属于其中某一类。
启动步骤不是对每个业务方法的完整性能采样。框架记录哪些步骤、提供哪些标签,取决于对应组件的埋点;需要更细的 CPU、分配或类加载证据时,再在受控环境补充 JVM 采样。关于步骤的定位和用途,可参考 Spring Application Startup Tracking。
调整前先分清成本移到哪里
懒加载可能缩短启动阶段,却把部分 Bean 创建和失败发现推迟到第一次访问。若尝试 spring.main.lazy-initialization,应同时检查首个请求的延迟、错误发现时机和最终内存需求,不能只看 Started 更早出现就宣称服务整体更快。Boot 默认没有启用全局懒初始化,边界见 Lazy Initialization。
把必要初始化直接丢进后台线程也有类似问题。若它决定业务能否正常工作,就需要与就绪条件协调;不能一边向外接流量,一边假设后台迟早会准备好。并行化前,还要确认依赖关系、幂等和下游承载能力,不能把串行等待改成同时压向数据库。
--debug 主要帮助查看自动配置条件报告,并不是逐方法耗时分析器。扩大包扫描范围、打开大量 DEBUG 日志、同时修改连接超时和线程数,都会干扰对照。应先确认一个具体慢点,一次只做一类变更。
用相同条件复测到真正就绪
至少对比进程创建、Spring started、业务 ready 和外部接流量几个时间点,并记录包版本、配置和资源配额。冷启动与已有缓存的重启应分别观察;数据库迁移、外部服务状态等条件不一致时,不能把两次耗时直接当作优化前后结果。
复测要覆盖启动后的首个有效业务请求和必要依赖,而不只验证端口打开。还要确认诊断端点没有被意外公开,采集容量、日志量和采样开销可接受;完成后按项目要求恢复临时诊断设置,保留变更前后的证据和回退条件。
若是健康检查仍然 DOWN,继续按健康检查排查区分组件状态和探针用途。更多入口可从 Java 后端排障专题与 Spring Boot 教程继续查找。
资料于 2026-11-04 按所列官方文档核对。本文示例用于定位启动阶段的耗时与等待,没有提供本站生产环境的启动基线或优化实测数据。
Spring Boot 启动很慢怎么排查
https://java.li/archives/spring-boot-slow-startup-troubleshooting
评论