Spring 第一次启动执行慢,从 JVM 角度讲讲?
结论先行:首次启动慢,JVM 侧的原因可以归成三类:类加载与验证(成千上万个类要读、验、解析)、解释执行与 JIT 未预热(Spring 启动期大量反射与字节码操作跑在解释器上)、CPU 与内存的资源竞争(JIT 编译线程、GC 初始化、元空间扩容)。第二次启动变快,往往只是操作系统页缓存与 CDS 存档命中的功劳,并不代表代码变快了。
一、启动时间都花在哪
| 阶段 | 典型占比 | JVM 侧原因 |
|---|---|---|
| JVM 初始化 | 3%~8% | 堆初始化、GC 线程创建、JIT 编译器初始化、随机数种子 |
| 类加载与验证 | 20%~35% | 读 class、校验字节码、解析常量池,Spring 动辄加载上万类 |
| Bean 定义扫描 | 20%~40% | 反射读注解、ASM 解析 class、getDeclaredMethods |
| 单例实例化 | 15%~30% | 反射调用构造器、CGLIB 生成代理类、元空间增长 |
| 端口与外部依赖 | 视情况 | 数据库连接池、注册中心、配置中心首次握手 |
启动时间线:
0s ─ JVM 初始化 ─ 类加载 ─ 注解扫描 ─ Bean 创建 ─ 上下文刷新 ─ 就绪
│ │ │ │
│ │ │ └─ CGLIB 生成代理:新类 → 元空间扩容
│ │ └─ 反射/ASM:全在解释执行,未达 JIT 阈值
│ └─ 验证 + 解析:每个类都要走一遍
└─ 首次启动无 CDS 缓存,页缓存也是冷的
二、能直接量化的证据
# 1. 看类加载耗时:统计 ClassLoad 事件数
java -Xlog:class+load=info -jar app.jar 2>&1 | grep -c "source:"
# 2. 看启动期有多少方法被编译(一般极少,说明大部分在解释执行)
java -XX:+PrintCompilation -jar app.jar 2>&1 | head -30
# 3. 看各阶段耗时(Spring Boot 自带)
java -jar app.jar --debug 2>&1 | grep -E "Started .* in "
# 4. 启动期的 GC 与元空间
java -Xlog:gc*,gc+metaspace=info -jar app.jar 2>&1 | head -20
三、四个见效最快的调整
# ① CDS / AppCDS:把类加载与验证结果存档,二次启动直接映射
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # 首次生成
java -XX:SharedArchiveFile=app.jsa -jar app.jar # 后续启动
# ② 启动期降级编译:只到 C1,省掉 C2 的编译开销(短生命周期进程适用)
java -XX:TieredStopAtLevel=1 -jar app.jar
# ③ 固定堆、限制 JIT 线程,避免与业务线程抢 CPU
java -Xms2g -Xmx2g -XX:CICompilerCount=2 -jar app.jar
# ④ 容器里给足 CPU 与熵
java -Djava.security.egd=file:/dev/./urandom -XX:ActiveProcessorCount=4 -jar app.jar
TieredStopAtLevel=1 在长驻服务上不推荐,它牺牲峰值性能换启动速度,只适合 CLI、函数计算、批处理。
四、启动耗时该量到什么粒度
| 指标 | 采集方式 | 参考基线(中型服务) |
|---|---|---|
| JVM 启动到 main 入口 | -Xlog:init 或用包装脚本打时间戳 | 0.2~1s |
| 类加载总数 | -Xlog:class+load=info 计数 | 8000~20000 个 |
| 类加载总耗时 | JFR ClassLoad 事件 | 1~4s |
| 首个 Bean 就绪 | Spring --debug 的启动报告 | 视规模 |
| 启动期 GC 次数 | -Xlog:gc 计数 | 0~2 次 |
| 元空间最终占用 | jcmd VM.metaspace | 80~250MB |
先有基线再谈优化。把上面六个数记进发布检查单,下一次「启动变慢」才有的比,而不是凭感觉。
五、常见追问
| 追问 | 回答要点 |
|---|---|
| 为什么第二次启动快很多 | 操作系统页缓存命中 + CDS 存档命中,类加载的磁盘 I/O 与验证开销大幅下降 |
| 启动慢一定是 JVM 问题吗 | 不,数据库连接池初始化、注册中心超时、DNS 解析、随机数熵不足都更常见 |
| 元空间为什么会拖慢启动 | 大量动态代理类触发元空间扩容,每次扩容可能伴随 Full GC |
| Spring 6 有什么改善 | AOT 处理 + GraalVM native image,把 Bean 定义在构建期算好,启动进入百毫秒级 |
| 怎么定位到具体 Bean | --debug 看条件评估报告;或用 JFR 的 ExecutionSample 抓启动期热点方法 |
小结
首启慢是「类加载 + 解释执行 + 资源竞争」三件事叠加的结果,CDS、编译层级、CPU 配额是三条最快的优化路径。判断是否值得优化,先看启动耗时是否落在扩缩容与发布的关键路径上。