`GC overhead limit exceeded` 怎么排查?它常常不是普通 OOM
看到 `GC overhead limit exceeded` 时,别急着把它等同成堆不够。先看 GC 已经浪费了多少时间、对象是长期回不来,还是某个业务窗口突然制造了一场对象风暴。
GC overhead limit exceeded 这类报错,最容易让人做出一个过早、也最省事的结论:
- 反正也是 OOM
- 堆不够了
- 调大
-Xmx,再抓个 Heap Dump
这条路有时候能碰巧救火,但它经常解释不了现场真正的样子。
我印象最深的一次,是结算服务在上午 10 点整批量导出日报时,两个实例同时打出:
java.lang.OutOfMemoryError: GC overhead limit exceeded
值班群里一开始也有人直接说“又是 heap OOM”。但那次我没有先按泄漏去查,因为事故长相不对:
- 只在 10:00 左右出现
- 影响的是跑导出的那两台,不是整组实例
- Heap 会冲得很高,但任务停掉后又能明显回落
- 业务线程先被 GC 拖慢,报错反而出现在后面
也就是说,这不是最典型那种“堆里有一批对象长期回不去,最后终于分配失败”的故事。
它更像:JVM 还没完全死,但已经把大量时间耗在无效 GC 上,业务吞吐先被拖垮了。
这篇就围绕那次现场讲:为什么我先怀疑“对象制造风暴”而不是长期泄漏,哪些证据把别的方向排掉,最后动作以后,又该看什么来验证自己抓到的是根。
报错前 8 分钟,现场其实已经在说话了
那次值班最关键的收获,是别把 GC overhead limit exceeded 当成事故开端。
真正的异常从 09:52 就开始了:
- 09:52:报表导出任务进入生成 Excel 阶段
- 09:55:Young GC 次数明显变密
- 09:57:Full GC 开始介入,单次停顿从 400ms 拉到 2s+
- 09:59:接口 RT 和 worker 线程等待时间一起抬头
- 10:01:应用才开始打印
GC overhead limit exceeded
也就是说,错误文本只是最后一个信号,不是第一现场。
真正先把我拉住的,是 GC 日志里一段非常难看的连续记录:
[09:58:11] GC(341) Pause Full (G1 Compaction Pause) 7821M->7684M(8192M) 6.91s
[09:58:24] GC(342) Pause Full (G1 Compaction Pause) 7855M->7719M(8192M) 7.08s
[09:58:39] GC(343) Pause Full (G1 Compaction Pause) 7890M->7752M(8192M) 7.42s
这三行已经把关键事实说明白了:
- JVM 花了很重的代价做 Full GC
- 但每次只回出一点点
- 业务线程会被不断停顿拖住
这就是 GC overhead limit exceeded 真正麻烦的地方:它不只是“堆满了”,而是 JVM 已经处在一种高成本、低回收收益的挣扎状态。
为什么那次我没先按“长期泄漏”去查
因为事故时间形态不支持这个结论。
当时我们先把三件事放在一起看:
- GC 时间占比
- 任务时间线
- 受影响实例分布
然后发现:
只有跑导出的实例先坏
整组 12 台实例里,真正先抖的是接了日报导出任务的 2 台。其他实例虽然 RT 受牵连,但没有同步打出同样的 OOM。
如果是典型长期泄漏,常见长相更像:
- 服务跑久了越来越重
- 低峰也回不去
- 整组实例会逐步一起恶化
那次不是,它太像一个业务窗口触发的局部风暴了。
任务停掉后,Heap 能明显回落
我们临时停掉导出任务以后,下一轮 Full GC 已经能把 Heap 从 7G+ 拉回 4G 出头。
这一步非常关键。它不代表绝对没有泄漏,但足以把“优先去查长期持有对象”这条线压低。
如果问题核心是长期对象一直回不去,光停一个任务,不会让基线回得这么明显。
真正把主线拉清的,是线程栈和 Histogram
那次现场里,GC 日志告诉了我们“JVM 在无效挣扎”,但还没告诉我们“是谁把 JVM 拖进这个状态”。
继续收敛靠的是两类证据。
线程栈先证明:业务线程正在生成一大批中间对象
线程栈里最密集的一条调用链是:
"export-worker-3"
at java.util.ArrayList.grow(ArrayList.java:237)
at com.xx.report.OrderDailyExporter.buildRows(OrderDailyExporter.java:182)
at com.xx.report.OrderDailyExporter.writeWorkbook(OrderDailyExporter.java:219)
这条栈很有指向性:
- 不是 GC 参数先坏了
- 也不是容器边界先杀进程
- 是导出逻辑在内存里一次性攒了太多行对象和字符串副本
Histogram 再证明:大头对象是“导出现场制造”的,不是长期常驻的
我们补出来的类直方图里,前几名非常集中:
byte[]char[]java.lang.Stringcom.xx.report.OrderDailyRowjava.util.ArrayList
这组对象当然不能直接等同根因,但和线程栈叠起来看,已经足够说明:这次错误更像一次导出窗口把对象制造速度推到 GC 追不上。
这也是为什么 GC overhead limit exceeded 不能简单等同为“普通 heap OOM”。它更强调的是:
GC 时间已经被拖爆了,但回收收益又不够。问题可能是长期回不来,也可能是你在某个窗口里造得太猛。
哪些方向那次被明确排掉了
不是 Pod 先 OOMKilled
应用日志已经先打印了 GC overhead limit exceeded,Pod 事件里没有先出现 OOMKilled。所以像 容器 OOMKilled 和 Java heap OOM,先怎么区分? 那条外层边界线,当时不是第一优先级。
不是 Metaspace / 类加载问题
Metaspace 基本稳定,没有发布后类加载数阶梯式抬升,也没有 Metadata GC Threshold。所以这也不是 Metaspace 一直涨但堆没 OOM,怎么判断是不是 ClassLoader 泄漏? 那类现场。
不是典型长期缓存泄漏
任务停掉后 Heap 明显回落,第二天非导出时段实例也比较轻。长期泄漏通常没这么听话。
最后真正有效的动作,不是调 GC 参数
那次如果只去折腾 GC 参数,最多只是把无效挣扎的姿势换一下。
我们真正落下来的动作是:
- 导出改成分页读取、分批写文件,不再一次性把整批结果攒进内存
- Excel 生成改流式写出,避免保留整份工作簿对象
- 对单次导出数据量加上硬上限
这些动作之所以对,是因为它们直接切在“对象制造风暴”这条链上。
动作后,怎样验证不是侥幸
验证那次判断成立,我们盯的不是一条“没再报错”日志,而是这几件事有没有一起回归:
- 10:00 导出窗口里,Young GC 仍会抬高,但不再立刻进入 Full GC 风暴
- GC 时间占比从事故时的 80%+ 降回到 15% 左右
- 同样规模的日报导出完成时,Heap 峰值从接近 8G 降到 4.6G
- 导出完成后,下一轮 Full GC 能把堆明显拉回正常基线
只有这些都成立,才能说明 GC overhead limit exceeded 的根,不在“参数没调对”,而在你把实例拖进了一个 GC 根本追不上的对象制造窗口。
所以,看到 GC overhead limit exceeded,最该先分什么
如果把那次值班经验压成一句话,我会记成:
先分它是“长期回不来”,还是“某个窗口里造得太快”,再决定去查泄漏还是查业务峰值链。
这类报错最怕两种偷懒:
- 直接把它归成普通 heap OOM
- 直接把它归成 GC 参数问题
前者会让你忽略 GC 时间已经先把吞吐拖死;后者会让你跳过对象和业务窗口本身。
真正有用的顺序通常是:
- 先看 GC 花了多少时间、每次回了多少
- 再看是不是有特定业务窗口把对象制造速度顶上去
- 最后才决定往长期持有对象还是短时对象风暴去收
把这三步走对,GC overhead limit exceeded 就不再只是“换一种写法的 OOM”,而是一条更早、更值钱的事故信号。