☰
Flink任务OOM排查实战:jmap、jstat、MAT工具链从根因定位到修复
2026/10/14 7:31:25 网站建设 项目流程

上个月我这边值班,凌晨三点被告警电话叫起来,Flink 任务直接把容器打死了。登录 Clustermanager 一看,作业状态是 FAILED,日志里写着java.lang.OutOfMemoryError: Java heap space,重启之后不到半小时又挂了一次。这种问题相信不少人都撞上过,排查链路绕不开一套经典工具链:jmap、jstat、jvisualvm,再加一个 MAT。这篇就完整复盘一下我是怎么用这些工具,把 Fliink 任务 OOM 从现象到根因一步步挖出来的,顺带把每个工具在什么时候该用、怎么用、怎么看结果都讲透。

这次 OOM 排查持续了大概一个工作日,从最初怀疑堆内存不够,到最终定位到是一处缓存对象被长期持有,中间走了不少弯路。尤其是 Flink 这种有着复杂内存模型的框架,直接用常规的 JVM 工具去查,很容易被一些假象误导。我会把这些坑也一并说清楚,争取让读到这篇的人少走几步弯路。

1. 告警响应的第一现场:OOM出现时我做了什么

1.1 第一次看到"Container killed by external signal"时的判断

Flink 任务的 OOM 和普通单体应用的 OOM 有个很不一样的地方:你往往看不到干净的OutOfMemoryError日志。我的作业日志里最显眼的是三行:

[INFO] Yarn application has finished. [ERROR] Container killed by external signal

第一眼看上去像是被 YARN 主动干掉的,像是容器超了内存限制被 NodeManager 强杀。这时候千万别急着去调taskmanager.memory.process.size,先搞清楚一个问题:是 JVM 自己 OOM,还是容器整体超了物理内存?

这两个情况的处理方向完全不同。JVM 堆内存溢出(Java heap space)通常会在日志里留下完整的异常栈,堆转储也能正常生成;而容器被 kill,多半是堆外内存、Native 内存、线程栈或者框架本身的内存占用超了。我这次的现象其实更隐蔽,偶尔能看到Java heap space的报错,但频率不高,更多时候是任务窗口期莫名其妙地失败。

我的建议是先把日志按时间线拉齐,先确认失败发生时的 TaskManager 数量、当时正在跑哪些算子、checkpoint 是否正常,然后再决定用哪个工具。这套流程走完,至少能排除掉"网络抖动""资源不足被挤掉"这类非内存问题。

1.2 先排除"假OOM":从日志和退出码里找线索

有一次我就被"假 OOM"坑过。某个状态很大的 KeyedStream 任务,状态后端用的是 RocksDB,堆外内存一直涨,容器同样被杀。但其实是那次作业的反压问题,导致 checkpoint 时状态快照写入过慢,最终触发了 YARN 的物理内存限制,日志里并没有真正的OutOfMemoryError。如果一上来就 dump 堆,反而找不到真问题。

所以拿到失败任务后,我第一件事是确认 JVM 当时的退出行为:

  • 有没有显式的OutOfMemoryError异常栈
  • 有没有kill -9对应的进程退出信号,比如SIGKILL
  • 有没有TaskExecutorRunner或ClusterActor自身写出的错误信息
  • 有没有 GC overhead limit exceeded 这类间接指标

重点看退出码和错误关键字。如果日志中反复出现Container killed on request. Exit code is 143,那大概率是外部主动关闭,而非 JVM 自身抛错。这次任务属于两者并存,既有堆内溢出的迹象,也有容器被杀,所以我决定从头把堆内存分布摸一遍。

2. 用jstat和jmap给堆内存做"体检"

2.1 jstat的四个核心指标:S0/S1/E/O/M

jstat 是我在线上排查 OOM 时第一个用的工具,原因很简单:它不需要重启进程,也不大会影响业务,随时可以连着看 GC 的状态变化。连接方式直接看 Java 进程的 PID,执行:

