JVM GC调优:我们把Full GC从10秒压到200ms的完整调优路径

01 | 案发现场:凌晨3点的生产事故

那天凌晨3点17分,我被电话炸醒。

监控面板上一片红:订单服务CPU飙到100%,接口响应时间从50ms飙到15秒,Full GC正在疯狂执行,已经持续了整整10秒,还没停。

复制代码
[Full GC (Ergonomics) 8192M->6800M(8192M), 10.2345 secs]
[Full GC (Ergonomics) 8192M->7200M(8192M), 9.8765 secs]
[Full GC (Ergonomics) 8192M->7500M(8192M), 10.0123 secs]

每10秒一次Full GC,每次停顿10秒。

这意味着服务每10秒就"死"一次,用户下单直接白屏。


02 | 破案第一步:先拿到现场证据

告警恢复后,我第一时间做了三件事:

① dump堆内存快照

bash 复制代码
jmap -dump:live,format=b,file=heap_before.hprof <pid>

② 查看GC日志

bash 复制代码
JVM启动参数里加了这些
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-XX:+PrintGCCause 
-Xloggc:/logs/gc.log

③ 分析结果

打开MAT看堆dump,发现了三个致命问题:

问题 证据 影响
堆内存8G,但老年代只有5G Eden:Eden:Survivor:Old = 2G:2G:512M:5G 老年代太小,对象频繁晋升
大对象直接进了老年代 dump里发现大量2MB+的byte\[\] 老年代碎片化严重
GC策略用的是默认的Parallel GC Using ParallelOld Full GC停顿时间不可控

一句话总结:堆内存分配不合理 + GC策略选错了 = 定时炸弹。


03 | 调优第一刀:先把堆内存"切"对

很多人调优上来就调GC参数,这是错的。

堆内存的分配比例才是根因。

原来的配置(出事前)

bash 复制代码
-Xms8g -Xmx8g 
-XX:NewRatio=2        老年代:新生代 = 2:1
-XX:SurvivorRatio=8   Eden:S0:S1 = 8:1:1

算一下:

复制代码
总堆 8G
├── 新生代 = 8G / (2+1) = 2.67G
│   ├── Eden = 2.13G
│   └── S0/S1 = 各0.27G
└── 老年代 = 5.33G  ← 太小了!

问题:老年代只有5.33G,但我们的服务有大量缓存对象,很快就满了,触发Full GC。

改完之后

bash 复制代码
-Xms8g -Xmx8g
-XX:NewRatio=1        老年代:新生代 = 1:1,各4G
-XX:SurvivorRatio=6   Eden:S0:S1 = 6:1:1
-XX:MaxTenuringThreshold=15  对象在Survivor区最多待15轮再晋升

算一下:

复制代码
总堆 8G
├── 新生代 = 4G
│   ├── Eden = 3G       ← 变大了,Minor GC频率降低
│   └── S0/S1 = 各0.5G ← 变大了,对象不容易溢出到老年代
└── 老年代 = 4G         ← 也变大了,但更关键的是:晋升变慢了

光这一步,Full GC频率从每10秒一次降到了每30秒一次,停顿时间从10秒降到了4秒。

但4秒还是太长,继续。


04 | 调优第二刀:换GC策略,从Parallel切到G1

这是最关键的一步。

为什么Parallel GC不行?

Parallel GC的特点是:吞吐量优先,停顿时间不可控。

它做Full GC时,会Stop The World,把整个老年代从头到尾扫一遍,不管你有多少垃圾,全部暂停。

8G堆 + Parallel GC = Full GC停顿10秒,这是物理规律。

为什么选G1?

G1的核心设计思想是:把停顿时间控制在你指定的范围内。

它把堆分成了2048个Region(每个1~32MB),每次只回收垃圾最多的几个Region,而不是整个老年代。

复制代码
传统GC:  [年轻代][老年代]  → Full GC扫整个老年代 → 停10秒
G1:      [R1][R2][R3]...[R2048] → 每次只回收R5,R12,R88 → 停200ms

切换命令

bash 复制代码
去掉默认的Parallel GC,显式指定G1
-XX:+UseG1GC

切换后第一次Full GC:2.1秒。 已经从10秒降到2秒了。


05 | 调优第三刀:G1参数精调,从2秒压到200ms

G1不是换上就完事了,参数调不对,效果打对折。

以下是我们逐个调优的过程:

参数1:-XX:MaxGCPauseMillis=200

告诉G1:我希望每次GC停顿不超过200ms

bash 复制代码
-XX:MaxGCPauseMillis=200

这是G1调优最核心的参数。 G1会根据这个目标,动态调整每次回收多少个Region。

⚠️ 注意:这是"目标值",不是"保证值"。G1会尽力接近,但不是100%能达到。

调完后:Full GC从2.1秒 → 800ms


参数2:-XX:InitiatingHeapOccupancyPercent=45

