
又双叒叕处理OOM了。这周排查了两个Spark任务的OOM问题顺手把线上环境里各种OutOfMemoryError的定位思路重新梳理了一遍。说实话OOM这东西本身不复杂复杂的是它出现的场景千奇百怪——堆内、堆外、元空间、直接内存每种情况的表现和排查手段都不一样。如果你也被线上OOM折腾过或者想知道遇到这类问题从哪下手这篇报告应该能帮你省不少时间。1. 一文吃透OOM的本质与分类OOM全称OutOfMemoryError是JVM在内存不足时抛出的错误。注意它是个Error不是Exception这意味着多数情况下程序已经无法正常继续执行了。但很多同学有个误区觉得只要是OOM就一定是某个对象太多把堆撑爆了其实远没有这么简单。从JVM的内存模型来看OOM可以发生在不同的内存区域每个区域的报错信息也完全不一样。最常见的两类是java.lang.OutOfMemoryError: Java heap space和java.lang.OutOfMemoryError: GC overhead limit exceeded。前者说明Java堆内存不够后者说明GC回收效率极低虚拟机快被垃圾回收拖垮了。还有Metaspace相关的OOM多发生在动态生成类或者大量使用反射的场景再有就是Direct buffer memory这是堆外内存溢出的典型报错常和Netty、NIO有关特别坑因为这类内存不归JVM堆管堆内存看着还有余量程序却已经崩了。区分OOM发生在哪个区域是第一件必须搞清楚的事。就像排查一个水管漏水问题你总得先确定是进水管漏还是出水管漏才能对症下药。经验不足的同学一看到OutOfMemoryError就直接调-Xmx这么干有时候能碰巧把问题解决但更多时候只是把症状往后拖了几天根本的泄漏或者配置问题没解决过阵子照样炸。我建议每个开发同学都花点时间把JVM的内存模型吃透。堆内存区域有新生代、老年代、幸存区这些划分每个区域的溢出表现各不相同。新生代溢出通常表现为频繁的Minor GC之后仍然无法为新对象分配内存老年代溢出则往往是对象存活时间异常或者大对象太多。这些差异本身就是定位问题的关键线索。另外栈溢出StackOverflowError虽说名字不叫OOM但也属于内存资源耗尽多因递归调用过深导致这个比较直接看到无限递归基本就能猜个八九不离十。提示处理OOM问题的第一原则不是急着改参数而是先从报错信息确定内存区域再结合GC日志判断是临时性内存峰值还是持续性内存泄漏。方向错了后面做的全是无用功。2. Spark任务OOM的典型场景与根因剖析2.1 Executor端OOM为何最常见在Spark集群模式下OOM高发场景集中在Executor端。为什么因为Executor的-Xmx通常按照内存总数乘个比例去配置比如常见的spark.executor.memory4g但这4个G并不是全都给RDD存储和任务执行用的。Spark会在Executor内存里划分出执行内存Execution Memory和存储内存Storage Memory两大块两者之间还有一个可动态调整的交界区。如果某次Shuffle的数据量远超预估或者拉取数据时一次性放入内存的Block过大就会直接打穿Executormemory的可用上限。在执行阶段比如一次sortByKey或者reduceByKey操作如果某个Key的数据量极度不均衡对应的Reduce任务需要在内存中缓存大量数据这就是我们常说的数据倾斜引发的OOM。这种情况在线上的实际处理中非常常见。之前遇到过单Key数据量过亿的情况所有数据都hash到同一个分区那个分区的任务直接OOM其他分区闲置得很整个Stage就卡死了。2.2 Driver端的OOM也值得关注很多人盯着Executor却忽略了Driver端。Driver的内存主要负责持有任务运行态的元数据、DAG图、以及collect()拉回的Result数据。线上出过这样的问题某个任务用collect()把全量结果拉回Driver做后续处理数据量稍微一涨Driver直接Java heap space。这本质上是代码用错了API分布式计算的结果不应当在Driver端汇总除非数据量确实可控且很小。另一个容易被忽视的点是广播变量。如果在Driver端创建了一个很大的广播变量比如把几十G的配置数据广播到所有Executor每个Executor都要在内存里保存一份副本。Executor数量一多Driver端序列化广播数据时的内存开销相当可观很容易把Driver搞挂。实际工作中广播变量的大小应当严格控制不仅是网络I/O问题它直接关系到两端的内存水位。2.3 堆外内存与元空间的坑Spark中引入堆外内存Off-heap通常是为了提升性能比如spark.memory.offHeap.enabledtrue配合spark.memory.offHeap.size来使用。堆外内存不经过JVM GC管理由操作系统直接分配所以-Xmx管不住它。如果你在配置里开了堆外内存但没有合理地估算分配上限Netty通讯框架会一直在堆外分配用于网络收发的DirectByteBuffer最终触发OutOfMemoryError: Direct buffer memory。这个报错最大的迷惑性在于jmap看堆内存时一切正常甚至GC都完全健康。这时候你得换思路去看操作系统的内存使用排行或者用pmap分析进程的地址空间分布结合JVM的NMTNative Memory Tracking去定位是谁在耗堆外内存。之前我调一个Spark Streaming任务时遇到过类似情况跑个两三天必挂一次最后查到是堆外内存配置得过大加上Netty缓冲区反复分配释放把系统内存挤爆了。2.4 DAG图过大与任务无限制提交还有一种比较隐蔽的OOM场景是DAG图无限膨胀。当你在循环中不断创建新的RDD或者DataFrame且上一个RDD没有被正确unpersistSpark的DAG血缘就越积越长。每个RDD都持有依赖信息驱动端的内存被这些元数据消耗殆尽。这种问题在流式任务里尤其突出因为流式计算天然是一个无限循环如果不注意每次批次处理完后清理状态信息跑上几天后Driver端内存报表必然异常。针对这类情况日常监控必须盯紧Driver内存的随时间变化曲线一旦发现趋势型增长就要警觉是否内存泄漏。不要等任务真的OOM了才开始日志排查。3. 线上OOM快速定位的实战方法论线上服务出现OOM最怕的就是手忙脚乱乱查一通。我总结了一套定位思路尽量用最短路径锁定根因力求可操作、可复现。3.1 第一步拿到现场证据OOM发生之后的第一动作是什么先保住现场。如果是Java服务确保JVM启动参数里带了-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs这样OOM触发的那一刻JVM会把堆快照自动落盘。这个参数应该在所有Java服务里默认打开不用等到出了事再后悔。有了heap dump文件后面用什么工具分析我这边用得最多的是Eclipse MAT它有自动泄漏检测功能能快速指出哪些对象占用了大块内存。另一个备选是JProfiler功能全但偏重MAT做快照分析更轻快。如果Dump文件太大MAT打不开或打开后卡死可以先用jhat做基础分析虽然交互比较原始但起码能看到对象的基本信息。更大文件可以考虑用VisualVM读取。拿到Dump文件后怎么快速看重点看Dominator Tree支配树它告诉你哪个对象是真正的“内存大户”。然后右键查看GC Roots引用链沿着引用链一路往回看就能定位到是哪个业务代码把对象一直持有没被回收。曾经定位过一个案例一个静态Map里存了所有用户的查询条件本意是做本地缓存但没设过期清理策略查询一次塞一份几个月后直接把Heap撑到5个G。3.2 第二步结合GC日志逆推时间线Heap Dump能告诉你哪块内存被什么对象占用了但有时候还需要知道OOM前GC是怎么演变的。这就得靠GC日志了。启动参数里建议加上-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsJDK8及之前的版本用这些参数JDK11及以后推荐直接使用-Xlog:gc*。拿到GC日志后重点关注老年代的使用率曲线。如果老年代在OOM前一路缓慢上涨GC频率越来越高但每次回收量很少这是典型的内存泄漏特征。如果老年代使用率一直很平稳G1还能维持正常的Mixed GC突然某一次GC后分配一个大对象失败导致OOM那更可能是短时间内存峰值。两种情况的应对策略完全不一样前者要去代码里找泄漏点后者要去查是否有超大对象、或者某次请求数据量异常大。3.3 第三步jmap与jstack的动态取证在OOM发生的瞬间或者服务还处于半死不活的状态时jmap和jstack是野外求生利器。jmap-histo命令可以快速打印堆中对象的统计直方图不需要完整Dump就能看到哪个类的实例数量异常。比如曾经定位过一个字符串拼接导致的OOM-histo里排在第一位的是char[]和String立刻锁定方向。jstack则用来查看所有线程的堆栈。OOM有时会伴随线程卡死或者无限循环分配对象jstack能告诉你当前各线程在做什么。如果你发现某个线程栈反复出现在同一个方法调用上且这个方法里有集合添加操作基本可以判定这里就是内存泄漏源头。顺带说说这两条命令都有进程PID参数在容器环境里要特别注意拿到的是JVM进程本身的PID别拿成Sidecar进程的。3.4 线上直接搞Dump的顾虑与处理方案有人会问线上服务比较敏感直接jmap Dump会不会影响性能这个问题问得很好。确实对一个大堆的在线服务执行jmap -dump会造成STW停顿停顿时间可能达到数秒甚至数十秒。所以线上Dump要挑业务低峰期操作。如果服务已经OOM崩溃那就无所谓了JVM自动落盘的Dump不影响任何人。如果是堆内存特别大比如几十个GDump文件会非常大下载和分析都费劲。一个折中方案是用jmap -dump:live只导出存活对象配合JVM参数-XX:HeapDumpAfterFullGC可以拿到Full GC之后的内存快照。这种快照更有利于排查泄漏点因为临时对象都被清理掉了留下的都是真正被持有的对象。提示保障线上容器的JVM启动参数里必须有HeapDumpOnOutOfMemoryError参数否则一旦OOM事发现场就没了后面的排查难度翻倍。4. mrds63与mrds65两次OOM的完整复盘4.1 mrds63任务OOM从GC日志读出端倪这次出问题的任务是mrds63凌晨两点左右收到监控告警任务失败。一开始大家以为是偶发网络原因导致Executor漂移重跑一遍可能就能过结果连续重试两次全部失败基本排除偶发因素确定是任务本身的问题。第一步动作是去集群的Executor日志目录把所有失败Executor的stdout、stderr全部拉到本地。错误信息明确显示java.lang.OutOfMemoryError: GC overhead limit exceeded。这个报错说明频繁GC每次GC回收不了多少内存JVM已经处于一种“垃圾回收也快转不动”的状态。拿其中几个Executor的GC日志做时间线分析发现老年代从任务启动后持续缓慢攀升大约每10分钟升5%左右而且每次Full GC后老年代只是小幅回落然后再接着涨。看到这种走势我心里基本有数了问题出在某个算子长期持有了大量数据。用MAT打开那个Executor的Dump文件Dominator Tree里显示了一个巨大的scala.collection.mutable.HashMap对象怀疑是某个map类型变量把大量中间结果全load进来了。查源码定位到一段foreachPartition里的逻辑处理一个分区数据后把结果塞到一个外层定义的HashMap里本意是做后续关联查询用结果这个HashMap被多个分区任务共享积累了全量数据。Executor的内存怎么够用修复方案其实很简单把这个HashMap移入到foreachPartition内部确保每个分区的处理逻辑是局部变量处理完即释放或者改用累加器/广播变量的方式去实现跨分区共享数据。改完再跑一遍老年代曲线变得非常平稳任务顺利跑完。4.2 mrds65任务OOM一次数据倾斜的经典案例mrds65的OOM报错信息是Java heap space时间点集中在Shuffle后的reduce阶段。看到这个报错和阶段特征第一反应就是数据倾斜。为了确认我特意去数了一下Shuffle后各个分区的大小分布发现一个分区里落了几亿条记录而其他分区才几百条属于典型的Key哈希分布极度不均匀。这种倾斜如果不加干预任务重试多少次都没用因为每次Shuffle都会把同一批数据哈希到同一个分区。解决的思路无非两种第一种是加随机前缀打散大Key比如给原有的Key加上一个随机数再分桶将一个大Key拆成多个小Key并行处理等做完聚合运算再去掉前缀另一种是调整分区策略让倾斜的Key单独分到另一个分区。这里我们选择了加随机前缀的方案具体做法如下// 在原Key前加随机后缀把过大的Key拆成多份 val saltedRdd skewedRdd.map { record val key record._1 val salt Random.nextInt(100) (key _ salt, record._2) } // 执行聚合操作 val aggregatedRdd saltedRdd.reduceByKey(_ _) // 去除随机后缀做最终聚合 val finalRdd aggregatedRdd.map { record val originalKey record._1.split(_)(0) (originalKey, record._2) }.reduceByKey(_ _)这里Random.nextInt(100)意味着把一个大Key拆成最多100份每个份儿的数据量只有原来的1%倾斜问题基本被抹平。改动之后任务运行时间从原来的40多分钟降到15分钟以内稳定性也有了明显提升。4.3 两个案例的共同特征和处理启发回看这两个案例其实有很大的共性都是内存被持有太长时间、没有被及时释放这种情况下依赖调大-Xmx往往只能解燃眉之急治标不治本。代码里的数据结构设计、作用域规划才是决定内存表现的根本因素。别以为改了代码就万事大吉。mrds63修复完之后我还顺手把Spark任务的监控补全了针对Executor内存、GC频率、Driver内存单独配置了监控面板和告警策略确保下一次内存异常能提前被感知而不是等任务失败了才后知后觉。这个习惯我现在强烈推荐给所有做数据开发的团队。5. OOM问题排查避坑指南与速查表排查OOM的过程中常遇到一些坑这里整理成清单每一条都是真金白银换来的教训5.1 误区一看到OOM就调大堆内存最典型的错误操作。直接把-Xmx从4G改成8G有时候任务确实不报OOM了但堆内存增大后YGC和FGC的停顿时间也会相应变长GC停顿对实时性要求高的服务是致命的。再者根因没解决内存继续泄漏过一个月堆照样被撑爆到时候处理难度更大。5.2 误区二只分析Heap Dump忽略GC日志和系统日志Heap Dump能告诉你“谁占了内存”但很难告诉你“为什么占”。GC日志记录了内存分配和回收的历史轨迹系统日志可能记录了触发某段业务逻辑的前置信息三者结合才能还原完整的因果链。5.3 误区三忽略堆外内存和线程栈占用堆外内存OOM在jstat和jmap里看不出异常。此类问题需要通过NMT-XX:NativeMemoryTrackingsummary或者操作系统层面top、pmap去定位。线程数量异常膨胀也会导致无法创建本地线程从而OOM这种问题看线程栈最直接。5.4 常见OOM问题定位速查表下面这个表是我自己在实际工作中总结出来的整理到这里遇到问题可以直接对照着用报错信息可能原因优先排查方向常用工具Java heap space堆内存不足是否存在内存泄漏、超大对象、数据集过大jmap -histo、MAT、GC日志GC overhead limit exceededGC回收效率低下老年代持续增长、Finalizer堆积GC日志分析、jstat -gcutilMetaspace元空间不足动态生成类过多、反射调用无缓存jstat -gcmetacapacity、JVM参数Direct buffer memory堆外内存不足NIO、Netty分配过多DirectByteBufferNMT、pmap、操作系统内存监控unable to create new native thread线程数达上限线程池无限创建、操作系统限制jstack、系统ulimitRequested array size exceeds VM limit试图创建超大数组单条记录数据量异常巨大Heap Dump分析6. 关于预防OOM的几点经验处理了这么多OOM问题我越发觉得预防的价值远大于兜底。一个稳定的线上任务应当在代码评审阶段就把内存风险考虑进去。比如不用大List收集全量数据再处理优先考虑流式处理或分页处理不用大对象频繁触发GC优先考虑对象复用不把跨任务的共享数据存放在executor的静态变量里优先考虑外部存储。再就是全链路监控。监控不是等出了事再去翻日志而是要把任务运行的每个环节都量化出来Executor内存使用率、GC时间占比、Shuffle读写的磁盘与内存比例、Driver内存使用曲线、连接池活跃数等。建议至少在监控面板上覆盖这些指标哪怕先不看告警出了事能调出历史趋势图对定位的助力是非常大的。还有一个容易被忽视的维度是依赖组件的版本。不同的Spark版本对内存管理的策略差异非常大比如Spark 1.x时代的内存管理是静态分区到了Spark 2.x引入了统一内存管理器Spark 3.x又加入了很多自适应执行优化。同一个任务在不同版本上的内存表现可能完全不同升级版本前一定要做充分的回归测试。我自己经历过的最复杂的一次OOM排查最终定位到是一个外部SDK在底层用了ThreadLocal存储上下文在高并发下ThreadLocal里的Map不断膨胀但代码里没有做remove清理。这种隐藏在第三方SDK里的内存泄漏如果不动用MAT顺着GC Roots一层层扒引用链完全找不到头绪。这也从侧面说明遇到OOM别怕跟着工具和方法论一步步来再隐蔽的问题也会暴露。7. 最后分享一个小工具使用技巧最后分享一个排查OOM时特别实用的MAT使用技巧。很多同学打开Dump文件后会被动辄数万行的对象列表吓到不知道从哪里入手。我的习惯是直接跑一遍MAT自带的“Leak Suspects Report”它会自动帮你圈定可疑的泄漏对象并给出简要的分析说明。这个报告有一定误报率但作为切入点效率非常高能帮你快速缩小排查范围。其次拿到可疑对象后一定要看它的GC Roots最短路径。这是确认对象是否真的被根对象持有的关键操作。如果一条对象到GC Roots的最短路径上有一个自定义的业务类那基本就锁定嫌疑人了接下来只要检查这个业务类的生命周期管理是否合理就行。这招帮我省了无数时间希望也能帮到你。OOM这块只要思路清晰、工具熟练基本没有解不了的问题。整个思路就是先确认内存区域再结合日志判类型最后用Dump定根因按这个顺序来稳得很。