先说结论

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 或内存余量。

一次安全的调优实验

  1. 写下假设,例如“订单转换产生大量短命 JSON 中间对象导致 Young GC 频繁”。
  2. 选择一个可验证改动,例如复用/流式化编码,或调整年轻代目标。
  3. 在接近生产的数据、流量和容器限制下压测。
  4. 比较业务 P99、吞吐、分配率、暂停和 CPU,而非只比较一个参数。
  5. 灰度到小流量实例,设置回滚阈值并观察完整高峰。

这样可避免多项参数同时修改后无法判断因果关系。调优记录应保留 JDK 版本、参数、数据集和图表,方便回归。

堆大小与容器预算

-Xmx 不能等于容器 memory limit。进程还需要元空间、代码缓存、直接内存、线程栈、JNI、mmap 和运行时本身的内存。若只把堆压到限制边缘,GC 看起来正常也可能被内核直接杀死。预算要基于真实 RSS 高水位,并留出故障和突发余量。

调优原则

  • 堆大小要给本地内存、线程栈和容器限制留余量。
  • 暂停目标通常是软目标,设置过激可能牺牲吞吐或占用更多资源。
  • 用与生产相近的数据量、流量和对象生命周期压测。

容易踩坑的地方

频繁手工 Full GC 只能暂时改变现象,可能加重停顿。只看平均暂停会掩盖长尾,堆越大也不一定越好,因为回收和故障转储成本会增加。

常见问题

追问:CPU 很高且 GC 频繁怎么办?

先区分是业务计算还是 GC 线程,检查分配速率、存活集和并发周期是否跟不上;再决定减少分配、增加余量或调整收集器,而不是先盲目加堆。

追问:能否通过 System.gc 解决内存问题?

不应作为常规手段。它可能触发昂贵回收且无法修复引用仍然存活的泄漏;只有在受控工具或明确场景下才评估,并遵守 JVM 和运行环境约束。

追问:GC 日志里看到 allocation failure 就是错误吗?

通常不是,它常表示因为分配需要触发一次年轻代回收,是正常工作路径。要结合回收效果、暂停和频率判断。

追问:为什么增加堆有时让 P99 更差?

更大堆可能降低回收频率,却增加单次标记、整理、转储和恢复成本;若根因是分配速率或泄漏,问题只是被延后。