jstat -gcutil <pid> 1000 30

每 1 秒打印一次,连续 30 次,核心看这几列:

指标含义本次观察
S0 / S1两个 Survivor 区的使用率一个接近 0,一个接近 100
EEden 区使用率频繁从低位跳到高位,说明对象分配压力很大
O老年代使用率稳步爬升,迟迟不降,这是主要疑点
M元空间使用率相对稳定,没成为瓶颈
FGCFull GC 次数次数不多,但每次耗时都在 3 秒以上

这些数据组合起来,基本能还原一个对象生命周期模型:Eden 区在快速分配对象,一批对象熬过了年轻代的 GC 进入老年代,但老年代空间回收不掉,Full GC 时间越来越长,最终堆可用空间见底。

那次连续观察 30 秒,老年代使用率从 68% 一路涨到 89%,Survivor 区频繁溢出,说明对象晋升速度快得不正常。这时候我基本断定:堆内存在大量长期存活的对象,不是一次性流量冲击那么简单,后续需要 dump 堆来分析。

2.2 jmap的堆快照解读:Total/Used/Free

老年代持续紧张只是表象,要回答"到底是什么对象占着内存",得看 jmap 导出的堆直方图。

最轻量的命令是看类级别的统计:

jmap -histo:live <pid> | head -50

-histo:live会先触发一次 Full GC 再统计存活对象,线上使用时要注意,生产环境如果堆很大,这一下可能让任务卡顿。我一般先不加live,用jmap -histo <pid>看整体分布,虽然包含了一些待回收对象,但对初步定位够用了。

结果里你通常会看到排名靠前的类,比如某个byte[]、某个自定义的缓存类,也可能看到大量HashMap$Node。我在现场看到的是两个值很大的自定义类型:com.dw.connector.cache.TradeCache和org.rocksdb.BlockBasedTable$Block,前者占了 45% 的堆,后者占了大约 20%。

一眼就能看出问题不在TradeCache——这是业务代码里定义的一个交易日快照缓存,理论上应该很小。再看BlockBasedTable$Block,这是 RocksDB 的块缓存相关类,它占堆内存说明 RocksDB 的堆内缓存可能配置得过大,或者存在大量的列族迭代器没有被正确释放。

如果-histo还不够定位,就需要把整个堆导出来,等到第 3 节用 MAT 去做引用链分析:

jmap -dump:format=b,file=/data/flink_dump.hprof <pid>

注意导出 hprof 文件的时间和代价:对十几 GB 的堆,导出过程本身会触发全局暂停,业务会有几十秒不可用。规避方式是等业务低峰期再做,或者在测试环境复现,尽量别在线上的高峰时段直接 dump。

2.3 工具选择的时序问题:什么时候该用哪个

很多人一上来就 jmap -dump,这是不对的。堆转储非常重,而且如果问题出在堆外内存,整份 dump 里根本找不到根因。我个人的排查顺序是:

  1. jstat 看 GC 趋势:先确认是老年代、Eden 还是 Metaspace 的紧张,也顺带确认 GC 耗时是不是已经异常。
  2. jmap -histo 看对象分布:快速圈出 Top 占内存的类,判断是业务对象还是框架对象。
  3. jmap -dump + MAT 看引用链:只有对象分布指向某个可疑类时,再做重操作。
  4. jvisualvm 或 MAT 远程分析:如果 dump 文件太大,直接拿到本地用 MAT 打开。

这套顺序的核心逻辑是从轻到重、从粗到细,每一步都在为下一步缩小范围,而不是一开始就把所有重量级手段全上。我见过有人直接对 20GB 堆执行 dump,搞了半天内存分析工具自己都被撑爆了,最后无从下手。

3. jvisualvm和MAT的组合拳:把内存泄漏的根挖出来

3.1 jvisualvm:轻量可视化,适合快速扫雷

