上周三下午两点四十,线上结算服务的 CPU 曲线走出了一根几乎垂直向上的线,紧接着 TP99 从 80ms 涨到 2.4s,监控群瞬间炸开。我登录跳板机看了一眼 GC 情况,血压也跟着曲线一起上去了:FullGC 每分钟至少触发 5 次,每次停顿 2 到 4 秒。这次线上 FullGC 频繁事故,让我把 G1 参数从"看着调"变成了"算着调",整个过程值得完整记录下来。这篇复盘适合所有被 FullGC 告警吓过的后端开发者,也适合那些想系统理解 G1 调优参数,而不是从网上复制一堆配置就上生产的人。
先说结论:这次的根因并不是 JVM 参数本身,而是应用层一个无上限的本地缓存把堆占满了。但同样的 FullGC 现象,如果不懂 GC 日志和 G1 的回收机制,很容易被带偏到慢 SQL、连接池这些方向上去。本文会完整讲清从告警到定位的排查链路,再逐个拆解我调整的 G1 参数以及每个参数背后的逻辑和适用边界。
1. 现象初现:一场把CPU和TP99同时拉爆的FullGC
1.1 告警数据长什么样
服务部署是4台8C16G的容器,JVM最大堆8G,JDK8,显式启用了G1。业务是订单结算,高峰期每台机器大概500 QPS,这个体量本来不该有任何GC压力。
告警发生时我收集到的数据是这样的:
- CPU 使用率 95% 以上,持续超过 10 分钟
- TP99 由平时的 80ms 涨到 2.4s,TP999 直接超时
- jstat 看到的 FGC 数量在 30 分钟内从 187 涨到 329,FGCT 从约 300 秒涨到 600 秒
- 平均每次 FullGC 的停顿时间在 2 到 4 秒之间
这个特征非常明显:FullGC 要么不出现,一旦以这种频率出现,基本等于 JVM 在"一边使劲回收一边继续堆积",堆已经处于崩溃边缘。此时首要任务不是调参,而是确认到底什么对象占满了堆。
1.2 第一时间做的三件事,其中一件带偏了节奏
我当时的操作顺序是这样的:
- 保留现场:暂停发布、摘流量,把 GC 日志、jstat 输出、thread dump 全部留存。这一步最重要,现场没了后面全是猜。
- 通过
jinfo -flags pid确认启动参数,发现堆虽然给了 8G,但 G1 的IHOP、MaxGCPauseMillis、NewSizePercent全是默认值。 - 错误的开始:我让人去查数据库慢日志和连接池状态,因为 TP99 上涨的第一直觉是"业务变慢导致线程堆积"。结果慢日志干干净净,连接池也正常。
回头看,这 20 分钟完全是被直觉带偏了。FullGC 导致 CPU 飙升、业务线程频繁停顿,TP99 自然会涨。正确的顺序应该是:先通过 jstat 或 GC 日志排除 JVM 层面的问题,再往业务方向查。等你看到 FGC 每分钟 5 次的时候,已经不需要怀疑业务了。
2. 完整定位链路:GC日志、jstat与堆转储的三板斧
2.1 好习惯是让GC日志一直开着
这次能快速定位,靠的是服务从一开始就开了完整的 GC 日志。如果没开,手头只有监控面板上那几个平均耗时指标,基本只能靠猜。
生产环境 JDK8 我建议至少配这样一套:
-Xloggc:/data/logs/gc/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=100MJDK9 之后的写法更简洁:
-Xlog:gc*=info:file=/data/logs/gc/gc.log:time,uptime,level:filecount=5,filesize=100m我的习惯是把 gc.log 独立到单独目录,和应用日志分开,避免日志滚动互相影响。这些日志文件平时没人看,但出问题的时候它就是唯一的"事故黑匣子"。
2.2 从GC日志里读出的三条线索
打开 gc.log,我看到了三类关键日志:
第一,FullGC 的原因基本都是Allocation Failure。在 G1 里,这意味着"分配对象时找不到足够的 Region 了",G1 被迫升级为 FullGC 做全局回收。偶尔还能看到GCLocker Initiated GC,说明有 JNI 临界区在干扰 GC 启动时机。
第二,回收效果极差。FullGC 日志大概长这样:
Full GC (Allocation Failure) 5960M->4810M(8192M), 2.3102164 secs]一次 FullGC 回收了 1000 多 M,但结束后堆仍然有 4.8G,而这只是几分钟前的下一次 FullGC 的开始值。堆使用率像锯齿一样反复横跳,说明每次回收完很快又被重新填满,这个速度绝对不是正常业务对象能产生的。
第三,部分 YoungGC 的停顿时间诡异。平时正常 YGC 是 30-50ms,但每隔一段时间就出现一次 300-500ms 的 YGC。这不是 YGC 本身变慢,而是 G1 的并发标记周期(Concurrent Marking Cycle)在穿插执行,全局标记、RSet 扫描、最终标记都会占用 CPU 和停顿时间。
2.3 jstat证实FullGC频率,而不是靠感觉
光看日志不够,我用了jstat -gcutil连续采样来确认频率和趋势:
jstat -gcutil <pid> 1000 5输出重点关注三列:FGC(FullGC 次数)、FGCT(FullGC 累计耗时)、O(老年代使用率)。我当时采到的数据是 30 分钟从 187 涨到 329 次,FGCT 从 300 秒涨到 600 秒。也就是说过去 30 分钟里 JVM 有整整 5 分钟以上花在了 FullGC 上。
到这里我已经能确定问题不在参数层面,而是堆里有大量"应该被回收但被应用牢牢持有"的对象。GC 参数调得再好,本质上也救不了这种局面,优化参数只能让 FullGC 从每分钟 5 次降到 2 次,并不能根治。
2.4 堆转储与MAT分析:找到那个3.9G的HashMap
接下来就是堆转储。我用的是:
jmap -dump:live,format=b,file=/data/dump/heap.hprof <pid>注意-dump:live会先触发一次 FullGC,高峰期执行会加剧停顿,我是摘了流量之后才操作的。转储文件大概 4G 多,用 MAT 打开后直接进 Dominator Tree,按 Retained Heap 排序。排在最前面的,是一个叫OrderSnapshotCache的静态 HashMap 实例,Retained Heap 高达 3.9G,里面接近 240 万条 Entry。
用 Path to GC Roots 一看,根路径是public static final Map,节点持有一堆 String 类型的订单号 key 和自定义对象 value。这个 Map 是业务代码里的本地缓存:每次结算请求进来先查它,查不到就查库,再放进去。问题是它没有容量上限、没有过期时间、没有淘汰策略。
这类问题在 MAT 里其实非常好认:一个大 Map 或者大 List 独占几个 G 的内存,基本就是缓存集合失控,不是传统意义上"对象泄漏无根引用"的泄漏,而是"对象一直被可达但早已无业务价值"的堆积。
2.5 从堆转储到代码修复
修复方案简单粗暴但有效。原代码逻辑类似这样:
private static final Map<String, OrderSnapshot> CACHE = new ConcurrentHashMap<>(); public static OrderSnapshot getSnapshot(String orderId) { OrderSnapshot snapshot = CACHE.get(orderId); if (snapshot == null) { snapshot = loadSnapshotFromDB(orderId); CACHE.put(orderId, snapshot); } return snapshot; }改成用 Caffeine 带容量上限和过期时间:
private static final Cache<String, OrderSnapshot> CACHE = Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(5)) .build(); public static OrderSnapshot getSnapshot(String orderId) { return CACHE.get(orderId, OrderSnapshotService::loadSnapshotFromDB); }这个替换上线后,就算什么都不调,FullGC 也会消失。但既然事故已经出了,我顺手把 G1 参数也重新梳理了一遍——为了不再出现下一次,也为了让它在代码层面已经健康的前提下,把 GC 停顿压得更平滑。
3. 想调G1参数,先得看懂它的回收时钟
3.1 Region、新生代与Humongous对象
G1 和 CMS、Parallel 最大的区别是"分代不连续"。G1 把堆划分成大小相等的 Region,每个 Region 大小在 1M 到 32M 之间,JVM 启动时会根据堆大小自动规划,基本目标是约 2048 个 Region。新生代老年代不再是物理连续的内存块,而是一组动态变化的 Region 集合。
这个设计可以理解为停车场管理:以前是一整栋楼按楼层分代,G1 是把整个停车场划成几百个车位,哪些车位做新生代、哪些做老年代,看情况动态调整。
有一个容易踩坑的细节:对象大小超过 Region 的一半时,会直接进入 Humongous 区域(大对象区),这个区域不参与常规的年轻代回收。如果你的服务里有大量大数组、超大 List,Region 大小设置不合适会导致大对象区碎片化,反而加剧 GC。
3.2 并发标记周期的启动时机与IHOP
G1 的回收节奏是这样的:平时只做 YoungGC,回收年轻代 Region 里的对象。当已用堆空间达到一定比例(默认InitiatingHeapOccupancyPercent = 45%)时,G1 启动一个并发标记周期,标记出老年代里大量死亡对象的 Region,随后进入 MixedGC 阶段,把一部分老年代 Region 和年轻代 Region 混在一起回收。
这个标记周期是分多个阶段完成的:初始标记(STW)、并发标记(后台线程跑)、最终标记(STW)、筛选回收(STW)。所以你会发现 GC 日志里 YoungGC 偶尔特别慢——那很可能是并发标记周期正在全局范围内工作。
这里有一个非常重要的认知:G1 的回收时机不是"等老年代满了再做",而是根据堆占用比例提前启动标记,配合后面若干轮 MixedGC 逐步腾空间。这个"提前量"就是我们调参最核心的空间。
3.3 RSet与SATB:G1的隐形开销来源
RSet(Remembered Set)是 G1 为了不整堆扫描而设计的引用记录:每个 Region 都记录"有哪些其他 Region 的对象引用了本 Region 的对象"。这样回收某个 Region 时,不需要遍历整个堆找引用关系。代价是每次对象引用赋值时都要维护 RSet,写屏障有开销。
SATB(Snapshot At The Beginning)是并发标记阶段的技术:标记启动那一刻打一个逻辑快照,之后新建的对象默认存活,防止并发标记过程中漏掉存活对象。副作用是并发标记期间产生的部分垃圾对象要留到下一轮标记才能被回收。
理解这两个东西的意义在于:你会明白为什么 G1 参数不能乱调。比如把 Region 调小了,回收粒度确实变细了,但 RSet 数量和写屏障开销会上升;把并发标记周期调得太频繁,SATB 带来的"延迟垃圾"和 CPU 消耗也会增加。G1 调优的本质,是在停顿时间、回收效率、CPU 开销三者之间找平衡点。
4. 参数重设:这次我只动了几处,每一处都说得清理由
4.1 代码修复永远优先于参数调整
我先强调一个原则:当堆转储已经证明是应用层对象堆积时,参数调整只是止血,代码修复才是治病。这次我是先在低峰期上了 Caffeine 修复版本,确认 FullGC 已经归零之后,才在下一轮发布中带上 G1 参数调整。如果反过来,先调参数再改代码,你根本分不清是谁起的作用,还会误把"调参有效"当成经验传给下一个项目。
4.2 堆大小与Region尺寸:匹配业务对象分布
原服务堆是-Xms8g -Xmx8g,我保留了这两个相等值。避免堆自动扩容的原因很简单:JVM 运行时动态扩缩容会触发大块内存分配和堆重新组织,这在高峰期会造成额外延迟抖动。8G 的堆在 16G 容器里占了 50%,留出的空间给线程栈、元空间、堆外 Direct Memory 和操作系统页缓存,这个比例相对安全。
Region 大小我设置为-XX:G1HeapRegionSize=4m。8G 堆除以 2048 个 Region,默认算出来正好也接近 4M,这一步更多是显式锁定,防止大堆机器默认算出更大的 Region。为什么不继续调到 2M 让回收粒度更细?因为我用jmap -histo:live看过对象大小分布,有一批 1M 到 2M 的批量处理对象,Region 只有 2M 时它们会被判定为 Humongous,直接进入大对象区,反而浪费连续空间。
4.3 MaxGCPauseMillis=100:软实时目标的取舍
-XX:MaxGCPauseMillis=100是我在这里设置的最关键参数。默认值是 200ms,对一批 TP99 要求 100ms 以内的服务来说,YGC 突然一次 150ms 就可能拖垮一批接口。
要理解的是,这个参数是"软目标",不是硬性限制。G1 的内部策略是通过动态调整新生代大小来努力满足停顿目标:新生代越大,每次 YGC 要移动的存活对象越多,停顿越长;新生代越小,YGC 越频繁但单次更快。如果你把目标压到 30ms,G1 会把新生代缩得非常小,YGC 频率暴涨,对象在年轻代还没来得及死去就被提前晋升到老年代,老年代膨胀更快。我在生产上见过把MaxGCPauseMillis设成 30ms 之后老年代每周上涨 20% 的真实案例。
100ms 是在"单次停顿可接受"和"YGC 频率不过分"之间的折中。如果你服务对延迟不敏感,200ms 默认值其实非常省心,不需要动。
4.4 IHOP从45降到30:提前开始并发标记
-XX:InitiatingHeapOccupancyPercent我调成了 30。这个参数决定"堆已用空间达到多少百分比时启动并发标记周期"。
默认 45% 是个保守值。我们这次的情况是:堆里有一批无法回收的缓存对象长期占据约 1.5G 空间,加上正常业务对象,堆使用率在高峰段很容易顶到 55% 以上。等到 45% 才启动标记,标记周期本身还要经历并发标记、最终标记、再筛选回收,全套流程跑完,堆可能已经到 65% 了,MixedGC 的节奏追不上分配速度,系统就会反复 Alloc Failure。
降到 30 以后,标记周期启动更早、周期更频繁,虽然 CPU 会多消耗一点,但有效避免了"启动太晚导致回收跟不上"的被动局面。这里有一个注意点:如果应用本身内存分配速率极快、活跃数据已经超过 60% 堆容量,降 IHOP 没有用,你要做的是加内存或者减数据,而不是调这个参数。
4.5 新生代的保底与上限
-XX:G1NewSizePercent我从默认 5% 调到 8%,-XX:G1MaxNewSizePercent从默认 60% 收到 50%。
为什么动这两个?默认最小新生代 5% 对 8G 堆就是 400M,在高峰期每秒要分配大量短命对象时,400M 的年轻代很快就被填满,YGC 太频繁。给到 8% 相当于 640M 保底,低频业务低谷时也不至于让 YGC 太密集。
上限从 60% 收到 50%,是防止 G1 为了满足 100ms 停顿目标,把新生代扩得太大。年轻代太大时,每次 YGC 拷贝的存活对象虽然比例低,但绝对数量大,单次停顿反而超过目标值。给 G1 划这个边界,是让它在 8% 到 50% 这个区间内自由决策,而不是让它把整个堆一半以上都当作新生代。
4.6 MixedGC效率与空间浪费的平衡
-XX:G1MixedGCCountTarget=8保持默认。这个参数决定并发标记完成后,MixedGC 分几轮来完成。8 轮的话,每轮回收的 Region 数量适中、单轮停顿短,整体平滑;设成 4 会更激进,回收完成快但单次停顿大。我们服务对抖动的容忍度低,没必要省那几秒。
-XX:G1HeapWastePercent从默认 5% 调到 3%。它的含义是:MixedGC 过程中,如果可回收空间占总堆比例低于这个值,G1 会提前结束本轮混合回收。默认 5% 在堆碎片化场景下偏"懒",降到 3% 让 G1 多回收一点。但注意不能设成 1%,否则 G1 为了找那 1% 空间反复启动 MixedGC,CPU 空转,GC 日志里全是没意义的周期。这个参数是典型的"你在容忍 GC 频率和容忍堆浪费之间做取舍"。
4.7 最终的JAVA_OPTS清单
这次最终生效的 G1 相关配置如下:
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=30 -XX:G1NewSizePercent=8 -XX:G1MaxNewSizePercent=50 -XX:G1MixedGCCountTarget=8 -XX:G1HeapWastePercent=3 -XX:G1ReservePercent=10G1ReservePercent保持默认 10%,这是 G1 为 to-space 和大对象预留的空间,防止晋升失败触发 FullGC。这个参数一般不需要动,动了容易出反效果。
所有参数调整我整理成了一张表,方便对照:
| 参数 | 默认值 | 本次调整 | 调整理由 | 适用注意点 |
|---|---|---|---|---|
| MaxGCPauseMillis | 200ms | 100ms | 满足 TP99 抖动要求 | 设过小会缩小新生代、YGC 频繁 |
| G1HeapRegionSize | 自动 | 4m | 匹配对象大小分布 | Region 太小会导致 Humongous 过多 |
| InitiatingHeapOccupancyPercent | 45% | 30% | 提前标记,避免回收跟不上分配 | 活跃数据本就超过 60% 时无效 |
| G1NewSizePercent | 5% | 8% | 降低 YGC 频率 | 需配合 MaxNewSizePercent 使用 |
| G1MaxNewSizePercent | 60% | 50% | 防止新生代过大导致停顿超标 | 与停顿目标联动 |
| G1HeapWastePercent | 5% | 3% | 让 MixedGC 回收更彻底 | 过小会导致 CPU 空转 |
4.8 参数之间其实是联动的
调完这几个参数,我最大的体会是:G1 参数不是独立的旋钮,而是一套联动系统。MaxGCPauseMillis改变新生代的决策空间,NewSizePercent和MaxNewSizePercent约束这个空间,IHOP决定全局标记的提前量,HeapWastePercent影响 MixedGC 的结束时机。你在网上抄一份参数配置没问题,但要把"为什么调、调了影响什么、和谁联动"想清楚,否则出了问题根本不知道怎么回滚。
5. 观察期验证:新参数上线后的曲线与第二次告警
5.1 三张曲线看变化
参数和代码修复一起上线之后,我没有急于宣布"搞定",而是守了三个观察窗口:24小时、一周、一个月。
24小时内最明显的变化有三个:
- FullGC 直接归零,GC 日志里只剩 YoungGC 和并发标记周期
- YoungGC 从每天 8000 次降到 3000 次左右,单次平均停顿从 60ms 降到 40ms
- 堆使用曲线从锯齿状攀升变成规整的梳子状:用完、回收、再上升,斜率明显平缓
一周的数据里,jstat -gcutil显示老年代使用率稳定在 30%-40% 区间波动,IHOP 调整到 30% 之后,每天能看到 2 到 3 次并发标记周期,MixedGC 每轮停顿约 120-180ms,没有出现过一次超过 200ms 的全局停顿。这说明 G1 的回收节奏已经跟上了分配速度。
5.2 第二次告警:System.gc的干扰
上线后的第三天凌晨,监控又报了一次 FGC。我打开 GC 日志一看,原因写的是Full GC (System.gc())。FullGC 次数为 1,持续了 1.1 秒,当时心里咯噔一下。
排查后发现是一个第三方 SDK 在连接池定期清理时显式调用了System.gc()。这里有个常见的处理建议:直接加-XX:+DisableExplicitGC把显式 GC 关掉。但我不建议一上来就这么做,因为很多框架用System.gc()配合DirectByteBuffer的 Cleaner 机制来释放堆外内存,关掉之后堆外内存的回收时机可能出问题,反而引起别的事故。
更稳妥的做法是加-XX:+ExplicitGCInvokesConcurrent,让System.gc()变成一个并发标记周期,而不是 STW 的 FullGC。我当时的处理是:先加这个参数让风险立刻消失,再推动 SDK 升级去掉显式 GC 调用,最后完全移除对System.gc()的依赖。整个过程虽然不算复杂,但值得单独记录,因为"FullGC 恢复但仍有零星 System.gc()"是很多服务的隐藏风险点。
5.3 一次只动一个变量,观察周期要给足
我自己的一个铁律:生产环境的 JVM 调参,一次发布只动一个变量组。这里的"一个变量组"是指强相关的几个参数一起上,比如 NewSize 和 MaxNewSize,或者 MixedGCCountTarget 和 HeapWastePercent。不要在一次发布里同时改堆大小、GC 算法、IHOP、RegionSize、停顿目标,否则出任何问题你都不知道该回滚谁。
观察周期至少要覆盖一个完整的业务峰谷,也就是 24 小时。如果服务有明显的月结、大促周期,最好再观察一个特殊周期。G1 的许多问题不是上线当天就暴露的,而是在内存水位逐步走高之后才浮现。
6. 从这次排障里学到的JVM调优边界
6.1 调优前先回答三个问题
每次接到 GC 告警,我现在的第一反应不再是"赶紧换个参数",而是先问三个问题:
- GC 日志里的 FullGC 原因是什么?是
Allocation Failure、System.gc()还是元空间不足? - 回收效果如何?FullGC 结束后堆使用率是否显著下降?如果从 5G 只回到 4.5G,说明堆里塞满了无法回收的对象。
- 有没有做堆转储?没有 heap dump 之前,任何"内存泄漏"的判断都是猜测。
这三个问题回答不了任何一个,就先不要调参数。
6.2 默认参数是保守的,别为了调而调
G1 的默认参数整体是偏保守的。IHOP=45、MaxGCPauseMillis=200、G1MixedGCCountTarget=8,这些值照顾的是绝大多数通用场景。默认参数真正的问题是"不针对你的场景优化",而不是"不能跑"。如果没有明确的观测数据支撑,改任何参数都是在给自己埋雷。
我见过有人把网上热门配置一股脑贴到生产,结果G1HeapWastePercent=1导致持续 MixedGC 空转,CPU 从 20% 涨到 70%;也有人把MaxGCPauseMillis=30导致新生代被压到极限、晋升激增、老年代每周涨 20%。这些坑的共同点都是:改参数之前没有回答"为什么改、影响什么、如何验证"。
6.3 三个最容易被误解的参数
第一个是MaxGCPauseMillis。它只是软目标,G1 会牺牲 YGC 频率来满足它,设得太低会引发连锁反应。
第二个是InitiatingHeapOccupancyPercent。它决定并发标记的启动时机,但它不是回收能力的保证。如果活跃数据本身已经超过 60% 堆容量,调它就是把回收周期拉得更勤,该 FullGC 还是会 FullGC,只是晚一点而已。
第三个是G1HeapWastePercent。它的作用是让 G1 在 MixedGC 中评估"值不值得继续收"。设小了,G1 会为了那点空间反复空转;设大了,堆碎片长期残存。3% 到 5% 之间是多数场景的合理区,出了这个区间就要有非常具体的理由。
这次事故的最后一个教训,是把 GC 日志当成生产环境的基础设施,而不是事故时才想起来开的开关。日志文件不占多少磁盘,但它在关键时刻提供的确定性,比任何监控面板上的平均耗时都值钱。如果你还没有给核心服务开完整的 GC 日志,我建议今天就去补上。下次 FullGC 来的时候,你至少能知道它是谁、为什么来、该不该调参,而不是对着监控面板干瞪眼。