Skip to content

JVM 调优与线上排查:先知道什么是正常,再判断什么是异常 ​

「GC 太频繁了要不要调参」——在回答之前先看数据:实测同一段程序 8 秒内,512MB 堆跑出 517 次 Young GC、平均暂停 2.07ms;2GB 堆只有 39 次,平均却是 6.99ms。次数和单次暂停往相反方向变,要看的是两者的乘积,以及最长的那一次业务能不能接受。 没有基线的调优就是在猜。

本文的数据都在 temurin 21.0.12 容器(2 CPU,G1 GC,默认参数)里实测,见文末配套实验。

一、先说结论 ​

  • 调优的目标不是「GC 次数少」,而是「总停顿占比和最长暂停都在业务可接受范围内」。堆越大,GC 次数越少但单次越长。
  • 先定基线:正常时的 GC 频率、暂停时间、堆使用曲线、线程数是多少。不知道正常值,就无法判断异常。
  • 排查按现象分三条线:内存问题看堆和对象分布,CPU 问题看线程栈,停顿问题看 GC 日志。
  • -XX:+HeapDumpOnOutOfMemoryError 必须开,但要知道代价:实测 256MB 堆 OOM 时生成了 244MB 的 hprof 文件,大堆上转储会很慢,文件也很大。
  • JDK 21 的默认值通常够用:G1 GC、MaxGCPauseMillis=200、最大堆为物理内存(容器里是内存 limit)的 1/4。改之前先证明默认值不够。

二、先看默认值 ​

bash
java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|MaxGCPauseMillis|InitialHeapSize|MaxHeapSize"

在内存 limit 为 2GB 的容器里实测:

text
UseG1GC             = true          # JDK 9 起的默认收集器
MaxGCPauseMillis    = 200           # G1 的暂停目标,默认 200ms
G1HeapRegionSize    = 1048576       # 1MB,按堆大小自动推导
InitialHeapSize     = 33554432      # 32MB,内存的 1/64
MaxHeapSize         = 536870912     # 512MB,内存的 1/4
UseContainerSupport = true
MaxRAMPercentage    = 25.000000

同样的镜像换成 4GB 的容器,最大堆就变成 1GB:JVM 读取的是 cgroup 限制(UseContainerSupport 默认开启),所以不需要手动算内存,但要确认容器的内存 limit 设置正确。没有设置 limit 时,JVM 看到的是宿主机内存。

默认只给堆 1/4 的内存,对只跑一个 Java 进程的容器来说偏保守。常用的容器内堆设置:

bash
-XX:MaxRAMPercentage=70.0    # 堆占容器内存的比例,给堆外和元空间留余量

实测 2GB 容器加上这个参数后,最大堆约为 1.4GB。

三、堆大小与 GC 的关系 ​

-Xmx512m-Xmx2g517 次Young GC平均 2.07ms单次暂停合计 1,071ms8 秒内39 次Young GC平均 6.99ms单次暂停合计 265ms8 秒内堆越大:GC 次数越少、单次暂停越长;次数 × 单次才是总停顿,要两个一起看调优目标不是「GC 次数少」,而是「总停顿占比和最长暂停都在业务可接受范围内」
图 1 · 同一段分配密集的程序(约 2.5GB/s)运行 8 秒,3 次取中位数:堆从 512MB 加到 2GB,Young GC 从 517 次降到 39 次,单次平均暂停从 2.07ms 变成 6.99ms,合计停顿从 1,071ms 降到 265ms

同一段分配密集的程序(每秒分配约 2.5GB,1% 的对象长期存活)运行 8 秒,每种参数跑 3 次取中位数:

参数Young GC 次数平均暂停最长暂停合计停顿并发标记周期Full GC
-Xmx512m5172.07ms4.07ms1,071ms860
-Xmx2g396.99ms13.82ms265ms40
-Xmx512m -XX:MaxGCPauseMillis=505042.19ms4.89ms1,090ms860