jvisualvm 在 JDK 8 时代还是自带工具,JDK 9 以后被剥离,需要单独下载 VisualVM 发行版。它最实用的功能是对运行中的 Java 进程做抽样器采样,不需要 instrument 所有方法,性能开销相对可控。

使用方式:

  1. 在 TaskManager 所在节点启动,选择对应的 Java 进程
  2. 切到"Visual GC"页签,看各个分区的实时占用曲线
  3. 用"Sampler"里的内存采样,跑几十秒,导出采样结果
  4. 对可疑线程直接做 Thread Dump

在排查现场,Visual GC 帮我看明白了一个细节:老年代在每次数据潮汐性上涨时都会有一个缓慢的爬坡,但曲线回落的幅度非常小。正常情况下,Full GC 之后老年代应该会有明显回落,但这里几乎是一条斜向上的直线。这说明老年代里的大部分对象根本不可达,却一直被某些静态引用或者线程上下文给拽住了。

不过 jvisualvm 也有短板——它只能给运行中的进程做采样,如果容器已经被杀掉、进程已经没了,就只能靠之前的 hprof 文件做离线分析。所以我对它的定位是实时守门员,负责现场确认内存走势和线程状态,而真正的根因定位还要靠 MAT。

3.2 MAT:Dominator Tree与Leak Suspects报告

MAT 全称 Eclipse Memory Analyzer,读 hprof 文件比 jvisualvm 好用很多。打开 dump 之后,我一般直接看两个东西:

Overview -> Report -> Leak Suspects

Leak Suspects 报告会用一句话描述可疑的泄漏点,比如 "The class X, loaded by Y, occupies Z bytes (N%) of the heap." 它内部通常是基于 Dominator Tree 做计算,会列出"支配树"上最占内存的对象集合。点进去之后能直接看到:

  • 这个可疑对象到底有多大
  • 它被哪些 GC Root 引用着
  • 从 GC Root 到它的完整引用路径

路径里的每一环都很关键。比如你看到这样的引用链:

Thread @ 0x7c9183d8 main -> com.dw.connector.cache.TradeCache -> ConcurrentHashMap -> Node -> TradeSnapshot

那问题就非常清楚:某个线程(通常是非 daemon 业务线程)在静态或成员变量层面保存了一个巨大的 ConcurrentHashMap,Map 里的 entry 越积越多,最终把堆塞满。

MAT 还有一个很实用的功能叫Path to GC Roots,可以针对直方图里 Top 的几个对象单独计算。我建议不要只看 Leak Suspects 就收工,因为它只给最可疑的第一名,实际线上可能是多个类联合导致的 OOM,要把 Top 10 的类都过一遍引用链才放心。

3.3 案例分析:从快照到嫌疑对象的完整推演

这次 dump 出来之后,我在 MAT 里看到的 Leak Suspects 有两个。第一个指向TradeCache,占用约 612 MB,引用路径是来自某后台刷新线程的静态属性。第二个指向一个LinkedList对象,是窗口算子中某 key 分组数据的关联缓存,占用了约 350 MB。

仔细看这两个对象的时间线,我发现它们几乎是同步增长的:后台刷新线程每次抓取全量交易日历数据后写入缓存,但刷新逻辑里只做了"更新条目",没有做"清理过期条目"。这个缓存设计用于存储每个交易日的行情快照,key 是日期字符串,value 是当天的交易快照对象。由于任务是长时间运行的流,每过一个自然日,日期 key 就多一个,缓存里的对象就永久驻留一批。看起来每天只增加几十 MB,跑上一周就是几百 MB,最终把老年代挤爆。

这个逻辑和 jstat 的观察是一致的:Eden 区的对象大多数能正常回收,但TradeCache里的快照对象一旦晋升老年代,就在静态引用的作用下永远驻留。Flink 算子本身没有叫它回收,外部线程也没有清理入口,日积月累形成严重的内存增长。

4. Flink内存模型的特殊性:为什么"堆转储正常"却还是OOM

