Java

`GC overhead limit exceeded` 怎么排查?它常常不是普通 OOM

看到 `GC overhead limit exceeded` 时,别急着把它等同成堆不够。先看 GC 已经浪费了多少时间、对象是长期回不来,还是某个业务窗口突然制造了一场对象风暴。

  • Java
  • JVM
  • OOM
  • GC
  • 故障排查
14 分钟阅读

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.String
  • com.xx.report.OrderDailyRow
  • java.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 时间已经先把吞吐拖死;后者会让你跳过对象和业务窗口本身。

真正有用的顺序通常是:

  1. 先看 GC 花了多少时间、每次回了多少
  2. 再看是不是有特定业务窗口把对象制造速度顶上去
  3. 最后才决定往长期持有对象还是短时对象风暴去收

把这三步走对,GC overhead limit exceeded 就不再只是“换一种写法的 OOM”,而是一条更早、更值钱的事故信号。