三个结论:

  1. 堆变大,GC 次数减少、单次暂停变长。 年轻代更大,每次要处理的对象更多。这次负载下,次数减少得更多,合计停顿从 1,071ms 降到 265ms;但最长暂停从 4ms 涨到 14ms。哪个更好取决于业务更在意吞吐还是单次延迟,所以要把「次数 × 单次」和最长暂停一起看。
  2. 暂停目标只在实际暂停接近它时才起作用。 把 MaxGCPauseMillis 从 200 降到 50,结果没有可见变化:暂停本来只有几毫秒,远低于目标,G1 没有理由缩小年轻代。反过来,目标设得过低会让年轻代太小、GC 更频繁、吞吐下降,而且 G1 不能保证一定达到。
  3. 没有 Full GC 不代表没问题。 512MB 堆下 8 秒跑了 86 个并发标记周期,它们在后台和业务线程抢 CPU。

四、三条排查线 ​

现象RT 上升 / CPU 高 / OOM内存问题:jcmd GC.heap_infoCPU 问题:jstack / 火焰图停顿问题:-Xlog:gc堆转储 jmap -dump / OOM 自动JFR 记录:jcmd JFR.startGC 日志分析工具实测 256MB 堆 OOM:保留 64KB 数组时转储 244MB,保留 1MB 大对象时只有 133MB转储文件接近存活对象的大小,最坏接近堆的上限;磁盘空间与转储耗时要按堆大小提前评估
图 2 · 先用指标确定问题类型,再用对应工具取证;每一步都要保留现场,而不是直接重启

4.1 先看堆:内存问题 ​

bash
jcmd <pid> GC.heap_info

在上面的负载运行时实测输出:

text
 garbage-first heap   total 524288K, used 441344K [0x00000000e0000000, 0x0000000100000000)
  region size 1024K, 13 young (13312K), 6 survivors (6144K)
 Metaspace       used 9777K, committed 9984K, reserved 1114112K
  class space    used 1147K, committed 1280K, reserved 1048576K

持续观察用 jstat:

bash
jstat -gcutil <pid> 1000 3
text
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT   
     - 100.00   5.48  63.68  97.93  89.67    499     1.143     0     0.000   179     0.106     1.249
     - 100.00  70.37  59.25  97.93  89.67    560     1.276     0     0.000   201     0.119     1.395
     - 100.00  91.46  58.57  97.93  89.67    622     1.409     0     0.000   223     0.131     1.540

要看的是趋势而不是瞬时值:

  • O(老年代使用率)在每次 GC 后是否回落。只涨不落是内存泄漏的典型信号。
  • YGC 与 YGCT 的增长速度,算出「每秒 GC 几次、每次多久」。上面每秒约 65 次 Young GC,每次约 2ms。
  • CGC 统计的是并发周期里的暂停次数(Remark 与 Cleanup 各算一次),不是周期数。
  • M(Metaspace)持续增长,通常是类加载器泄漏(动态代理、热部署、脚本引擎)。

4.2 拿到证据:堆转储 ​

bash
# 启动参数(生产必开)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps

# 手动转储(会触发 Full GC 并暂停应用)
jcmd <pid> GC.heap_dump /data/dumps/heap.hprof

# 只看对象分布,代价小得多
jcmd <pid> GC.class_histogram | head -20

实测 256MB 堆发生 OOM 时,程序一直保留 64KB 的数组,自动生成了 244MB 的 hprof 文件;换成保留 1MB 的数组,文件只有 133MB。转储的是存活对象,所以文件大小跟着存活数据走:1MB 的数组在 1MB 的 region 里是大对象,每个要占两个 region,堆满时真正的数据只有一半左右。这意味着:

  • 磁盘要按堆的上限准备空间:OOM 时存活数据通常接近堆的上限,8GB 堆可能就是接近 8GB 的文件,容器里往往没有这么大的可写空间;
  • 转储期间应用完全暂停,大堆可能要几十秒;
  • 文件要能传出来分析,容器重启后本地文件就没了,要挂载持久卷或及时上传。

分析工具用 Eclipse MAT 或 JDK 自带的 jhat 替代品(推荐 MAT 的 Leak Suspects 报告)。