4.1 Flink的堆内/堆外/托管内存/元空间

如果只是常规 JVM 堆溢出,前 3 节的内容其实已经能找到答案。但 Flink 的 OOM 往往比这个复杂,因为它的内存大头可能根本不在堆里。Flink TaskManager 的内存分为几块:

  • 堆内框架内存:给 Flink 框架自身用,比如流处理算子的一些内部数据。
  • 堆内任务内存:给用户代码和算子状态用,默认占比较大。
  • 托管内存(Managed Memory):可以分为堆内或堆外,Flink 用它管理 RocksDB 状态后端、排序缓冲区、批量加载等。如果是 RocksDB,实际上用堆外内存。
  • 直接内存/网络缓冲:网络读写、序列化反序列化时的 Direct ByteBuffer。

我见过一个非常典型的情况:任务代码里堆的使用很健康,jmap 看老年代完全正常,但容器仍然被杀。后来查出来是 RocksDB 的 block cache 大小,以及 Flink 网络缓冲池占用,超出了taskmanager.memory.process.size的进程限制。

Flink 在 1.10 之后对内存管理做了很严格的分区配置,核心参数如下:

配置项作用备注
taskmanager.memory.process.size整个 TaskManager 进程总内存包括堆和堆外
taskmanager.memory.task.off-heap.size任务堆外内存用户代码和框架的堆外部分
taskmanager.memory.managed.size托管内存大小RocksDB 状态后端的主要来源
taskmanager.memory.framework.off-heap.size框架堆外内存网络、RPC 等
taskmanager.memory.jvm-overhead.fractionJVM 开销线程栈、编译缓存、Direct memory

这次任务里,task.off-heap和managed.size都偏大,加上 jmap 看到 RocksDB 块缓存对象在堆内也有大量映射,进一步放大了整体占用。所以在 Flink 里遇到 OOM,不能只看一个维度,堆内堆外要一起拉平来看。

4.2 容易被忽略的元空间和网络缓冲

堆外问题里最容易被忽略的是 Metaspace 和网络缓冲。Metaspace 好像平时很安静,但如果你频繁做动态代理、动态加载类,或者 JVM 的MaxMetaspaceSize没限制,它也会涨到无法控制。

网络缓冲是个更隐蔽的点。Flink 的网络栈基于 Netty,每个 TaskManager 的 network buffer 默认数量跟可用内存、task slot 数强相关。如果taskmanager.memory.network.min/max设置不当,在异常反压下缓冲会占满堆外内存,最终表现为"内存使用率高但堆很空"。这次我们的任务虽然没有明显反压,但jstat的M列一直不算低,我在排查时把 Metaspace 也算在一个次要嫌疑里。

排查这些区域没有比堆 dump 更省事的手段,单纯 jmap 看不到。我的做法是:

jcmd <pid> VM.native_memory

这个命令需要 JVM 启动时加-XX:NativeMemoryTracking=summary,如果没加,只能通过/proc/<pid>/status里的 VmRSS 和jstat算一个大概值。有条件的话,我会给线上任务加上 NMT,虽然有一点性能损耗,但对排查堆外问题太关键了。

4.3 结合工具数据确认根因:主导因子定位

回头把所有工具数据汇总,我最终锁定的主导因子是堆内TradeCache长驻对象,其次还有 RocksDB 块缓存的堆内映射放大了堆占用。这属于堆问题,但如果不做全内存视角的检查,很容易在修完缓存后踩到 RocksDB 的另一个坑。

这次的经验是:当堆 dump 能找到明确的可疑对象时,先修代码;没有可疑对象时,优先看堆外和托管内存。如果两者都有嫌疑,先解决堆内,因为堆内问题通常能模块化定位,而且修完马上就能在测试环境复现对比。

