文档目录

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%。


九、本节小结

  1. JVM 调优是「榨取最后几个百分点」,收益是个位数百分比,永远排在最后。
  2. 它只影响「同一份代码的执行效率」,不影响「代码做了多少工作」——所以与 N+1、缺索引、排队、锁全都无关。
  3. 堆不是越大越好:加大堆降低 GC 频率,但拉长单次停顿;对延迟敏感的服务,单次停顿时长更重要。
  4. 容器环境两个坑:堆不能等于容器 limit(会 OOMKilled);容器里 nproc 可能显示宿主机核数(用 -XX:ActiveProcessorCount)。
  5. GC 选型必须用自己的负载测,而且要同时看延迟、吞吐、CPU——只看延迟会被误导。
  6. 没有 GC 日志的调优都是猜;关键是把 GC 停顿与 P99 尖刺做时间对齐。
  7. 排障必备的两个参数:-XX:+HeapDumpOnOutOfMemoryError 和 jcmd <pid> VM.flags(确认真实参数)。

十、自测

  1. 一个服务的 P99 从 80 ms 涨到 400 ms,CPU 45%。同事建议「先上 ZGC 试试」。请评价这个建议,并说出你会先做什么。
  2. 堆从 4 GB 加到 16 GB 后,GC 频率降了一半,但 P99 反而变差了。请解释原因。
  3. 为什么「没有 GC 日志的 JVM 调优都是猜」?请说出 GC 日志里你最关注的两个信息,以及它们分别能排除什么。
  1. 这个建议方向错了(虽然不一定有害)。理由:① 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 日志)。
  2. 原因:堆变大 → 单次 GC 需要扫描/复制的区域更大 → 单次停顿变长。GC 频率降低减少了"小停顿"的次数,但每次停顿更久,P99(尾部)反而恶化。另一个可能的原因:Eden 变大后,对象在年轻代停留的时间更长,更多对象被晋升到老年代 → 老年代压力上升 → 可能触发更长的 mixed GC 甚至 Full GC。教训:对延迟敏感的服务,单次停顿的时长比频率更重要(P99 看的是最慢那次);加堆不是万能的,先降分配速率(第 2 章 2.5)往往更有效。
  3. 因为没有 GC 日志就无法验证效果——JVM 调优的效果不是"感觉快了",而是「停顿时间分布变了多少」「频率变了多少」「有没有引入 Full GC」。最关注的两个信息:① 停顿的时间戳与时长——用于与 P99 尖刺做时间对齐:对齐则 GC 是根因;不对齐则排除 GC(这是一个非常有价值的排除结论,能省下大量时间)。② 每次 GC 的回收量(回收前→后)与堆基线趋势——用于区分「正常的周期性回收」与「内存泄漏/对象大量晋升」:如果每次回收后的低点持续抬升,说明有泄漏或晋升过多(第 3 章 3.5 的浸泡分析方法)。补充第三点:Full GC 是否出现——出现 Full GC 通常意味着配置不当或内存泄漏,是必须查明的严重信号。