Java

Old 区没打满却频繁 Full GC?一次 G1 Humongous Allocation 事故复盘

这篇不泛讲 Full GC 的所有成因,只复盘一次真实线上问题:Old 区只有六成多,但预览接口一次性拼出超大 JSON,触发 G1 Humongous Allocation,最后把 Full GC 节奏打出来。

  • Java
  • JVM
  • GC
  • Full GC
  • 性能排查
12 分钟阅读

“Old 才 62%,为什么 Full GC 已经两三分钟来一次了?”

这篇我不打算把它写成“Old 没满却 Full GC 频繁的原因大全”。我只讲一次非常具体的现场:G1 下的大对象分配事故。

那次线上真正发生的事,不是老年代慢慢堆满,也不是 JVM 在跟人打哑谜,而是一个新上的预览接口一次性在内存里拼了超大的 JSON,触发了 G1 Humongous Allocation。从那一刻开始,问题的重点就不是 “Old 区到底还剩多少”,而是 这批大对象还能不能顺利放进去,以及 GC 日志已经在提示什么

如果你的现场没有 Humongous Allocation、没有超大响应体、也没有大块 byte[] / char[],这篇就不要硬套。它只对应这一类事故。

先看到的异常,不是 Old 满了,而是几台带预览流量的实例先抖

事故发生在一次营销活动预览窗口。推荐服务给运营后台提供“活动预览”接口,单次会返回大量候选商品、富文本说明和图片信息。

当时监控面板上的现象很别扭:

  • Old 区在 58% 到 64% 之间波动
  • Heap 总体也没撞到上限
  • 但 Full GC 已经从十几分钟一次变成 2 到 3 分钟一次
  • p99 和 CPU 一起往上跳
  • 14 台实例里,最先抖的是接到预览流量的 3 台

这几条放在一起看,第一反应不该是“先把堆调大”。因为如果是整组实例一起进入老年代压力,症状通常不会只在带某类请求的少数节点先冒头。

那次最值钱的判断,就是先把视线从 Old 百分比移开,去看 GC 日志里写的触发词

真正把方向收紧的,是这两行 GC 日志

GC 日志里最关键的是这两行:

GC(512) Pause Young (Concurrent Start) (G1 Humongous Allocation) 6144M->5872M(10240M) 182ms
GC(519) Pause Full (G1 Compaction Pause) 7021M->5210M(10240M) 2.31s

看到 G1 Humongous Allocation,方向其实已经很明确了。

在这个现场里,它说明的不是“Old 区统计图画错了”,而是:

  • 应用正在申请大对象
  • G1 需要为这类对象找连续 Region
  • 卡住你的,可能不是总量,而是空间形态和分配时机
  • Full GC 是后面被逼出来的结果,不是最开始的原因

也就是说,这次事故从一开始就不该按“老年代已经装满”去理解,而该按“大对象分配把 G1 的节奏打乱了”去理解。

为什么我没有先走“内存泄漏”那条线

这次如果只看“Full GC 变频繁”,很容易把它往长期泄漏上带。但现场里有几条证据并不支持这个方向。

1. 不是整组实例一起变差,而是预览流量命中的节点先坏

14 台实例里,先出问题的是 3 台。它们有同一个特征:都接到了运营后台的预览请求。

如果是老年代长期积债,通常更像“跑久了都一起沉”,而不是“谁接到某个接口谁先抖”。

2. Full GC 后的基线没有一路抬高

我们把故障前后几个小时的曲线放一起看,发现 Full GC 之后 Heap 和 Old 都还能明显回落,没有那种一轮比一轮更高、低峰也回不去的长相。

这说明它不像是对象一直留住不放,更像是某类请求在短时间内制造了很差的分配形态。

3. Metaspace 和容器边界也没有同步失真

类加载数量稳定,GC 日志里没有 Metadata GC Threshold,RSS 也没有明显脱离 Heap 单独猛涨。

所以那次可以先把下面两条线压住:

  • 不是 Metaspace 在顶
  • 不是容器先把进程逼到 OOMKilled 边缘

这些排除动作的意义,不是为了凑“排查清单”,而是为了避免把人带离真正的现场。

把事故坐实的,不是猜测,而是线程栈和对象长相