我还复盘了一个容易误判的点:-histo里BlockBasedTable$Block占内存,未必真的有问题,它可能是 RocksDB 状态后端正常的块缓存映射。真正的关键字是看它有没有大量不可释放的"memtable"和"iterators",这些才是值得追的。所以 MAT 分析时不要迷信类名,要结合引用来确认。

5. 修复与验证:代码、参数、监控三管齐下

5.1 修复数据缓存逻辑

根因确认后,修复本身不复杂。TradeCache的刷新线程原本只做put,我改成在每次刷新完成后清理掉超过 7 天的旧 key:

// 修复前:只写不清理 tradeCache.put(dateKey, snapshot); // 修复后:写入前按策略淘汰 if (tradeCache.size() > MAX_CACHE_SIZE) { tradeCache.entrySet().removeIf( entry -> isExpired(entry.getKey(), retentionDays)); } tradeCache.put(dateKey, snapshot);

另外把缓存的引用方式从"静态方法内直接持有"改成"弱引用容器 + 定时刷新"。这个改动有个副作用:跨作业频繁访问缓存的场景,弱引用可能提前被回收,导致缓存命中率下降。于是我在刷新线程里保留了强引用,但用一个更细粒度的 TTL 来淘汰。

这类代码上的小改动,逻辑上不复杂,但测试起来要特别小心。因为流任务里算子并发度可能很高,如果淘汰逻辑不等于"数据正确性逻辑",就可能导致两个并行子任务读到不一致的缓存快照。我特意在测试环境用 16 并行度压了半天数据,确认缓存行为没有引入脏读。

5.2 配置层面的调整:TaskManager内存参数

代码修完后,我把 TaskManager 内存参数也顺手重新分配了一遍。原先的总内存给得很大,但堆内任务内存和托管内存之间的比例不合理。调整思路是:

  • 适当缩小task.off-heap.size,因为该任务的序列化主要走堆内。
  • 把managed.size控制在一个合理上限,限制 RocksDB 的堆外缓存不要继续无界膨胀。
  • 保留足够的jvm-overhead.fraction,给可能的线程栈和 Direct 内存留余量。

最终使用的关键参数(前提是物理容器内存充足):

taskmanager.memory.process.size: 4096m taskmanager.memory.task.heap.size: 2048m taskmanager.memory.managed.size: 1024m taskmanager.memory.task.off-heap.size: 384m taskmanager.memory.jvm-overhead.fraction: 0.2

这里有个经验:process.size是上限,不是实际需求,千万不要只看这个值很大就认为万事大吉。真正决定变的还是运行时使用方法。我见过很多任务把process.size调得很大,但因为内存泄漏,最终还是被 YARN 杀。

5.3 验证结果与监控体系的完善

修复后重启任务,连续运行了 72 小时。我用 jstat 定期采集 GC 数据,老年代使用率基本稳定在 45% 上下,不再持续爬坡。Full GC 只在极端数据潮汐下出现过两次,耗时也降到 1 秒以内。

为了下次能更快定位同类问题,我在监控体系里补了以下指标:

  • TaskManager 的进程物理内存总占用(VmRSS)
  • Flink 自带的内存指标(Managed Memory、Network Memory)
  • 老年代使用率和 Full GC 耗时
  • 定时对关键算子做堆直方图采样

这套监控在第二次 OOM 模拟里发挥了作用,因为指标面板能直接看到某个算子 state 增长曲线,不再需要靠猜来判断 OOM 方向。

最后再分享一个我个人的操作习惯:线上问题恢复并验证稳定后,我会把 dump 文件和 jstat 采样数据保留一份,写成一个只有一两页的复盘记录,重点写清"当时哪些指标触发了怀疑""哪个工具链条一步步引导了结论"。下次再遇到类似的 Flink OOM,翻一下记录能省去大量从零探查的时间。内存问题的排查,本质上就是靠一套可靠的工具链条,把"看起来玄学"的故障还原成"可以定位和修复"的工程问题,稍微多花一点时间把工具链用熟,完全不亏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询