给 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 → 错方向

事故时间线:从新功能上线到 Full GC 失速

应用画像

一个均衡型数据报表服务,TPS 300、RT p99 目标 200ms、堆 4G、JDK 11(默认 G1),跑默认参数——唯一的"特殊"是批量导出接口,一次查询要组装一个 17MB 的大数组返回。

这里先定义本篇的关键词:Humongous(巨型对象),指大小超过 G1 Region 一半的对象。G1 把堆切成等大小的 Region(默认按堆大小自动定,4G 堆约 2MB),正常对象都装进一个 Region,而这个 17MB 的数组装不下,要横跨 9 个连续 Region。

Full GC 频率随导出功能上线急剧攀升

Full GC 频率与 RT 的对齐

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

RT p99 曲线:Full GC 停顿尖刺与 RT 尖刺时间轴对齐

(数据来自压测环境 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 换收集器根治 错方向:问题在分配模式不在收集器

参数三连的预期 vs 实际:加堆反而加重

被误解的 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)]

GC 日志原文:G1 Humongous Allocation + Full GC 关键行高亮

(日志来自 8C16G 容器压测快照,JDK 11.0.12。为方便对照,此处以 JDK 8 经典 PrintGCDetails 格式展示;JDK 9+ 用 -Xlog:gc* 时字段相同、只是多带 decorator 前缀。红色高亮:G1 Humongous AllocationHumongous 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

G1 Region 布局:普通对象单 Region 拷贝回收 vs Humongous 横跨多 Region 无法移动

根因锁定: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[] 独占堆对象榜首

[B 就是 byte[]——导出接口一次把整份 CSV 拼成一个 18MB 的字节数组塞进响应,高峰期 4 个并发在飞。

【方案】分块读取:让大对象在堆里消失

核心修复:把 17MB 拆成 400KB 小块流式写

既然 Humongous 的判据是"对象 > Region 一半"(4G 堆 Region 2MB,即单对象 >1MB 就触发),最干净的办法就是让每个对象别超过这个线。把"一次拼 17MB"改成"分块流式输出,每块控制 400KB 以下":

分块流式输出:cursor 分页 + 复用缓冲池,最大对象 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 无论如何都跨过阈值线——唯一能救命的是把对象体积降到线以下,而不是把堆变大。

修复前后对比:分块后 Full GC 归零、RT p99 恢复基线

(可选)补防:调整 G1HeapRegionSize 减少碎片

如果业务上确实避免不了偶尔的大对象(比如文件读取),再配合放大 Region 减小横跨数量。但记住:调 Region 是缓解,不是根治——只要对象还在 Region 一半以上,Humongous 判定不变。

边界条件:什么场景不要用这个方案

  • 必须要 17MB 原子返回(协议限制,比如上游要求单响应体)→ 分块不适用,只能依赖 Region 放大 + 内存预算
  • 对象只在内存中短暂存在(如读文件立即写文件)→ 可以通过 -XX:G1HeapRegionSize 配合,但核心仍是缩短大对象生命周期
  • 如果你在 JDK 8 上,G1 需要显式 -XX:+UseG1GC(JDK 8 默认是 Parallel GC,见上篇)

【标记】🔍 在你的 GC 日志中搜索

确认你的服务有没有踩这个大对象陷阱——一条命令:

grep 命令与预期输出:G1 Humongous Allocation 定位

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 YoungHumongous RegionsAllocation Failure 这些关键词各代表什么,从一份日志里能看出哪些结论。