给 G1 加了 2G 堆,Full GC 反而更频繁:大对象分配陷阱
场景:一个数据报表服务,JDK 11 + G1 默认参数,高峰期 Full GC 每小时 120 次、单次停顿 1.4s,RT p99 从 90ms 飙到 2.1s 路径:事故画像 → 盲区(加堆/G1HeapRegionSize/换 ZGC 三连无效)→ GC 日志锁定 Humongous → G1 Region 布局图解 → 分块读取方案 → 标记命令
上篇我们聊了 JDK8 默认 GC 在高并发下的失控,换 G1 把停顿从 1.2 秒降到 80ms。这篇我们来看另一个方向的坑:同样是 G1,一个 17MB 的对象在堆里怎么变成"癌症",加 2G 堆不但没救反而更糟。
同一个服务,同一份代码,JDK 11 默认 G1——它本该是上一篇里我们推荐的那个"停顿可控"的答案。但高峰期 Full GC 每小时 120 次、单次停顿 1.4 秒,RT p99 从 90ms 直接飙到 2.1s。给堆从 4G 加到 6G,Full GC 次数反而涨到了每小时 156 次。
不是 G1 又选错了——是我们往堆里塞了一个它不会碰的东西。
【画像】一个批量导出接口的 Full GC 失速
事故时间线
监控告警的时间线是这样的:
| 时间 | 现象 |
|---|---|
| 10:12 | 报表导出接口上线新功能,支持一次导出 40 万行(约 17MB) |
| 10:15 | Full GC 开始每小时零星出现 1-2 次 |
| 10:30 | Full GC 频率上升到每小时 30 次,RT p99 破 300ms |
| 10:42 | 告警触发:Full GC 每 30 秒一次,单次停顿 1.4s |
| 10:50 | 盲区三连:加堆 → 更糟;调 Region 大小 → 无效;怀疑 G1 → 错方向 |

应用画像
一个均衡型数据报表服务,TPS 300、RT p99 目标 200ms、堆 4G、JDK 11(默认 G1),跑默认参数——唯一的"特殊"是批量导出接口,一次查询要组装一个 17MB 的大数组返回。
这里先定义本篇的关键词:Humongous(巨型对象),指大小超过 G1 Region 一半的对象。G1 把堆切成等大小的 Region(默认按堆大小自动定,4G 堆约 2MB),正常对象都装进一个 Region,而这个 17MB 的数组装不下,要横跨 9 个连续 Region。

Full GC 频率与 RT 的对齐
把 Full GC 曲线和 RT p99 画在一起规律很清楚:每次 Full GC 停顿,就是一次 RT 尖刺。10:42 之后 Full GC 每 30 秒一次,RT p99 基本告别了 200ms。

(数据来自压测环境 GC 日志快照:JDK 11.0.12,4G 堆,4 并发批量导出压测 10 分钟,Full GC 触发 20 次,最大停顿 1384ms;20 次 Full GC 的触发原因 100% 为 Allocation Failure,grep 可验证。)
【盲区】大多数人的第一反应:加堆、调 Region、换 ZGC
加堆为什么会更糟
按"Full GC 频繁 = 内存不够"的直觉,值班同学第一个操作就是 -Xmx6g。结果出乎所有人意料——Full GC 次数从每小时 120 次涨到 156 次。
为什么?因为 G1 的 Region 大小有限:默认按堆大小计算(约为堆的 1/2048 再 round-down 到 2 的幂,4G→2MB、6G(6/2048≈3M 向下取 2 幂)仍是 2MB、8G 整恰为 4MB)。你从 4G 加到 6G,Region 依然是 2MB,17MB 数组照样横跨 9 个连续 Region,Humongous 判定和占用毫不变。
所以加堆对根因毫无影响,却带来两个副作用:堆越大,单次 Full GC 要全堆清扫的物理 Region 范围越广,停顿越重;同时 Humongous 保留了更多 Region,每次 Full GC 的停顿账单更贵。压测快照实测 Full GC 从 120/hr 升到 156/hr——不是加堆治好了什么,是它把每次都停更久的 Full GC,以同样(甚至略高)的频率保留了下来。
| 操作 | 预期 | 实际 |
|---|---|---|
-Xms4g -Xmx6g 加堆 |
缓解 Full GC | Full GC 每小时 120 → 156 次,更糟 |
-XX:G1HeapRegionSize=4m 调大 Region |
减小碎片 | 大对象还是超 Region 一半,依然 Humongous |
| 怀疑 G1 不行想换 ZGC | 换收集器根治 | 错方向:问题在分配模式不在收集器 |

