这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 GC 调优不是背参数,而是在明确目标下持续度量、提出假设、验证效果。多数应用不需要激进调优,合理设置堆、CPU、日志和诊断,往往比复制复杂参数更有效。
29.1 调优目标
开始前必须明确目标,常见目标有三种:
| 目标 | 典型指标 |
|---|---|
| 降低延迟 | P99、P999、最大停顿 |
| 提高吞吐 | QPS、批处理总耗时 |
| 降低资源 | CPU、内存、成本 |
目标之间可能冲突:
更小停顿 -> 可能增加屏障和并发成本 -> 吞吐下降
更高吞吐 -> 可能接受更长停顿
更低内存 -> GC 更频繁 -> 延迟抖动
不要说“把 GC 调到最优”,而要说“在 4C8G、P99 不超过 150ms 的前提下,将 GC 停顿 P99 控制在 20ms 内”。
29.2 基线采集
调优前先采集:
- JDK 版本和发行版;
- JVM 参数;
- 堆、CPU、容器 limit;
- QPS 和请求分布;
- P50/P99/P999;
- GC 日志;
- JFR;
- 堆 dump 或 Live 数据估算;
- 机器资源和宿主机干扰;
- 业务特征,如大促、批处理、夜间任务。
基线表:
| 指标 | 当前值 |
|---|---|
| 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 成本高,可以:
- 增大堆或新生代;
- 减少临时对象;
- 复用缓冲;
- 流式处理数据;
- 降低序列化频率。
情况二:Young GC 停顿太长
证据:
Young GC 120ms
存活对象扫描过多
方向:
- 减小新生代;
- 降低单次请求临时对象;
- 检查 Survivor 和晋升;
- 换 G1 或 ZGC;
- 增加并行 GC 线程,前提 CPU 充足;
- 减少线程数。
情况三:对象过早晋升
证据:
每次 Young GC Old 增加
Tenuring 分布显示大量对象 age 1 晋升
方向:
- 增大 Survivor;
- 调整
TargetSurvivorRatio前先验证; - 避免突发大对象;
- 拆分大请求;
- 检查缓存是否存入短生命周期对象。
情况四:Mixed GC 不干净
证据:
G1 Mixed GC 多轮后 Old 仍高
回收集合收益低
方向:
- 检查大对象和 Region 占用;
- 检查缓存生命周期;
- 增加 G1 停顿目标要谨慎;
- 增加
G1MixedGCCountTarget需压测; - 若 Live 数据接近堆上限,扩堆更直接。
情况五:Full GC 频繁
方向:
- 找触发原因;
- 检查是否 System.gc;
- 检查 promotion failed;
- 检查元空间;
- 堆 dump 看 Live;
- 修泄漏后扩堆;
- 更换收集器。
29.5 收集器选择
| 场景 | 起点建议 |
|---|---|
| 小内存、单核或低并发 | Serial |
| 批处理、吞吐优先 | Parallel |
| 常见服务、均衡 | G1 |
| 大堆低延迟 | ZGC |
| 支持并验证过的低延迟方案 | Shenandoah |
选择流程:
明确延迟目标
-> 统计当前停顿分布
-> 判断 G1 是否满足
-> 若不满足且资源充足,测试 ZGC
-> 对比吞吐、CPU、内存、P999
-> 灰度上线
收集器没有绝对最强,只有和负载、资源、版本是否匹配。
29.6 调整堆大小
估算思路:
Live 数据:回收后仍存活的堆占用
余量:应对流量峰值、并发标记、碎片和波动
经验起点:
堆大小约为 Live 数据的 1.5 到 3 倍
这只是估算,不是规则。若 Live 数据 2g,堆 2.2g 通常过紧;4g 可能更稳;但堆外和容器 limit 也必须考虑。
判断堆是否够:
- Full GC 后 Old 是否仍高;
- 并发 GC 是否频繁触发;
- Allocation Stall 是否出现;
- 回收后占用是否持续上升;
- 是否有足够余量应对峰值。
29.7 新生代调整
分代收集器中,新生代大小影响频率和单次停顿:
新生代变大
Young GC 频率下降
单次停顿可能变长
Survivor 容量增加
新生代变小
频率上升
单次停顿可能变短
更容易晋升
G1 通常优先设置停顿目标,让 JVM 控制新生代大小。手动设置 -Xmn 或 NewRatio 可能削弱 G1 的自适应能力。
Parallel 更常显式调整新生代,但仍要基于日志和压测。
29.8 压测方法
每次只改一个关键变量:
| 实验 | 变量 |
|---|---|
| A | 当前基线 |
| B | 堆 4g -> 6g |
| C | G1 PauseTarget 100ms -> 50ms |
| D | ZGC 替代 G1 |
固定条件:
- 相同数据集;
- 相同流量模型;
- 相同预热时间;
- 相同机器资源;
- 相同 JDK 和应用版本;
- 足够长观察窗口;
- 记录多次结果。
输出:
| 实验 | 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
分析:
- GC 本身 70ms;
- 应用停止 300ms;
- 多出 230ms 是安全点等待;
- 线程 dump 中有大循环未及时进入安全点。
处理:
- 将大循环拆小;
- 使用可计数循环结构;
- 压测验证安全点等待;
- 再评估是否需要调整 GC。
如果只调小新生代,GC 停顿可能从 70ms 降到 40ms,但 P999 仍高。
29.10 案例:批处理任务慢
现象:
任务读取大 CSV,解析后写入数据库
总耗时 2h,吞吐低
GC 60 次,停顿总 3s
判断:GC 总停顿只有 3s,不是主要问题。继续 JFR 发现:
60% 时间等待数据库写入
20% CPU 在 JSON 转换
优化:
- JDBC 批量提交;
- 合理事务大小;
- 并行分片;
- 降低每行 JSON 转换;
- 数据库索引和约束检查前移。
GC 调优不能替代系统瓶颈分析。
29.11 上线与回滚
上线步骤:
1. 在预发完成同流量压测
2. 评审参数含义和风险
3. 灰度 1 个或少量实例
4. 对比 GC、延迟、CPU、错误率
5. 观察 24 小时以上
6. 分批推广
7. 保留回滚参数
8. 固化到部署模板
必须回滚的信号:
- P99 或 P999 明显恶化;
- OOM; 3| Allocation Stall 增加;
- CPU 成本超预算;
- Full GC 新增;
- 吞吐下降无法接受。
本章小结
GC 调优要先明确目标、采集基线、确认问题类型,再决定调整堆、新生代、收集器或高级参数。大多数问题来自内存泄漏、异常分配、CPU 不足或配置余量不足,而不是缺少神奇参数。每次调优都应单变量实验,完整对比延迟、吞吐、CPU 和内存,并具备灰度和回滚能力。
思考题
- 为什么吞吐优先和延迟优先的 GC 策略可能冲突?
- 如何判断堆大小是否充足?
- G1 中手动设置
-Xmn有什么风险? - GC 停顿 70ms 但应用停 300ms,应排查什么?
- 如何设计一份 GC 调优报告?