Old 区没打满却频繁 Full GC?一次 G1 Humongous Allocation 事故复盘
这篇不泛讲 Full GC 的所有成因,只复盘一次真实线上问题:Old 区只有六成多,但预览接口一次性拼出超大 JSON,触发 G1 Humongous Allocation,最后把 Full GC 节奏打出来。
“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 放在一起,这条链就连上了:
- 预览接口一次性拉很大批量的数据
- 应用在内存里整包拼响应
- 生成了大块
byte[]/char[] - G1 记录到
Humongous Allocation - 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 频繁”的通用答案,它只对应这一次被大对象分配坐实的事故。