JVMNotes

第 29 章:GC 调优实战

zjc 于 2026-01-29 发布

这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 GC 调优不是背参数,而是在明确目标下持续度量、提出假设、验证效果。多数应用不需要激进调优,合理设置堆、CPU、日志和诊断,往往比复制复杂参数更有效。

29.1 调优目标

开始前必须明确目标,常见目标有三种:

目标 典型指标
降低延迟 P99、P999、最大停顿
提高吞吐 QPS、批处理总耗时
降低资源 CPU、内存、成本

目标之间可能冲突:

更小停顿 -> 可能增加屏障和并发成本 -> 吞吐下降
更高吞吐 -> 可能接受更长停顿
更低内存 -> GC 更频繁 -> 延迟抖动

不要说“把 GC 调到最优”,而要说“在 4C8G、P99 不超过 150ms 的前提下,将 GC 停顿 P99 控制在 20ms 内”。

29.2 基线采集

调优前先采集:

  1. JDK 版本和发行版;
  2. JVM 参数;
  3. 堆、CPU、容器 limit;
  4. QPS 和请求分布;
  5. P50/P99/P999;
  6. GC 日志;
  7. JFR;
  8. 堆 dump 或 Live 数据估算;
  9. 机器资源和宿主机干扰;
  10. 业务特征,如大促、批处理、夜间任务。

基线表:

指标 当前值
QPS 1200
P99 180ms
Young GC 频率 8 次/分钟
Young GC 平均停顿 35ms
Full GC 2 次/小时
回收后 Old 2.8g
4g
CPU limit 4 核

没有基线,就无法证明调优是否有效。

29.3 判断是否有问题

现象 是否需要调优
Young GC 频繁但停顿 5ms 视业务目标
P99 正常但平均 GC 低 通常不需要
Full GC 偶发于发布后 先看预热和缓存
GC 停顿超过延迟目标 需要
Allocation Stall 需要
吞吐下降明显 需要
OOM 必须处理

优先级:

1. 修复泄漏和异常 Full GC
2. 降低异常分配
3. 合理设置堆和 CPU
4. 调整新生代或收集器
5. 微调高级参数

29.4 常见问题与处理

情况一:Young GC 太频繁

证据:

Young GC 20 次/分钟
每次回收 Eden 512M -> 32M
停顿 8ms
P99 正常

若延迟目标满足,可以不调。若 CPU 成本高,可以:

  1. 增大堆或新生代;
  2. 减少临时对象;
  3. 复用缓冲;
  4. 流式处理数据;
  5. 降低序列化频率。

情况二:Young GC 停顿太长

证据:

Young GC 120ms
存活对象扫描过多

方向:

  1. 减小新生代;
  2. 降低单次请求临时对象;
  3. 检查 Survivor 和晋升;
  4. 换 G1 或 ZGC;
  5. 增加并行 GC 线程,前提 CPU 充足;
  6. 减少线程数。

情况三:对象过早晋升

证据:

每次 Young GC Old 增加
Tenuring 分布显示大量对象 age 1 晋升

方向:

  1. 增大 Survivor;
  2. 调整 TargetSurvivorRatio 前先验证;
  3. 避免突发大对象;
  4. 拆分大请求;
  5. 检查缓存是否存入短生命周期对象。

情况四:Mixed GC 不干净

证据:

G1 Mixed GC 多轮后 Old 仍高
回收集合收益低

方向:

  1. 检查大对象和 Region 占用;
  2. 检查缓存生命周期;
  3. 增加 G1 停顿目标要谨慎;
  4. 增加 G1MixedGCCountTarget 需压测;
  5. 若 Live 数据接近堆上限,扩堆更直接。

情况五:Full GC 频繁

方向:

  1. 找触发原因;
  2. 检查是否 System.gc;
  3. 检查 promotion failed;
  4. 检查元空间;
  5. 堆 dump 看 Live;
  6. 修泄漏后扩堆;
  7. 更换收集器。

29.5 收集器选择

场景 起点建议
小内存、单核或低并发 Serial
批处理、吞吐优先 Parallel
常见服务、均衡 G1
大堆低延迟 ZGC
支持并验证过的低延迟方案 Shenandoah

选择流程:

明确延迟目标
  -> 统计当前停顿分布
  -> 判断 G1 是否满足
  -> 若不满足且资源充足,测试 ZGC
  -> 对比吞吐、CPU、内存、P999
  -> 灰度上线