被误解的 G1HeapRegionSize
-XX:G1HeapRegionSize 很多人以为调大就能装下大对象。它改变的是 Region 的粒度,不改变 Humongous 的判定——判据只有一个:对象大小 > Region 一半。17MB 的对象,你把 Region 调到 4MB、8MB、16MB,它永远是 Humongous,永远横跨多个 Region。
(关于 Region 自动计算的机制,参考官方 G1 调优指南:G1HeapRegionSize 不设置时由堆大小决定,约为堆的 1/2048,取 2 的幂并夹在 1-32MB。)
【路标】GC 日志锁定 G1 Humongous Allocation
GC 日志原文
开启 GC 日志(JDK 8 用 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps;JDK 9+ 已废弃这两个开关,用 -Xlog:gc* 替代,JDK 11 下前面两个开关只是在打 deprecated 提示),GC 日志暴露了三个特征字符串。先看 Humongous 触发的 Young GC:
129.012: [GC pause (G1 Humongous Allocation) (young), 0.0388440 secs]
[Parallel Time: 33.0 ms, GC Workers: 8]
[Eden: 2072.0M(2072.0M)->0.0B(2048.0M) Survivors: 32.0M->32.0M Heap: 3630.0M(4096.0M)->1094.0M(4096.0M)]
再看压垮骆驼的 Full GC——注意触发原因是 Allocation Failure(分配失败),不是 Ergonomics:
129.020: [Full GC (Allocation Failure) 3667M->1206M(4096M), 1.3840120 secs]
[Eden: 0.0B(1024.0M)->0.0B(1024.0M) Survivors: 0.0B->0.0B Heap: 3667.0M(4096.0M)->1206.0M(4096.0M)]
[Humongous Regions: 36(72M)]
[Metaspace: 218266K->215856K(218266K)]

(日志来自 8C16G 容器压测快照,JDK 11.0.12。为方便对照,此处以 JDK 8 经典 PrintGCDetails 格式展示;JDK 9+ 用 -Xlog:gc* 时字段相同、只是多带 decorator 前缀。红色高亮:G1 Humongous Allocation、Humongous Regions: 36(72M)、1.3840120 secs。)
两个特征连起来看:
- G1 Humongous Allocation = 一次 Young GC 是因为分配大对象空间不足而触发的
- Humongous Regions: 36(72M) = 堆里有 36 个 Region 正被大对象占用,共 72M(2M/Region × 36)——对应 4 个并发大数组各占 9 个 Region——这些 Region G1 不会主动回收
The key insight:Humongous 不会被移动
这里藏着问题的本源:G1 的 Region 回收机制对普通对象是"拷贝式"的——Young GC 时把存活对象从当前 Region 拷贝到空闲 Region,原 Region 就能复用。但 Humongous 对象不参与这种拷贝,它只能靠后续的并发标记(Concurrent Mark)确定它已经死了才释放,或者等 Full GC 全堆清扫。
于是:一个 17MB 的数组,分配时横跨 9 个连续 Region(9×2M=18M),只要它"活着"哪怕只有几秒,这 9 个 Region 在这几秒内就是死的空间。导出接口高并发时同时有多个大数组在飞,Region 被迅速占用殆尽,活着的大对象又拷贝不走,只能 Full GC 全堆清扫——Full GC 频繁的本质,是 Humongous 对象把老年代连续空间吃光又不肯挪窝,回收只能靠全堆停摆。
G1 Region 布局图解:正常对象 vs Humongous

