JVM 调优与线上排查:先知道什么是正常,再判断什么是异常
「GC 太频繁了要不要调参」——在回答之前先看数据:实测同一段程序 8 秒内,512MB 堆跑出 517 次 Young GC、平均暂停 2.07ms;2GB 堆只有 39 次,平均却是 6.99ms。次数和单次暂停往相反方向变,要看的是两者的乘积,以及最长的那一次业务能不能接受。 没有基线的调优就是在猜。
本文的数据都在 temurin 21.0.12 容器(2 CPU,G1 GC,默认参数)里实测,见文末配套实验。
一、先看默认值
java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|MaxGCPauseMillis|InitialHeapSize|MaxHeapSize"在内存 limit 为 2GB 的容器里实测:
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 进程的容器来说偏保守。常用的容器内堆设置:
-XX:MaxRAMPercentage=70.0 # 堆占容器内存的比例,给堆外和元空间留余量实测 2GB 容器加上这个参数后,最大堆约为 1.4GB。
二、堆大小与 GC 的关系
同一段分配密集的程序(每秒分配约 2.5GB,1% 的对象长期存活)运行 8 秒,每种参数跑 3 次取中位数:
| 参数 | Young GC 次数 | 平均暂停 | 最长暂停 | 合计停顿 | 并发标记周期 | Full GC |
|---|---|---|---|---|---|---|
-Xmx512m | 517 | 2.07ms | 4.07ms | 1,071ms | 86 | 0 |
-Xmx2g | 39 | 6.99ms | 13.82ms | 265ms | 4 | 0 |
-Xmx512m -XX:MaxGCPauseMillis=50 | 504 | 2.19ms | 4.89ms | 1,090ms | 86 | 0 |
三个结论:
- 堆变大,GC 次数减少、单次暂停变长。 年轻代更大,每次要处理的对象更多。这次负载下,次数减少得更多,合计停顿从 1,071ms 降到 265ms;但最长暂停从 4ms 涨到 14ms。哪个更好取决于业务更在意吞吐还是单次延迟,所以要把「次数 × 单次」和最长暂停一起看。
- 暂停目标只在实际暂停接近它时才起作用。 把
MaxGCPauseMillis从 200 降到 50,结果没有可见变化:暂停本来只有几毫秒,远低于目标,G1 没有理由缩小年轻代。反过来,目标设得过低会让年轻代太小、GC 更频繁、吞吐下降,而且 G1 不能保证一定达到。 - 没有 Full GC 不代表没问题。 512MB 堆下 8 秒跑了 86 个并发标记周期,它们在后台和业务线程抢 CPU。
三、三条排查线
3.1 先看堆:内存问题
jcmd <pid> GC.heap_info在上面的负载运行时实测输出:
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:
jstat -gcutil <pid> 1000 3 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)持续增长,通常是类加载器泄漏(动态代理、热部署、脚本引擎)。
3.2 拿到证据:堆转储
# 启动参数(生产必开)
-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 报告)。
3.3 CPU 高:看线程在做什么
# 找出占 CPU 最高的线程(Linux)
top -H -p <pid>
printf "%x\n" <tid> # 线程 ID 转十六进制
jcmd <pid> Thread.print | grep -A 30 <十六进制 tid>更省事的做法是用 JFR 或异步采样器直接出火焰图:
jcmd <pid> JFR.start name=cpu settings=profile duration=60s filename=/tmp/cpu.jfr
jfr summary /tmp/cpu.jfr常见原因:正则回溯、序列化、日志同步写、锁竞争自旋、GC 本身。
3.4 停顿:GC 日志
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50MJDK 9 起统一用 -Xlog,旧的 -XX:+PrintGCDetails 系列参数已经废弃。日志里要关注:
- 暂停时间分布:偶发的长暂停比平均值更重要;
to-space exhausted:G1 的疏散失败,通常意味着堆不够或分配速率过高;Humongous:大对象(超过 region 一半)直接进入老年代,频繁出现要检查是不是有大数组或大字符串。
Full GC 为 0 也不代表没问题:G1 的并发周期同样消耗 CPU,Remark 和 Cleanup 也是停顿。
四、一份排查顺序
- 确认现象:是延迟上升、错误率上升,还是进程被杀?拿到具体时间点。
- 看监控:QPS、RT 分位数、GC 次数与耗时、堆使用率、线程数、CPU。先判断是不是 JVM 的问题——很多「GC 问题」其实是下游变慢导致对象堆积。
- 对照基线:这些值和平时比,差多少。
- 取证:内存问题取
class_histogram或堆转储,CPU 问题取 JFR 或线程栈,停顿问题取 GC 日志。先取证再重启,重启之后现场就没了。 - 定位到代码:堆转储看支配树(Dominator Tree)找到占用最大的对象及其引用链;火焰图看最宽的栈帧。
- 验证修复:改完之后用同样的指标对比,确认不是「重启之后暂时好了」。
五、什么时候真的需要调参
大多数情况下不需要。绝大多数延迟问题的根因在代码和下游依赖,JVM 参数是最后一环;真要调,每次只改一个参数,并用同样的负载验证。值得动手的信号:
| 信号 | 可能的动作 |
|---|---|
| 老年代持续增长、Full GC 频繁 | 先查泄漏,不要盲目加堆 |
| 最长暂停超过业务容忍度 | 降低 MaxGCPauseMillis,或换 ZGC(JDK 21 的分代 ZGC 暂停通常在亚毫秒级) |
| 吞吐优先、暂停不敏感(批处理) | 可以考虑 Parallel GC |
| Metaspace 持续增长 | 查类加载器泄漏,而不是加大 MaxMetaspaceSize |
| 堆外内存增长 | 开 -XX:NativeMemoryTracking=summary 后用 jcmd VM.native_memory 对比 |
换收集器前先验证:ZGC 的暂停极短,但吞吐和内存占用与 G1 不同,要用真实负载压测对比,而不是看基准测试的宣传数字。
虚拟线程带来的新故障模式,见 虚拟线程迁移;线程池参数的定法见 线程池参数怎么定。
用 GC.class_histogram 对比 JDK 25 压缩对象头开关前后各类对象的大小,见 对象头少了 4 字节,为什么有的对象一点没变小。
配套实验
- codesphere-labs/java/gc-baseline:容器内的默认堆参数、堆大小与暂停目标对 G1 的影响、
jcmd与jstat的输出、OOM 自动转储的文件大小(验证记录)
参考资料