先说结论
GC 调优先确认问题确实来自 GC,再用暂停分位数、频率、吞吐损失、分配速率、晋升量和回收后占用建立基线。优先减少不必要分配和内存滞留,然后才调整堆、收集器和停顿目标,每次只改有限变量并压测验证。
低延迟、批处理吞吐和小容器实例的目标不同,不存在适用于所有应用的一组“最佳 JVM 参数”。
诊断路径
开启统一 GC 日志并关联请求延迟。Young GC 过频要看分配速率和年轻代;Full GC 后占用持续上升要检查泄漏或缓存;晋升失败和疏散失败则关注存活对象、堆余量与并发回收时机。
先建立可量化基线
调优前至少采集一段包含高峰和低谷的时间窗口:
- 请求吞吐、P50/P95/P99、超时率和错误率。
- JVM 堆使用曲线、各代占用、直接内存、线程数和容器 RSS。
- GC 次数、暂停时间、并发周期时间、回收前后占用。
- 每秒分配字节数、晋升量、存活集大小。
- CPU、磁盘 I/O、页故障和是否发生 cgroup 内存压力。
只看“GC 次数多不多”无法决定优化方向。每秒几十次很短的 Young GC 可能完全正常;一次数秒的停顿即使频率低,也可能严重破坏延迟 SLO。
按现象定位根因
| 现象 | 更可能的方向 | 首先验证什么 |
|---|---|---|
| Young GC 很频繁 | 分配速率高、年轻代太小 | 分配火焰图、对象生命周期 |
| 老年代稳步上升 | 泄漏、缓存无上限、晋升过快 | Heap Dump、存活对象与 GC Root |
| Full GC 后仍占很高 | 长生命周期对象或泄漏 | 类别直方图、引用链 |
| 并发收集跟不上 | CPU 不足、堆余量太小、分配突增 | 并发周期、CPU 与存活集 |
| 容器被 OOM Kill | 堆外内存或 heap 配置过满 | RSS、直接缓冲、线程栈、cgroup 限制 |
收集器选择思路
吞吐优先的批处理、延迟敏感在线服务、超大堆和小容器的需求不同。先看目标 JDK 默认收集器能否满足 SLO,再评估 G1、ZGC 等收集器在当前 JDK 的成熟度、内存开销和运维经验。不要只因为“某收集器停顿更低”就迁移,低延迟往往会增加 CPU 或内存余量。
一次安全的调优实验
- 写下假设,例如“订单转换产生大量短命 JSON 中间对象导致 Young GC 频繁”。
- 选择一个可验证改动,例如复用/流式化编码,或调整年轻代目标。
- 在接近生产的数据、流量和容器限制下压测。
- 比较业务 P99、吞吐、分配率、暂停和 CPU,而非只比较一个参数。
- 灰度到小流量实例,设置回滚阈值并观察完整高峰。
这样可避免多项参数同时修改后无法判断因果关系。调优记录应保留 JDK 版本、参数、数据集和图表,方便回归。
堆大小与容器预算
-Xmx 不能等于容器 memory limit。进程还需要元空间、代码缓存、直接内存、线程栈、JNI、mmap 和运行时本身的内存。若只把堆压到限制边缘,GC 看起来正常也可能被内核直接杀死。预算要基于真实 RSS 高水位,并留出故障和突发余量。
调优原则
- 堆大小要给本地内存、线程栈和容器限制留余量。
- 暂停目标通常是软目标,设置过激可能牺牲吞吐或占用更多资源。
- 用与生产相近的数据量、流量和对象生命周期压测。
容易踩坑的地方
频繁手工 Full GC 只能暂时改变现象,可能加重停顿。只看平均暂停会掩盖长尾,堆越大也不一定越好,因为回收和故障转储成本会增加。
常见问题
追问:CPU 很高且 GC 频繁怎么办?
先区分是业务计算还是 GC 线程,检查分配速率、存活集和并发周期是否跟不上;再决定减少分配、增加余量或调整收集器,而不是先盲目加堆。
追问:能否通过 System.gc 解决内存问题?
不应作为常规手段。它可能触发昂贵回收且无法修复引用仍然存活的泄漏;只有在受控工具或明确场景下才评估,并遵守 JVM 和运行环境约束。
追问:GC 日志里看到 allocation failure 就是错误吗?
通常不是,它常表示因为分配需要触发一次年轻代回收,是正常工作路径。要结合回收效果、暂停和频率判断。
追问:为什么增加堆有时让 P99 更差?
更大堆可能降低回收频率,却增加单次标记、整理、转储和恢复成本;若根因是分配速率或泄漏,问题只是被延后。