有了 Humongous Allocation 这个信号以后,下一步不是继续讲 GC 原理,而是去找:到底是谁在制造大对象

线程栈先把问题拉回预览接口

故障实例上的线程栈里,最显眼的是这一段:

"http-nio-8080-exec-71"
  at com.xx.preview.PreviewAssembler.buildFullPayload(PreviewAssembler.java:214)
  at com.xx.preview.PreviewController.preview(PreviewController.java:57)

这说明当时有请求线程正在做整包拼装,而不是边取边回。

再去翻接口日志,现场就更具体了:运营在批量预览时把单次候选商品数量拉到了 20,000 条,富文本说明、图片信息和扩展字段也都一起塞进响应对象里。

这时问题已经不是抽象的“内存有点高”,而是很具体的“一个接口在内存里攒了太大的返回体”。

Histogram 再把“大对象”钉死

类直方图里出现了很扎眼的分布:

  • 多个 8MB 到 16MB 的 byte[]
  • 大量由富文本拼接产生的 char[]
  • 一批体积异常的 PreviewItemDTO

线上当时看到的,不是普通的短命小对象潮,而是几个明显偏大的块一起冒出来。

把 GC 日志、线程栈和 histogram 放在一起,这条链就连上了:

  1. 预览接口一次性拉很大批量的数据
  2. 应用在内存里整包拼响应
  3. 生成了大块 byte[] / char[]
  4. G1 记录到 Humongous Allocation
  5. Full GC 频率被后续空间整理和回收节奏顶上去

到这里,这篇文章真正要讲的东西其实已经够了:Old 没满,只是表象;真正的问题是这次大对象分配事故。

后来真正有效的动作,都切在“大对象分配链”上

那次最后能止住,不是因为大家终于接受了“Old 其实挺危险”,而是因为动作切到了正确的位置。

我们做了三件事:

1. 给预览接口加单次返回上限

先把单次候选商品条数压下来,不再允许一个请求把几万条数据连同富文本一起拖进来。

这个动作最直接,因为它先把最夸张的响应体截掉了。

2. 富文本和图片说明改成按需加载

原来接口会把说明信息一次性拼进完整结果。后来改成列表先返回核心字段,详细说明按需再取。

这一步减少的不是“总业务数据量”,而是一次请求里必须同时待在内存里的那部分对象

3. 大响应改成流式输出

原先代码会先在内存里把完整 payload 拼好,再统一序列化输出。改完之后,至少不再需要先把整包内容攒成一个特别大的中间对象。

这一步对这次事故尤其关键,因为它直接改变了对象分配模型。

怎么确认自己抓对了,不是碰巧回落

这类问题最怕“改了一个动作,指标刚好自己回去了”,然后误以为根因已经抓到。

那次我们确认判断成立,看的不是单个指标,而是下面几件事有没有一起发生:

  • 相同预览流量再进来时,GC 日志里不再出现 Humongous Allocation
  • Full GC 从数分钟一次恢复到正常水平
  • 预览请求的响应体大小明显下降
  • 故障线程栈里不再频繁出现 buildFullPayload 这条整包拼装链
  • Old 区仍会波动,但不会再被预览请求轻易拖进 Full GC 节奏

真正说明方向对了的,不是“某一刻 Old 变低了”,而是 触发词、对象形态和结果层症状一起消失了

这篇能给你的,不是“通用答案”,而是一个很窄但很实用的判断边界

如果你也遇到“Old 区没打满,但 Full GC 忽然变勤”的现场,而同时又能看到下面这些证据:

  • G1 日志里有 Humongous Allocation
  • 问题集中在某类大响应、大批量导出、整包序列化请求
  • histogram 里有明显偏大的 byte[]char[] 或大 DTO
  • 几个命中该流量的实例先坏,而不是整组一起沉

那就不要再把主要精力花在“Old 百分比到底算不算高”上了。对这种现场,更值得先追的是:是不是某条请求链在制造大对象,导致 G1 在分配和整理上先失去从容。

反过来说,如果你没有这些证据,尤其是 GC 日志里根本没有 Humongous Allocation,那这篇复盘也就到此为止。它不是“Old 没满却 Full GC 频繁”的通用答案,它只对应这一次被大对象分配坐实的事故。