收集器没有绝对最强,只有和负载、资源、版本是否匹配。

29.6 调整堆大小

估算思路:

Live 数据:回收后仍存活的堆占用
余量:应对流量峰值、并发标记、碎片和波动

经验起点:

堆大小约为 Live 数据的 1.5 到 3 倍

这只是估算,不是规则。若 Live 数据 2g,堆 2.2g 通常过紧;4g 可能更稳;但堆外和容器 limit 也必须考虑。

判断堆是否够:

  1. Full GC 后 Old 是否仍高;
  2. 并发 GC 是否频繁触发;
  3. Allocation Stall 是否出现;
  4. 回收后占用是否持续上升;
  5. 是否有足够余量应对峰值。

29.7 新生代调整

分代收集器中,新生代大小影响频率和单次停顿:

新生代变大
  Young GC 频率下降
  单次停顿可能变长
  Survivor 容量增加

新生代变小
  频率上升
  单次停顿可能变短
  更容易晋升

G1 通常优先设置停顿目标,让 JVM 控制新生代大小。手动设置 -XmnNewRatio 可能削弱 G1 的自适应能力。

Parallel 更常显式调整新生代,但仍要基于日志和压测。

29.8 压测方法

每次只改一个关键变量:

实验 变量
A 当前基线
B 堆 4g -> 6g
C G1 PauseTarget 100ms -> 50ms
D ZGC 替代 G1

固定条件:

  1. 相同数据集;
  2. 相同流量模型;
  3. 相同预热时间;
  4. 相同机器资源;
  5. 相同 JDK 和应用版本;
  6. 足够长观察窗口;
  7. 记录多次结果。

输出:

实验 QPS P50 P99 P999 GC 时间占比 最大停顿 CPU 内存
A 1200 40ms 180ms 900ms 4% 240ms 70% 4g
B 1250 39ms 120ms 500ms 2% 90ms 68% 6g
C 1180 41ms 110ms 420ms 3% 60ms 74% 6g

只有完整权衡才能决定上线方案。

29.9 案例:接口 P999 抖动

现象:

日常 P99 80ms,P999 偶尔 800ms
日志显示 G1 Young GC Pause 70ms
安全点总停止 300ms

分析:

  1. GC 本身 70ms;
  2. 应用停止 300ms;
  3. 多出 230ms 是安全点等待;
  4. 线程 dump 中有大循环未及时进入安全点。

处理:

  1. 将大循环拆小;
  2. 使用可计数循环结构;
  3. 压测验证安全点等待;
  4. 再评估是否需要调整 GC。

如果只调小新生代,GC 停顿可能从 70ms 降到 40ms,但 P999 仍高。

29.10 案例:批处理任务慢

现象:

任务读取大 CSV,解析后写入数据库
总耗时 2h,吞吐低
GC 60 次,停顿总 3s

判断:GC 总停顿只有 3s,不是主要问题。继续 JFR 发现:

60% 时间等待数据库写入
20% CPU 在 JSON 转换

优化:

  1. JDBC 批量提交;
  2. 合理事务大小;
  3. 并行分片;
  4. 降低每行 JSON 转换;
  5. 数据库索引和约束检查前移。

GC 调优不能替代系统瓶颈分析。

29.11 上线与回滚

上线步骤:

1. 在预发完成同流量压测
2. 评审参数含义和风险
3. 灰度 1 个或少量实例
4. 对比 GC、延迟、CPU、错误率
5. 观察 24 小时以上
6. 分批推广
7. 保留回滚参数
8. 固化到部署模板

必须回滚的信号:

  1. P99 或 P999 明显恶化;
  2. OOM; 3| Allocation Stall 增加;
  3. CPU 成本超预算;
  4. Full GC 新增;
  5. 吞吐下降无法接受。

本章小结

GC 调优要先明确目标、采集基线、确认问题类型,再决定调整堆、新生代、收集器或高级参数。大多数问题来自内存泄漏、异常分配、CPU 不足或配置余量不足,而不是缺少神奇参数。每次调优都应单变量实验,完整对比延迟、吞吐、CPU 和内存,并具备灰度和回滚能力。

思考题

  1. 为什么吞吐优先和延迟优先的 GC 策略可能冲突?
  2. 如何判断堆大小是否充足?
  3. G1 中手动设置 -Xmn 有什么风险?
  4. GC 停顿 70ms 但应用停 300ms,应排查什么?
  5. 如何设计一份 GC 调优报告?