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调优从来不是玄学。
它的本质就是三件事:
- 把堆内存分对(新生代大一点,老年代别太小)
- 把GC策略选对(8G以下用G1,16G以上考虑ZGC)
- 把参数调对(MaxGCPauseMillis + InitiatingHeapOccupancyPercent 是核心)
然后,回到代码层面,少造大对象,少泄漏内存。
这三步走完,90%的GC问题都能解决。
剩下10%?那是架构问题,不是调优能解决的了 😂