4.3 CPU 高:看线程在做什么 ​

bash
# 找出占 CPU 最高的线程(Linux)
top -H -p <pid>
printf "%x\n" <tid>            # 线程 ID 转十六进制
jcmd <pid> Thread.print | grep -A 30 <十六进制 tid>

更省事的做法是用 JFR 或异步采样器直接出火焰图:

bash
jcmd <pid> JFR.start name=cpu settings=profile duration=60s filename=/tmp/cpu.jfr
jfr summary /tmp/cpu.jfr

常见原因:正则回溯、序列化、日志同步写、锁竞争自旋、GC 本身。

4.4 停顿:GC 日志 ​

bash
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50M

JDK 9 起统一用 -Xlog,旧的 -XX:+PrintGCDetails 系列参数已经废弃。日志里要关注:

  • 暂停时间分布:偶发的长暂停比平均值更重要;
  • to-space exhausted:G1 的疏散失败,通常意味着堆不够或分配速率过高;
  • Humongous:大对象(超过 region 一半)直接进入老年代,频繁出现要检查是不是有大数组或大字符串。

五、一份排查顺序 ​

  1. 确认现象:是延迟上升、错误率上升,还是进程被杀?拿到具体时间点。
  2. 看监控:QPS、RT 分位数、GC 次数与耗时、堆使用率、线程数、CPU。先判断是不是 JVM 的问题——很多「GC 问题」其实是下游变慢导致对象堆积。
  3. 对照基线:这些值和平时比,差多少。
  4. 取证:内存问题取 class_histogram 或堆转储,CPU 问题取 JFR 或线程栈,停顿问题取 GC 日志。先取证再重启,重启之后现场就没了。
  5. 定位到代码:堆转储看支配树(Dominator Tree)找到占用最大的对象及其引用链;火焰图看最宽的栈帧。
  6. 验证修复:改完之后用同样的指标对比,确认不是「重启之后暂时好了」。

六、什么时候真的需要调参 ​

大多数情况下不需要。值得动手的信号:

信号可能的动作
老年代持续增长、Full GC 频繁先查泄漏,不要盲目加堆
最长暂停超过业务容忍度降低 MaxGCPauseMillis,或换 ZGC(JDK 21 的分代 ZGC 暂停通常在亚毫秒级)
吞吐优先、暂停不敏感(批处理)可以考虑 Parallel GC
Metaspace 持续增长查类加载器泄漏,而不是加大 MaxMetaspaceSize
堆外内存增长开 -XX:NativeMemoryTracking=summary 后用 jcmd VM.native_memory 对比

换收集器前先验证:ZGC 的暂停极短,但吞吐和内存占用与 G1 不同,要用真实负载压测对比,而不是看基准测试的宣传数字。

七、常见误区 ​

  • 「GC 次数越少越好」:实测堆加大后次数减少、单次变长,最长暂停从 4ms 涨到 14ms,要按业务对单次延迟的要求取舍。
  • 「Full GC 为 0 就没问题」:G1 的并发周期同样消耗 CPU,实测 512MB 堆 8 秒内跑了 86 次。
  • 「先重启恢复再说」:重启会丢掉全部现场,下次还会发生。至少先抓一份线程栈和 class histogram。
  • 「堆越大越安全」:大堆的转储、回收、页面换入都更慢,并且掩盖泄漏。
  • 「调 JVM 参数是主要优化手段」:绝大多数延迟问题的根因在代码和下游依赖,JVM 参数是最后一环。

小结 ​

JVM 排查的核心是基线:知道正常时的 GC 频率、暂停、堆曲线,异常才有参照。遇到问题按现象分三条线取证——堆、线程、GC 日志,先取证再重启。调参放在最后,而且每次只改一个参数、用同样的负载验证,否则只是把问题换了个样子。

虚拟线程带来的新故障模式,见 虚拟线程迁移;线程池参数的定法见 线程池参数怎么定。

用 GC.class_histogram 对比 JDK 25 压缩对象头开关前后各类对象的大小,见 对象头少了 4 字节,为什么有的对象一点没变小。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权