上个月我这边值班,凌晨三点被告警电话叫起来,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 |
| E | Eden 区使用率 | 频繁从低位跳到高位,说明对象分配压力很大 |
| O | 老年代使用率 | 稳步爬升,迟迟不降,这是主要疑点 |
| M | 元空间使用率 | 相对稳定,没成为瓶颈 |
| FGC | Full 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 里根本找不到根因。我个人的排查顺序是:
- jstat 看 GC 趋势:先确认是老年代、Eden 还是 Metaspace 的紧张,也顺带确认 GC 耗时是不是已经异常。
- jmap -histo 看对象分布:快速圈出 Top 占内存的类,判断是业务对象还是框架对象。
- jmap -dump + MAT 看引用链:只有对象分布指向某个可疑类时,再做重操作。
- jvisualvm 或 MAT 远程分析:如果 dump 文件太大,直接拿到本地用 MAT 打开。
这套顺序的核心逻辑是从轻到重、从粗到细,每一步都在为下一步缩小范围,而不是一开始就把所有重量级手段全上。我见过有人直接对 20GB 堆执行 dump,搞了半天内存分析工具自己都被撑爆了,最后无从下手。
3. jvisualvm和MAT的组合拳:把内存泄漏的根挖出来
3.1 jvisualvm:轻量可视化,适合快速扫雷
jvisualvm 在 JDK 8 时代还是自带工具,JDK 9 以后被剥离,需要单独下载 VisualVM 发行版。它最实用的功能是对运行中的 Java 进程做抽样器采样,不需要 instrument 所有方法,性能开销相对可控。
使用方式:
- 在 TaskManager 所在节点启动,选择对应的 Java 进程
- 切到"Visual GC"页签,看各个分区的实时占用曲线
- 用"Sampler"里的内存采样,跑几十秒,导出采样结果
- 对可疑线程直接做 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 SuspectsLeak 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.fraction | JVM 开销 | 线程栈、编译缓存、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,翻一下记录能省去大量从零探查的时间。内存问题的排查,本质上就是靠一套可靠的工具链条,把"看起来玄学"的故障还原成"可以定位和修复"的工程问题,稍微多花一点时间把工具链用熟,完全不亏。