7.7 JVM 调优:永远排在最后
上一节:7.6 池化与复用 | 下一节:7.8 优化的三问纪律 配套代码:07-optimization-and-validation/07-jvm-tuning
一句话结论
JVM 调优是「榨取最后几个百分点」的手段,不是解决性能问题的方法。 它的收益通常是个位数百分比,而验证成本高、风险大(可能让吞吐下降而延迟只改善一点)。所以它永远排在最后。
一、用「修车」理解 JVM 调优的顺序
车开得慢,你会先检查什么?
| 顺序 | 检查项 | 对应 |
|---|---|---|
| 1 | 轮胎气压是否正常 | 代码合理性(有没有明显的浪费) |
| 2 | 是不是一直踩着刹车 | 有没有阻塞、排队 |
| 3 | 油路是否通畅 | 数据库、网络是否正常 |
| 4 | 驾驶习惯 | 架构、并发模型 |
| 最后 | 调 ECU(发动机控制单元) | JVM 参数 |
如果你先调 ECU,而轮胎没气——你会花几天时间,最后发现没用。
二、JVM 调优能做什么、不能做什么
能做的
| 目标 | 手段 |
|---|---|
| 降低 GC 停顿 | 换低停顿 GC(ZGC/Shenandoah)、调堆、调 region 大小 |
| 提高吞吐 | 换 Parallel GC、加大堆 |
| 降低冷启动时间 | AppCDS、减少类加载 |
| 减少内存占用 | 调堆、调元空间 |
不能做的
| 错误期望 | 现实 |
|---|---|
| 解决「N+1 导致的慢」 | GC 参数与查询次数无关 |
| 解决「连接池排队」 | 与 GC 无关 |
| 解决「锁竞争」 | 换 GC 不会减少锁竞争(可能更糟) |
| 解决「下游慢」 | 完全不相关 |
| 「让代码更快」 | JIT 已经做了优化,参数影响有限 |
一句话:JVM 调优只影响「同一份代码的执行效率」,不影响「代码做了多少工作」。
三、正确的调优顺序
① 确认代码层没有明显浪费
→ 用第 6 章的方法:N+1?缺索引?重复序列化?热路径日志?
→ 这些的收益是数倍级,远超 JVM 调优
② 确认不是排队问题
→ 连接池 pending?线程池队列?调度器饥饿?
→ 这些也是数倍级
③ 建立基线(含 GC 日志)
→ 必须有 before 数据,否则无法判断调优是否有效
④ 一次只改一个参数,测完记录
→ 堆大小、GC 选型、其他 -XX 参数
⑤ 用长期观测验证
→ GC 参数的效果需要覆盖多个 GC 周期与流量形态
四、堆大小:不是越大越好
权衡
| 堆大小 | GC 频率 | 单次停顿 | 内存占用 |
|---|---|---|---|
| 小(如 512 MB) | 高 | 短 | 低 |
| 中(如 4 GB) | 中 | 中 | 中 |
| 大(如 32 GB) | 低 | 长(扫描/复制区域大) | 高 |
关键:加大堆降低频率,但拉长单次停顿。
例:
4 GB 堆:young GC 每 8 秒一次,每次 15 ms → P99 受影响小
16 GB 堆:young GC 每 40 秒一次,每次 80 ms → 频率低但每次停顿更长
对延迟敏感的服务:单次停顿的时长比频率更重要(因为 P99 看的是最慢的那次)。
怎么定堆大小
① 先设成容器内存 limit 的 50%~75%(第 4 章 4.8)
→ 留出元空间、直接内存、线程栈、代码缓存
② 用真实负载压测,看 GC 日志:
→ young GC 频率与停顿
→ 有没有 Full GC(有了必须查明)
③ 如果停顿影响 P99:考虑换低停顿 GC,而不是单纯加堆
⚠️ 容器环境的两个坑
# 坑 1:堆设成容器 limit 的 100% → 必然 OOMKilled
-Xmx2g 而 memory.max = 2g # ❌
-Xmx1400m 而 memory.max = 2g # ✅
# 坑 2:容器里 nproc 显示宿主机核数,JVM 可能误判
# 用 -XX:ActiveProcessorCount 显式指定
java -XX:ActiveProcessorCount=2 -jar app.jar
五、GC 选型:必须用自己的负载测
三种 GC 的取舍
| GC | 停顿 | 吞吐 | 适用 |
|---|---|---|---|
| G1(默认) | 中(可设停顿目标) | 中 | 大多数场景 |
| Parallel | 长 | 最高 | 批处理、离线任务 |
| ZGC | 极低(亚毫秒) | 略低 | 延迟敏感、大堆 |
⚠️ 换 GC 的代价
ZGC 换低停顿,代价通常是:
- 吞吐下降(并发标记需要额外 CPU)
- 内存开销更大(需要额外的着色指针/映射结构)
- CPU 消耗更高(并发 GC 线程与业务线程争抢)
所以如果你的瓶颈是吞吐而不是尾延迟,换 ZGC 可能让整体更差。
验证方法
# 对比两种 GC(同一份代码、同一负载)
java -XX:+UseG1GC -Xlog:gc*:file=gc-g1.log:time,uptime -jar app.jar
# 压测,记录 P50/P99/吞吐
java -XX:+UseZGC -Xlog:gc*:file=gc-zgc.log:time,uptime -jar app.jar
# 同样压测
# 对比
echo "G1 :" && grep -E "Pause" gc-g1.log | awk '{sum+=$NF; n++} END{print "平均停顿", sum/n"ms, 次数", n}'
echo "ZGC:" && grep -E "Pause" gc-zgc.log | awk '{sum+=$NF; n++} END{print "平均停顿", sum/n"ms, 次数", n}'
要同时看:延迟(P50/P99)、吞吐(QPS)、CPU 利用率。只看延迟会被误导。
六、GC 日志:调优的眼睛
没有 GC 日志的调优都是猜。
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -jar app.jar
四个必看的指标
| 指标 | 健康 | 需要关注 |
|---|---|---|
| Young GC 频率 | 与分配速率匹配 | 几秒一次 → 分配速率太高 |
| 停顿时间 | 与延迟预算匹配 | P99 尖刺与停顿时间对齐 |
| 回收效果 | 每次回收大部分 | 回收后基线持续抬升 → 泄漏/晋升 |
| Full GC | 不应出现 | 出现 → 配置问题或泄漏,必须查明 |
关键动作:时间对齐
把 GC 停顿的时间戳与 P99 尖刺的时间戳对齐——对齐则 GC 是根因,不对齐则排除(第 6.6 节)。
不对齐时的结论:不要在这里继续花时间。转向:连接池、慢查询、锁、下游、容器节流。
七、其他常见的 JVM 参数(按需)
| 参数 | 用途 | 注意 |
|---|---|---|
-Xms = -Xmx |
避免堆动态伸缩影响测量 | 生产常见做法 |
-XX:MaxMetaspaceSize |
限制元空间 | 不设则无上限 |
-XX:MaxDirectMemorySize |
限制直接内存(Netty 等) | 不设则默认≈堆大小 |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时自动 dump | 排障必备 |
-XX:ActiveProcessorCount |
容器内显式指定核数 | 影响线程池/GC 线程数 |
-XX:+AlwaysPreTouch |
启动时预触碰堆 | 降低运行时页错误,但延长启动时间 |
-XX:NativeMemoryTracking=summary |
追踪堆外内存 | 有开销(约 5%–10%) |
两个容易忽略的
# ① OOM 时自动 dump(排障必备)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/
# ② 确认 JVM 实际使用的参数(别相信文档)
jcmd <pid> VM.flags
jcmd <pid> VM.system_properties
八、一个反面案例
❌ 一个团队的做法:
① 观察到 P99 高
② 直接上 ZGC + 32GB 堆 + 一堆 -XX 参数
③ 花了两周,P99 从 400ms 降到 360ms(10%)
④ 后来发现:根因是某条 SQL 全表扫描
⑤ 加索引后 P99 降到 90ms(77%)
✅ 正确顺序:
① 用第 6 章的方法定位到缺索引(2 小时)
② 加索引(10 分钟)
③ 验证:P99 400 → 90ms
④ 如果还有余力,再看 GC(那时 GC 参数的边际收益才值得投入)
注意第 ③ 步的对比:两周换 10% vs 两小时换 77%。
九、本节小结
- JVM 调优是「榨取最后几个百分点」,收益是个位数百分比,永远排在最后。
- 它只影响「同一份代码的执行效率」,不影响「代码做了多少工作」——所以与 N+1、缺索引、排队、锁全都无关。
- 堆不是越大越好:加大堆降低 GC 频率,但拉长单次停顿;对延迟敏感的服务,单次停顿时长更重要。
- 容器环境两个坑:堆不能等于容器 limit(会 OOMKilled);容器里
nproc可能显示宿主机核数(用-XX:ActiveProcessorCount)。 - GC 选型必须用自己的负载测,而且要同时看延迟、吞吐、CPU——只看延迟会被误导。
- 没有 GC 日志的调优都是猜;关键是把 GC 停顿与 P99 尖刺做时间对齐。
- 排障必备的两个参数:
-XX:+HeapDumpOnOutOfMemoryError和jcmd <pid> VM.flags(确认真实参数)。
十、自测
- 一个服务的 P99 从 80 ms 涨到 400 ms,CPU 45%。同事建议「先上 ZGC 试试」。请评价这个建议,并说出你会先做什么。
- 堆从 4 GB 加到 16 GB 后,GC 频率降了一半,但 P99 反而变差了。请解释原因。
- 为什么「没有 GC 日志的 JVM 调优都是猜」?请说出 GC 日志里你最关注的两个信息,以及它们分别能排除什么。
- 这个建议方向错了(虽然不一定有害)。理由:① JVM 调优的收益是个位数百分比,而当前 P99 涨了 5 倍(400/80),说明有结构性问题(不是"执行效率略低");② CPU 只有 45% 提示这可能是等待类问题(等 IO/锁/池),而 GC 参数与等待无关;③ 先做什么:按第 6 章的分析流程——(a) 现象量化(哪个接口、何时开始、影响范围);(b) 延迟分解(把 400 ms 拆成网络/排队/应用/依赖);(c) 如果服务排队占大头 → 查连接池
pending、线程池队列、RUNNABLE == 核数且 CPU 低(调度器污染);(d) 如果数据库占大头 → 查pg_stat_statements的慢查询与calls(N+1);(e) 如果应用自身占大头 → 采 CPU 火焰图。只有在这些都排除、且确认 GC 停顿与 P99 尖刺时间对齐之后,才考虑 GC 调优——而且要先建立基线(含 GC 日志)。 - 原因:堆变大 → 单次 GC 需要扫描/复制的区域更大 → 单次停顿变长。GC 频率降低减少了"小停顿"的次数,但每次停顿更久,P99(尾部)反而恶化。另一个可能的原因:Eden 变大后,对象在年轻代停留的时间更长,更多对象被晋升到老年代 → 老年代压力上升 → 可能触发更长的 mixed GC 甚至 Full GC。教训:对延迟敏感的服务,单次停顿的时长比频率更重要(P99 看的是最慢那次);加堆不是万能的,先降分配速率(第 2 章 2.5)往往更有效。
- 因为没有 GC 日志就无法验证效果——JVM 调优的效果不是"感觉快了",而是「停顿时间分布变了多少」「频率变了多少」「有没有引入 Full GC」。最关注的两个信息:① 停顿的时间戳与时长——用于与 P99 尖刺做时间对齐:对齐则 GC 是根因;不对齐则排除 GC(这是一个非常有价值的排除结论,能省下大量时间)。② 每次 GC 的回收量(回收前→后)与堆基线趋势——用于区分「正常的周期性回收」与「内存泄漏/对象大量晋升」:如果每次回收后的低点持续抬升,说明有泄漏或晋升过多(第 3 章 3.5 的浸泡分析方法)。补充第三点:Full GC 是否出现——出现 Full GC 通常意味着配置不当或内存泄漏,是必须查明的严重信号。