title: Full GC 从 4 秒压到 80 毫秒:G1 调优的发力点不在 -Xmx,在这三个参数和一份停顿日志
date: 2026-09-25
tags: [Java, G1 GC, JVM, 调优, Full GC, Humongous]
2024 年大促前压测,订单服务每隔 30 分钟来一次 Full GC,每次 4 秒以上。那段时间用户下单经常卡在提交页,监控显示 GC 时间占了接口 P99 的大头。我们团队第一反应是堆给小了,直接把-Xmx从 4G 调到 8G,结果 Full GC 间隔变成 60 分钟,但单次时间更长,高峰期照样卡死。
真正解决问题的是三个参数:-XX:G1HeapRegionSize、-XX:MaxGCPauseMillis、-XX:G1MixedGCCountTarget,加上一份 GC 日志。这篇文章把当时的排查过程和 G1 的关键源码逻辑拆开,说清楚为什么调大堆没用,以及 RegionSize 为什么是关键。
一、事故现场:Full GC 把订单接口卡死
线上配置大致如下:
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=2004GB 堆,G1,目标停顿 200ms。压测时订单接口 P99 偶尔飙升到 5 秒,查看 GC 日志:
[Full GC (Allocation Failure) 4096M->2048M(4096M), 4.123s]Allocation Failure 触发 Full GC,说明新生代分配不过来,老年代也满了,只能 STW 做全堆回收。
我当时的排查思路是:既然 Allocation Failure,那就说明堆不够用,加大堆就行。于是把-Xmx改成 8G:
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200压测后 Full GC 间隔确实变成 60 分钟一次,但单次时间从 4 秒涨到 6 秒。P99 仍然偶发 5 秒以上。这说明单纯加堆没解决问题,只是把问题延后了。
二、为什么调大 -Xmx 反而更慢
我后来翻了 G1 的源码,才明白其中的道理。G1 把整个堆划分成多个 Region。4GB 堆默认 RegionSize 是 2MB,总共有 2048 个 Region。8GB 堆默认还是 2MB,Region 数量变成 4096 个。
Region 数量增加带来的后果:
- Remembered Set 更大:每个 Region 都要维护一个 RSet,记录哪些 Region 引用了自己。Region 数量翻倍,RSet 占用的内存也更多。
- Mixed GC 扫描更多 Region:G1 的 Mixed GC 会选择一部分老年代 Region 回收,Region 越多,选择和管理成本越高。
- Full GC 标记-整理的对象更多:8GB 堆里的存活对象本来就比 4GB 多,Full GC 时遍历和移动的对象更多,时间自然更长。
所以堆大了,Full GC 间隔变长,但每次 Full GC 时间也更长。对 P99 来说,单次长停顿的影响比频繁短停顿更差。
三、G1 Region 大小的影响:Humongous 对象是罪魁祸首
G1 把整个堆划分为多个 Region,每个 Region 大小由-XX:G1HeapRegionSize指定,范围是 1MB 到 32MB。如果没指定,G1 会根据堆大小自动计算。源码逻辑在heapRegionSizer.cpp:
// G1 自动计算 region size 的逻辑(简化) uint HeapRegionSizer::region_size_calc(size_t heap_size) { // 目标 region 数量在 2048 左右 size_t region_size = heap_size / 2048; // 对齐到 2 的幂,限制在 1M-32M if (region_size < 1M) region_size = 1M; if (region_size > 32M) region_size = 32M; return region_size; }4GB 堆默认 RegionSize 是 2MB,8GB 堆默认 RegionSize 是 4MB。注意这里 8GB 默认就是 4MB,但当时我盲目加到 8G 并没有配合其他参数,所以效果还是不好。
我们当时的业务里有很多 3-5MB 的报表对象。Humongous 对象指的是占用的空间超过 Region 大小一半的对象。2MB Region 下,任何大于 1MB 的对象都是 Humongous 对象。
Humongous 对象的问题:
- 直接分配到老年代,不走新生代晋升。
- 需要连续多个 Region,容易产生碎片。
- 回收时只能等待专门的 Mixed GC 或 Full GC,Young GC 通常不回收它们。
当 RegionSize 是 2MB 时,一个 3MB 的报表对象就要占 2 个连续 Region。压测时每秒生成几十个这样的对象,老年代迅速被 Humongous 对象占满,Full GC 被触发。
我们把 RegionSize 调到 4MB 后,1-2MB 的对象变成了普通对象,可以在 Young GC 里回收。3-4MB 的对象虽然还是 Humongous,但需要的连续 Region 数量减少,碎片问题也缓解了。
下面这段 Java 代码可以模拟大对象分配对 GC 的影响:
public class HumongousAllocator { private static final List<byte[]> holder = new ArrayList<>(); public static void main(String[] args) throws Exception { // 模拟每秒分配 50 个 3MB 对象,其中 80% 很快失效 for (int i = 0; i < 1000; i++) { byte[] large = new byte[3 * 1024 * 1024]; if (i % 5 == 0) { holder.add(large); // 20% 长期持有 } // 其他 80% 成为垃圾 if (i % 10 == 0) { Thread.sleep(20); } } System.out.println("holder size=" + holder.size()); } }用 2MB RegionSize 和 4MB RegionSize 分别跑,GC 日志里的 Humongous Allocation 次数会差很多。
四、关键参数:MaxGCPauseMillis 不是硬性承诺
-XX:MaxGCPauseMillis=200只是 G1 的目标,不是保证。G1 会根据这个目标动态选择回收哪些 Region。如果目标设得太低,G1 每次只回收少量 Region,垃圾堆积越来越多,最后不得不 Full GC。
我当时把这个参数从 200 调到 300。同时调整-XX:G1MixedGCCountTarget从 8 调到 5。这个参数控制 Mixed GC 分几次完成回收目标。值越小,每次 Mixed GC 回收的 Region 越多,单次停顿增加,但总停顿次数减少,能更快把老年代垃圾清掉。
这里有个取舍:停顿时间目标越短,单次 GC 工作量越少,但垃圾清理越不彻底;目标越长,单次工作量越大,但 Mixed GC 能更快把老年代水位降下来。对于我们的场景,300ms 比 200ms 更适合,因为重点是避免 Full GC,而不是追求每次 GC 都小于 200ms。
五、GC 日志解读:找到真正的问题
加了-Xlog:gc*:file=gc.log:time:filecount=10,filesize=100m后,我们看到的关键日志:
[2024-10-11T14:23:05.123+0800] GC(123) Pause Young (Mixed) (G1 Evacuation Pause) [2024-10-11T14:23:05.198+0800] GC(123) Pause Young (Mixed) 2048M->1536M(4096M) 75.123ms [2024-10-11T14:23:05.198+0800] GC(123) User=0.45s Sys=0.02s Real=0.08s以及 Humongous 分配记录:
[2024-10-11T14:23:06.001+0800] GC(124) Pause Young (G1 Humongous Allocation)频繁出现G1 Humongous Allocation说明有大对象。我们统计了一周内 GC 日志,发现每天有 2000+ 次 Humongous 分配,其中 80% 是 2-4MB 的报表对象。
为了快速验证 RegionSize 调整的效果,我写了一个简单的 Java 工具类,用来从 GC 日志里统计 Humongous 分配次数和 Full GC 次数:
public class GcLogAnalyzer { public static void main(String[] args) throws Exception { Path path = Paths.get("gc.log"); long humongousCount = Files.lines(path) .filter(line -> line.contains("Humongous Allocation")) .count(); System.out.println("Humongous Allocation count: " + humongousCount); } }用脚本跑了一天的日志,确认了 Humongous 是高频事件。
另外,在本地压测时可以用Runtime接口快速查看 JVM 内存使用情况,辅助判断对象分配是否过大:
public class MemoryInspector { public static void main(String[] args) throws Exception { Runtime runtime = Runtime.getRuntime(); long maxMemory = runtime.maxMemory(); long totalMemory = runtime.totalMemory(); long freeMemory = runtime.freeMemory(); long usedMemory = totalMemory - freeMemory; System.out.println("maxMemory=" + maxMemory / 1024 / 1024 + "MB"); System.out.println("totalMemory=" + totalMemory / 1024 / 1024 + "MB"); System.out.println("usedMemory=" + usedMemory / 1024 / 1024 + "MB"); } }实际压测时,我会在请求处理前后分别调用类似逻辑,观察每次请求后堆内存的增量,从而定位是否有大对象残留在老年代。
六、最终 JVM 参数
经过多轮压测,最终参数如下:
-Xms6g -Xmx6g -XX:+UseG1GC -XX:MaxGCPauseMillis=300 -XX:G1HeapRegionSize=4m -XX:G1MixedGCCountTarget=5 -XX:InitiatingHeapOccupancyPercent=35 -XX:G1ReservePercent=15 -XX:+AlwaysPreTouch -Xlog:gc*:file=/var/log/app/gc.log:time:filecount=10,filesize=100m各参数含义:
-Xms6g -Xmx6g:固定堆大小,避免运行时扩缩容。-XX:MaxGCPauseMillis=300:目标停顿 300ms,给 G1 更多空间清理老年代。-XX:G1HeapRegionSize=4m:把 Region 从 2MB 提到 4MB,减少 Humongous 对象数量。-XX:G1MixedGCCountTarget=5:Mixed GC 目标 5 次完成,单次回收更多 Region。-XX:InitiatingHeapOccupancyPercent=35:堆占用达到 35% 就开始并发标记,提前触发 Mixed GC。-XX:G1ReservePercent=15:预留 15% 堆空间给新生代晋升,避免 Allocation Failure。-XX:+AlwaysPreTouch:启动时把堆内存摸一遍,避免运行时缺页中断。
调整后的效果:
- Full GC 从每 30 分钟一次降到 3 天一次
- Full GC 单次时间从 4 秒降到 80 毫秒
- 订单接口 P99 从 5 秒降到 280 毫秒
- Humongous Allocation 次数从每天 2000+ 降到每天 100 以内
七、G1 回收流程源码级理解
G1 的 Young GC 大致流程:
- STW,扫描 GC Roots。
- 标记 Eden 和 Survivor 里的存活对象。
- 把存活对象复制到新的 Survivor 或 Old Region。
- 清空原来的 Eden/Survivor Region。
源码入口在G1CollectedHeap::do_collection_pause_at_safepoint:
void G1CollectedHeap::do_collection_pause_at_safepoint(...) { // 1. 选择要回收的 Collection Set // 2. 扫描 GC Roots // 3. 标记存活对象 // 4. 复制存活对象到新的 Region // 5. 清理回收后的 Region }Mixed GC 是在 Young GC 基础上,额外回收一部分老年代 Region。G1 会根据停顿目标选择回收价值最高的老年代 Region。源码在G1CollectionSetChooser:
// 简化:选择回收效率最高的老年代 Region void G1CollectionSetChooser::add_old_regions(...) { // 按 reclaimable memory / collection cost 排序 // 选择 top N 加入 Collection Set }Full GC 是 G1 最后的兜底,走单线程标记-整理。G1 设计目标就是尽量避免 Full GC,一旦 Full GC 频繁,说明参数或代码有问题。
八、我的取舍判断
- 不要一上来就调大 -Xmx,先分析 GC 日志。很多时候问题不是堆不够,而是参数不对或大对象太多。
- 如果日志里 Humongous 分配频繁,优先调大 G1HeapRegionSize。这是最容易被忽略的参数。
- MaxGCPauseMillis 不要设得太激进,200ms 对大多数服务够用,但对大堆可以适当放宽。
- 业务上减少大对象分配,比如报表不要一次性加载到内存再序列化,用流式处理或分片返回。
- G1ReservePercent 适当增加,给新生代晋升预留空间,避免 Allocation Failure。
- AlwaysPreTouch 对低延迟服务有价值,但要接受启动时间变长。
九、复盘真实数字
- 优化前 Full GC:每 30 分钟一次,单次 4 秒+
- 第一次调整
-Xmx=8g后:每 60 分钟一次,单次 6 秒+ - 最终优化后 Full GC:3 天一次,单次 80ms
- 订单接口 P99:从 5 秒降到 280ms
- Humongous Allocation:从每天 2000+ 降到 100 以内
- JVM 参数调整耗时:2 天压测验证
十、思考题
你的服务 GC 日志里有没有频繁出现G1 Humongous Allocation?如果有,你调整过 G1HeapRegionSize 吗?欢迎在评论区交流。