开发工程师 JVM 调优实录:2C4G 上 Full GC 从每天 8 次压到 0
上个月有台订单查询服务又开始抽风,每天上午九点零七分左右准时卡,日志里 GC 一刷刷一片。机器是阿里云 ecs.c6.large,2 核 4G,JDK 1.8.0_202,Spring Boot 2.3.7,日调用量 180 万上下。 现象很典型:接口 P99 从平时的 700 多毫秒飙到 3 秒以上,运维一周报三到五次 502。 我做的第一件事不是调参数,而是翻启动脚本。结果发现这服务跑了一年半,启动参数只有 -Xms2g -Xmx2g,连 GC 日志都没开过。 所以第一步是补上 -Xloggc 和 PrintGCDetails 重启一次,等它再病一回,把现场抓下来。
第一步:先看趋势,别看单次快照
重启后第二天早上,我蹲在工位上敲 jstat -gcutil 12345 1000 60,每秒打一行,连打一分钟。
O 那一列(老年代使用率)从 32% 一路爬到 99.8%,FGC 计数在 24 小时里涨了 8 次,FGCT 总共 15.3 秒。
也就是说平均每次 Full GC 停 1.9 秒,最狠的一次 2.4 秒,而这段时间里所有请求都堵着。
很多人排查 GC 的坏习惯是打一次 jstat 或者 jinfo 看看就下结论,单次快照什么都说明不了,你得看它涨的速度。
我是怎么判断的:老年代每小时涨 80 到 120MB,而 G1 或者 Parallel 在 JDK8 里都不会主动去回收这部分,除非触发 Full GC。
第二步:找大对象,别猜
接下来两行命令:
jmap -histo:live 12345 | head -20
输出里 java.util.HashMap$Node 排第一,实例数 140 多万,占用 1.2G。
第二行我把堆 dump 下来:jmap -dump:live,format=b,file=/tmp/order.hprof 12345,3.8G 的堆,出来 2.9G 的 hprof 文件。
这里踩了个坑,第一次 scp 直接传,传了 40 分钟还没完,后来 gzip 一下压到 300M 左右,三分钟搞定,下次记得先压再拉。
本地用 MAT(Eclipse Memory Analyzer 1.13.0)打开,看 Dominator Tree,很快就锁到一个类:com.xxx.order.cache.SkuCache。
里面是个 static 的 HashMap<String, SkuDTO>,key 是 skuId,value 是整个 SKU 对象图,连带关联的库存、价格、门店列表一起塞进去。
代码注释写着一行 // TODO 加个过期时间,这行 TODO 躺了一年半。
第三步:动手改,三个地方
改的地方其实不多,一个是缓存容器,一个是 GC 收集器,一个是参数。
缓存换成 Caffeine,把原来那个无上限的 HashMap 替掉:
Cache<String, SkuDTO> skuCache = Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
顺带把另一个存配置的 ConcurrentHashMap(实例数 9 万,占用 210M)也一起替了。
注意 maximumSize 别拍脑袋,我们的 SKU 总量在 8 万左右,热数据大概 3000 到 4000,所以给 5000 是留了余量。
如果你们 SKU 是百万级的,这个值要重新算,不然命中率会掉到 40% 以下,反而拖慢接口。
参数从两行变成这样:
-Xms2560m -Xmx2560m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=35
-XX:G1HeapRegionSize=8m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-Xloggc:/data/logs/gc.log
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M
-Xmx 从 2g 提到 2560m,是因为 4G 的机器上还跑了 node_exporter 和 filebeat,留 1.5G 给系统和其他进程,不能再多了。
改完之后的数据(观察 30 天)
| 指标 | 改之前 | 改之后 |
|---|---|---|
| Full GC 次数 | 平均 8 次/天 | 0 次 |
| 单次 STW | 1.8 ~ 2.4s | 无 Full GC,Young GC 平均 18ms |
| 接口 P99 | 780ms,高峰期 3.2s | 120ms |
| 常驻内存 | 3.75G 满格 | 2.1G 左右浮动 |
| 502 次数 | 一周 3 ~ 5 次 | 0 |
| Young GC 频率 | 约 3 分钟一次 | 约 40 秒一次 |
最后一行容易被忽略:换成 G1 之后 Young GC 明显变频繁了。 MaxGCPauseMillis=200 是个期望值不是保证值,G1 为了让单次停顿落在这个区间内,会主动缩小 Young 区,代价就是回收次数变多。 我们这边从 3 分钟一次变成 40 秒一次,但每次只停 15 到 20 毫秒,算总账是赚的。 如果你们服务的吞吐量本身就吃紧,这个交换值不值,得自己在压测环境里跑一遍再定。
几个不太主流的看法
第一,绝大多数被叫做「JVM 调优」的活儿,本质是内存泄漏排查,参数只是收尾。 你把堆里那个只涨不降的 HashMap 找出来,参数一个都不改,Full GC 也能少一大半。反过来,先调参数再找泄漏,就是白干。
第二,G1 不是银弹。堆小于 4G、QPS 不高的服务,JDK8 默认的 Parallel Scavenge + Parallel Old 完全可以活着,只是停顿数字不好看。 我们换 G1 图的是停顿可控,不是吞吐变高,这两个目标在 G1 里本来就是要取舍的。
第三,IHOP 设成 35 不是通用值。默认 45 的时候,这个服务经常是并发标记还没跑完,老年代已经满了,直接退化到 Full GC。 调低到 35 是给并发标记留出提前量,代价是并发标记线程多占一点 CPU,2 核机器上能明显看到 us 升高 3 到 5 个百分点。 所以这条别照抄,先看你老年代的增长速率,如果一小时涨不到 20%,默认值大概率够用。
给后来人的排查顺序
- 先确认有没有开 GC 日志,没开就先补上重启,别凭感觉调参。
jstat -gcutil <pid> 1000 60看趋势,重点看 O 列和 FGC 列的增速。jmap -histo:live <pid> | head -20找实例数或者占用排前几的类。- 有条件就 dump(记得先看磁盘够不够,hprof 大概是堆大小的七成),MAT 看 Dominator Tree。
- 定位到具体代码、改完、再上参数,最后回到第 1 步拿数据验证。
这套流程我从 2020 年用到现在,前后处理过七八个服务,真正靠「调三个参数」解决问题的只有两次,剩下全是代码里藏着的容器没上限。 这台机器改完到现在跑了 90 多天,gc.log 里 Full GC 那栏一直是 0,我偶尔还去 Grep 一下确认它没背着我偷偷发作。