根因锁定:17MB 大数组
jmap -histo 直接定位到分配大户:
num #instances #bytes class name
----------------------------------------------
1: 4 75497472 [B ← 4 个并发 18MB byte[](每个横跨 9 个 Region)
2: 3200 762112 [Ljava.lang.Object;
3: 2048 212992 [Ljava.lang.String;
![jmap -histo:4 个 18MB byte[] 独占堆对象榜首](screenshots/code-jmap-histo.png)
[B 就是 byte[]——导出接口一次把整份 CSV 拼成一个 18MB 的字节数组塞进响应,高峰期 4 个并发在飞。
【方案】分块读取:让大对象在堆里消失
核心修复:把 17MB 拆成 400KB 小块流式写
既然 Humongous 的判据是"对象 > Region 一半"(4G 堆 Region 2MB,即单对象 >1MB 就触发),最干净的办法就是让每个对象别超过这个线。把"一次拼 17MB"改成"分块流式输出,每块控制 400KB 以下":

| 参数 | 作用 | 为什么在这里生效 |
|---|---|---|
-XX:+UseG1GC(JDK9+ 默认) |
确认在跑 G1 | G1 的停顿可控优势前提是 Region 能正常拷贝回收 |
| 业务层分块读取 | 让最大对象 < Region 一半 | 根治:400KB < 1MB 阈值,永不触发 Humongous |
-XX:G1HeapRegionSize(可选) |
放大 Region 粒度 | 配合分块减少碎片冗余;对 400KB 对象非必需 |
为什么分块在这里生效
- 分块后最大对象 400KB:4G 堆 Region 2MB,Humongous 阈值 = 2MB/2 = 1MB,400KB 远低于阈值,走正常拷贝回收路径
- 拷贝回收活了:Young GC 时这些对象被拷贝到新 Region,原 Region 马上复用,不再等 Full GC
- Full GC 归零:不再有大量 Region 被大对象长期占用,老年代连续空间保住了
【盲区】里加堆无效的深层原因在这里也解释通了:加堆只影响 Region 变大,但 17MB 无论如何都跨过阈值线——唯一能救命的是把对象体积降到线以下,而不是把堆变大。

(可选)补防:调整 G1HeapRegionSize 减少碎片
如果业务上确实避免不了偶尔的大对象(比如文件读取),再配合放大 Region 减小横跨数量。但记住:调 Region 是缓解,不是根治——只要对象还在 Region 一半以上,Humongous 判定不变。
边界条件:什么场景不要用这个方案
- 必须要 17MB 原子返回(协议限制,比如上游要求单响应体)→ 分块不适用,只能依赖 Region 放大 + 内存预算
- 对象只在内存中短暂存在(如读文件立即写文件)→ 可以通过
-XX:G1HeapRegionSize配合,但核心仍是缩短大对象生命周期 - 如果你在 JDK 8 上,G1 需要显式
-XX:+UseG1GC(JDK 8 默认是 Parallel GC,见上篇)
【标记】🔍 在你的 GC 日志中搜索
确认你的服务有没有踩这个大对象陷阱——一条命令:

grep 命令定位
grep -E 'G1 Humongous Allocation|Humongous Regions' gc.log
预期输出与判读
129.012: [GC pause (G1 Humongous Allocation) (young) ... ← 大对象触发的 Young GC
129.020: [Full GC (Allocation Failure) ... Humongous Regions: 36(72M) ← 大对象占住的 Region
- 看到
G1 Humongous Allocation→ 你的代码在分配超过 Region 一半的对象,注意它触发的 Young GC 越多,离 Full GC 越近 - 看到
Humongous Regions且数字在持续上涨 → 老年代正被大对象持续占用,Full GC 只是时间问题 Full GC (Allocation Failure)+Humongous Regions同时出现 → 已进入全堆清扫模式,优先排查大数组/大 List
GC 日志不是一份日志文件——它是一份性能体检报告。Humongous Regions 就是体检单上"血栓已形成"的红章。
大对象不是 bug,是分配模式选择了你无法回收的路径。G1 不是不擅长回收,是面对 17MB 的对象,它的"拷贝回收"直接失灵。
所以接到这类告警,第一反应不该是给堆加内存——先看一眼代码里有没有一次分配超 Region 一半的对象,这比调任何 JVM 参数都快。
下一篇我们换个角度,专门把 GC 日志当一个体检报告来读:Pause Young、Humongous Regions、Allocation Failure 这些关键词各代表什么,从一份日志里能看出哪些结论。