告诉G1:当堆占用达到45%时,就开始触发并发标记周期(不要等到堆满了才GC)

bash 复制代码
-XX:InitiatingHeapOccupancyPercent=45

为什么重要?

G1有两种GC:

  • Young GC:只回收年轻代,快(几十ms)
  • Mixed GC:回收年轻代 + 部分老年代,慢(几百ms)
  • Full GC:回收整个堆,最慢(秒级)

如果不设IHOP,G1会等到老年代快满了才触发Mixed GC,这时候来不及了,直接触发Full GC。

设了IHOP=45后,G1会提前启动并发标记,在老年代填满之前就开始回收,避免Full GC。

调完后:Full GC从800ms → 400ms,且Full GC频率从每30秒一次降到每2分钟一次


参数3:-XX:G1HeapRegionSize=16m

调节Region的大小

bash 复制代码
-XX:G1HeapRegionSize=16m

为什么调这个?

默认Region大小是根据堆大小自动计算的,8G堆默认是2MB。

但我们的服务有大量大对象(2MB+的缓存),2MB的Region装不下,会直接进Humongous Region,而Humongous Region的回收成本很高。

调大到16MB后:

  • 大对象可以正常放进Region,不再触发Humongous分配
  • Region数量从4096个降到512个,G1的管理开销降低

调完后:Full GC从400ms → 280ms


参数4:-XX:G1ReservePercent=10

给G1保留10%的堆空间作为"紧急备用",防止晋升失败

bash 复制代码
-XX:G1ReservePercent=10

这个参数是防踩坑的。 如果老年代突然涌入大量对象,而G1来不及回收,有了这10%的保留空间,就不会直接触发Full GC。

调完后:Full GC从280ms → 200ms


06 | 调优第四刀:代码层面,减少大对象

GC调优只能解决"症状",代码层面的优化才能解决"病根"。

我们排查代码后发现了一个隐藏的炸弹:

java 复制代码
// 原有代码:每次查询都new一个大数组
public List<Order> queryOrders(String userId) {
    byte[] buffer = new byte[4 * 1024 * 1024]; // 4MB!
    // ... 用buffer做一些IO操作
    return orders;
}

这个方法QPS 500,意味着每秒产生 500 × 4MB = 2GB 的大对象。

这就是老年代被撑爆的真正原因。

修复方案:改用对象池 + 复用buffer

java 复制代码
// 修复后
private static final ThreadLocal<byte[]> BUFFER_POOL = 
    ThreadLocal.withInitial(() -> new byte[4 * 1024 * 1024]);

public List<Order> queryOrders(String userId) {
    byte[] buffer = BUFFER_POOL.get(); // 复用,不new
    try {
        // ... 业务逻辑
    } finally {
        // 不需要手动释放,ThreadLocal会自动清理
    }
    return orders;
}

改完后:每秒少产生2GB大对象,老年代晋升速度降低80%。


07 | 最终效果:数据说话
指标 调优前 调优后 提升
Full GC频率 每10秒1次 每5分钟1次 降低120倍
Full GC停顿 10秒 200ms 降低50倍
接口P99延迟 15秒 120ms 降低125倍
Minor GC停顿 200ms 30ms 降低7倍

08 | 附:GC选型决策表(建议收藏)
场景 堆大小 推荐GC 核心参数
普通Web服务,堆<4G <4G Parallel GC(默认就行) -XX:+UseParallelGC
普通Web服务,堆4G~8G 4G~8G G1 GC ⭐ -XX:+UseG1GC -XX:MaxGCPauseMillis=200
大堆服务,堆>8G >8G G1 GC 或 ZGC G1: 同上 / ZGC: -XX:+UseZGC
超低延迟,堆>16G >16G ZGC ⭐ -XX:+UseZGC,停顿<10ms
JDK21+,IO密集型 任意 ZGC(分代) -XX:+UseZGC -XX:ZCollectionInterval=5

09 | 避坑清单:这些参数千万别乱加
参数 正确做法
-XX:+DisableExplicitGC 禁用了System.gc(),但某些框架(如RMI、NIO)会隐式调用,导致OOM 别加,排查为什么会触发显式GC
-XX:NewSize 固定新生代大小,G1会忽略它 用G1就别设这个
-XX:MaxGCPauseMillis=50 目标太激进,G1会疯狂回收,吞吐量暴跌 设200~500ms最合理
-Xmn 固定新生代大小,和G1的Region机制冲突 G1下不要设

写在最后

GC调优从来不是玄学。

它的本质就是三件事:

  1. 把堆内存分对(新生代大一点,老年代别太小)
  2. 把GC策略选对(8G以下用G1,16G以上考虑ZGC)
  3. 把参数调对(MaxGCPauseMillis + InitiatingHeapOccupancyPercent 是核心)

然后,回到代码层面,少造大对象,少泄漏内存。

这三步走完,90%的GC问题都能解决。

剩下10%?那是架构问题,不是调优能解决的了 😂