2026/10/12 3:58:37

JVM 堆一切正常,进程却被 OOM Killer 杀了:一次 Netty 直接内存泄漏的完整复盘

JVM 堆一切正常,进程却被 OOM Killer 杀了:一次 Netty 直接内存泄漏的完整复盘 JVM 堆一切正常进程却被 OOM Killer 杀了一次 Netty 直接内存泄漏的完整复盘摘要线上某战斗服实例在 10 分钟内进程内存从 10G 飙到 15G 后突然消失。诡异的是整个过程中 JVM 堆内存曲线风平浪静GC 日志毫无异常进程死后也没有留下任何 Heap Dump。本文完整复盘这次 Netty 直接内存Direct Memory泄漏事故从堆稳定但进程内存暴涨的矛盾切入用排除法 操作系统日志锁定直接内存回溯业务日志定位到一个恒为 0 的客户端确认帧最终讲清 Netty 堆外内存只分配、不回收的底层机制以及熔断、分批、监控三层修复方案。适合读者使用 Netty / NIO / 帧同步的 Java 服务端开发以及所有堆监控正常却离奇 OOM问题的排查者。一、事故概要项目内容事故现象单个战斗服实例进程内存 10 分钟内从 10G 涨至 15G随后进程直接消失影响范围单实例该实例上的战斗房间全部掉线其余 20 个同版本实例正常JVM 表现堆内存Young/Old使用率全程正常无 Full GC 异常无 Heap Dump 生成进程死因Linux OOM Killer 强杀SIGKILL根本原因客户端异常触发大跨度追帧逻辑产生超大网络报文Netty 直接内存分配速率远超释放速率耗尽机器物理内存一句话概括这次事故JVM 的账本上一切正常内核的账本已经爆了。二、背景出问题的系统长什么样事故来自一套帧同步玩法的战斗服务Battle Server架构要点多实例部署20 多个 Battle 实例并行跑每个实例负责各自房间的战斗逻辑实例之间完全隔离——这也解释了为什么事故只影响单实例帧同步模式服务器按固定频率推进逻辑帧并广播给房间内客户端客户端上报确认帧号表示我收到了第 N 帧服务器将 N 之后的帧重发追帧以此对抗丢包网络层基于 Netty报文使用 ProtoBuf 序列化——这两个信息在后面是关键线索JVM 配置堆内存-Xmx12g-XX:MaxMetaspaceSize256m没有设置-XX:MaxDirectMemorySize。事故发生在一次停服维护后稳定运行的第 4 天。稳定运行了 4 天、只挂了 1 台、无法复现——这三个词组合在一起基本可以排除新版本引入的常规 Bug更像是某个特定条件触发的路径。这也是后文定位思路的起点。三、时间线10 分钟的生死时速时间进程总内存RSSJVM 堆备注16:20~10G开始异常上涨正常监控告警起点16:25持续飙升正常CPU 同时出现尖刺16:2915G逼近机器物理内存上限正常16:30进程从监控中消失—被 OOM Killer 强杀注意两个反差进程总内存RSS暴涨堆内存曲线纹丝不动——说明涨的部分不在堆里CPU 同时有尖刺——说明这个时间点存在很耗性能的计算后面会呼应到超大报文的序列化与内存拷贝。还有一个对排查非常不利的现实进程是被SIGKILL强杀的JVM 没有任何机会执行-XX:HeapDumpOnOutOfMemoryError之类的善后动作案发现场没有留下 Heap Dump也无法事后 jstack。能依靠的只有三样东西监控曲线、操作系统日志、应用日志。四、排查堆没涨那内存去哪了4.1 第一层推理堆外内存的四个嫌疑犯“进程内存涨、JVM 堆不涨”问题必然出在堆外Off-Heap。一个 JVM 进程的内存构成里堆以外的大头有四个嫌疑犯嫌疑对象说明常见肇因Metaspace类元数据动态类生成CGLIB、Groovy、反射代理失控Direct Memory直接内存ByteBuffer.allocateDirect/ Netty 的 IO 缓冲区大报文、泄漏、释放不及时线程栈每线程默认 1M线程数暴涨未收敛的线程池Native / JNI框架 native 库、压缩指针区、CodeCacheGZIP、加密库、native 泄漏4.2 排除法收网排除 Metaspace启动参数已显式限制-XX:MaxMetaspaceSize256m物理上最多 256M与本次 ~5G 的增量量级完全不符排除线程栈监控中线程数平稳没有创建线程的风暴锁定直接内存两个强线索指向它——服务大量使用 Netty而 Netty 的网络读写缓冲区默认走堆外直接内存这也是它高性能的原因之一JVM 参数没有设置-XX:MaxDirectMemorySize。很多人不知道这个参数不设置时默认上限等于-Xmx本例即 12G。也就是说直接内存有一个 12G 的合法透支额度泄漏到几个 G 不会触发任何 JVM 层面的报错。4.3 决定性证据操作系统日志/var/log/messages是这类进程离奇消失问题的金矿。案发时间点正好有这样两条Out of memory: Kill process 24555 (java) score 866 or sacrifice child Killed process 24555 (java) total-vm:27684912kB, anon-rss:14911728kB, ...解读这条日志Out of memory: Kill process—— 内核的 OOM Killer 机制在机器物理内存 swap耗尽时出手选择oom_score最高的进程杀掉。Java 进程吃了十几个 G毫无悬念是头号目标anon-rss:14911728kB ≈ 14.9G—— 该进程实际占用的物理内存。现在可以算账了进程 RSS ≈ 14.9 G - 堆内存上限 - 12 G 堆曲线正常按最大值估 - Metaspace - 0.25 G 有显式上限 - 线程栈/CodeCache 等 - 0.5 G 量级估算 ≈ 去向不明 ≥ 2~3 G 堆按上限乐观估实际缺口只多不少这 2~3 G 去向不明的内存就是泄漏堆积的直接内存。机器总内存被堆 直接内存联手吃光内核先于 JVM 崩了——这也解释了为什么 JVM 自身从头到尾没报任何OutOfMemoryError它离自己的崩溃线还远着呢。复盘时很多人问为什么OutOfMemoryError: Direct buffer memory没有抛出来因为 JVM 对直接内存的记账上限默认是 Xmx12G堆外才涨到 3G离触发 JVM 层面的 OOM 还早但机器物理内存已经先被吃光了。JVM 觉得自己还好好的内核已经掀桌子了。4.4 为什么只有一台出问题20 多个实例跑着完全相同的代码版本、相同的配置只有一台挂——说明触发条件不在代码版本或常规负载上而在某个实例上特定的运行时状态某个房间、某个客户端。这把排查方向推向了应用日志回溯。五、根因一个恒为 0 的确认帧如何吃掉 3G 内存5.1 帧同步的确认帧机制先补一下背景知识帧同步服务端的同学可以跳过Server ──帧1、帧2、帧3……帧3000──▶ Client 服务器持续推进并广播逻辑帧 Client ──确认帧号 N我收到第N帧──▶ Server 客户端定期上报收帧进度 Server发现 N 之后的部分可能丢包了 → 重发 N1 之后的帧追帧正常情况下弱网玩家的确认帧号落后几十帧追帧重发几十帧的数据几 KB 到几十 KB毫无压力。5.2 日志回溯异常客户端回溯故障实例的应用日志在案发前发现有一个玩家的确认帧号日志字段此处为示意下称 lastAckedFrame恒为 0而服务器已推进到 3000 帧且该客户端在以每秒一次的频率持续发送帧确认请求协议名此处为示意。也就是说服务器认为这个玩家一帧都没收到于是每次收到确认请求都把3000 帧的数据全部重发一遍每秒一次。5.3 代码缺陷无上限的追帧打包审查追帧逻辑示意代码// 事故版本示意把客户端已确认帧到服务器当前帧之间的数据全部打包重发privatevoidresendMissingFrames(Playerplayer){intcurFrameroom.currentFrame();// 服务器已推进到 3000intackFrameplayer.lastAckedFrame();// 客户端上报 0ListFramebatchnewArrayList();for(intiackFrame;icurFrame;i){batch.add(frameHistory.get(i));// 3000 帧全部装入}FrameSyncPacketpacketFrameSyncPacket.of(batch);// 序列化成超大 ProtoBuf 报文player.channel().writeAndFlush(packet);// 每秒一次}问题很清楚这段逻辑没有任何自我保护。帧差 30 帧时它工作得很好帧差 3000 帧时它忠实地每次打包 3000 帧。单帧按 1KB 算就是约 3MB 的报文每秒一次帧数据更大时轻松突破 Netty 池化内存的单块上限默认 chunk 16MB每次都走全新分配的路径完全无法复用。5.4 关键机制Netty 直接内存为什么只还不收写网络报文时Netty 会把数据放进堆外的DirectByteBuffer避免堆内 buffer → 内核缓冲区的一次拷贝这是它高性能的来源。但堆外内存的回收规则和堆完全不同这正是本次事故内存只涨不降的机制根源堆内存 没人引用了 → GC 自动回收不用开发者操心 堆外内存 不归 GC 直接管走两条路径之一 ─┐ ├─ 路径AJDK Cleaner 机制 │ 分配时注册 Cleaner │ 等 GC 扫到 buffer 对象死亡才回收 │ → 堆越空闲、GC 越懒堆外越堆积 └─ 路径BNetty 引用计数release() 谁最后用完谁调 release() → 忘了调 永不回收且无异常本案例中两条路径同时失效堆没有压力业务数据都在堆外流过堆一直很空闲GC 几乎不跑。依赖 Cleaner 回收的直接内存无人清理——不是不回收是排队等一个不会来的 GC分配速率远超释放速率每秒一个数 MB 级的大 buffer加上大报文超出池化块上限后每次全新分配分配端在油门到底回收端在怠速没有任何熔断未设置MaxDirectMemorySize时JVM 层面默认额度是 12GNetty 读到这个默认值作为自己的堆外上限3G 的占用离报警线还远。于是机器物理内存先被榨干OOM Killer 出手。CPU 尖刺也呼应上了每秒一次的 3000 帧序列化 大内存拷贝本身就是 CPU 密集操作。六、修复熔断、根治、监控三层防御6.1 紧急措施给直接内存装空气开关所有 Battle 实例 JVM 参数强制添加-XX:MaxDirectMemorySize2g这里有个值得展开的细节这个参数对 Netty同样有效。Netty 启动时会反射读取 JVM 的MaxDirectMemorySize作为自己的堆外内存总限额超过限额时Netty 会在分配处抛出OutOfDirectMemoryError。故障从进程被内核无声强杀变成分配点抛明确异常 留下现场可排查性和影响范围都完全不同。取值原则正常业务直接内存峰值的 2~3 倍。太小会误伤正常 IO太大起不到熔断作用。本例战斗服正常直接内存用量远低于 1G2G 留足余量。6.2 根治追帧加上限 分批同步// 修复版本示意单次最多补发 100 帧追不上就分批追privatestaticfinalintMAX_FRAMES_PER_SYNC100;privatevoidresendMissingFrames(Playerplayer){intcurFrameroom.currentFrame();intackFrameplayer.lastAckedFrame();intendFrameMath.min(curFrame,ackFrameMAX_FRAMES_PER_SYNC);ListFramebatchnewArrayList();for(intiackFrame;iendFrame;i){batch.add(frameHistory.get(i));}player.setLastAckedFrame(endFrame);// 推进补发位点下次从这里继续追player.channel().writeAndFlush(FrameSyncPacket.of(batch));}配套两个防御逻辑追帧跨度告警帧差超过阈值如 500 帧打 WARN 日志——本次事故如果有这条日志16:20 之前就能看到苗头落后即重连客户端持续落后说明已经追不上了时直接通知重连、整场重新同步而不是无限追帧。追帧是给短暂丢包兜底的不是给客户端已经挂了续命的。顺带一提这也是一条通用的设计原则任何把一批数据全部打包的逻辑同步、导出、批量推送都必须有单次上限。今天上限保护的是内存明天可能是接口超时、下游限流——本质相同。6.3 监控补盲别只盯着堆本次事故的系统性根因是监控盲区只监控 JVM 堆永远看不到这类问题。补三层① 进程 RSS 与堆的差值告警最简单普适性最强堆外粗估值 进程 RSS - 堆 Used - Metaspace Used差值持续增长即告警。不需要精确需要的是能看见趋势。② Netty 自身的分配器指标JMX 的java.nio:typeBufferPool,namedirect只统计走 JDK 记账的那部分直接内存Netty 默认的池化分配器部分绕过这套记账。要看全量需暴露 Netty 的 allocator metricsPooledByteBufAllocator.DEFAULT.metric()的usedDirectMemory到监控系统。③ 泄漏检测开关排查期神器-Dio.netty.leakDetection.leveladvanced # 生产可长期开采样检测并打印分配栈 -Dio.netty.leakDetection.levelparanoid # 排查期临时开全量检测有性能代价开启后如果存在 buffer 未 release日志里会出现LEAK: ByteBuf.release() was not called...并附分配时的调用栈直接指认肇事代码。七、沉淀一份可复用的排查手册这次复盘用到的手段整理成进程内存暴涨/离奇消失通用排查卡手段命令 / 位置看什么OS 日志grep -E Out of memory|Killed process /var/log/messages或dmesg -T是否被 OOM Killer 杀、anon-rss多大、score 多少进程内存构成topRES/pmap -x pidRSS 与堆的差值定位堆外增量量级JVM 参数jcmd pid VM.flags是否缺失MaxDirectMemorySize、MaxMetaspaceSizeNMT能力边界要注意-XX:NativeMemoryTrackingsummaryjcmd pid VM.native_memory能看到 Unsafe 路径的直接内存JDK 8 计入 Internal 的 malloc新版归 Other但粒度只有总量、分不出 Netty 与其他来源——定量还得靠 Netty 自身指标Netty 侧leakDetection 日志、allocator metricsLEAK: ByteBuf.release()...与usedDirectMemory应用日志追帧/同步/导出类请求的跨度统计找单次数据量无上限的逻辑八、经验与教训监控要盯差值不要只盯账面。堆监控正常 ≠ 进程健康。进程 RSS 与堆、Metaspace 的差值是堆外泄漏最灵敏的先行指标。防御式编程一切打包全部都要设上限。本次事故的追帧逻辑在 99.99% 的请求里表现完美唯独特定的极端输入确认帧0把它变成了内存粉碎机。分页、分批、限流是对极端输入的标配防御。JVM 参数是代码的一部分。-XX:MaxDirectMemorySize这类参数没有默认安全值兜底默认值是 Xmx形同虚设必须显式、统一地配置并纳入启动模板管理。进程消失先查内核日志。没有 Heap Dump、没有错误日志的进程死亡第一反应应该是/var/log/messages/dmesg而不是怀疑 JVM。框架的高性能是有代价的。Netty 把零拷贝、堆外、引用计数这些利器交到你手上也就把释放的责任交给了你。用 Netty 而不了解它的内存模型等于开手动挡不看转速表。一句话总结堆内内存归 GC 管堆外内存归你管——当你把几十万个对象的生死交给 JVM、却把几个 G 的堆外缓冲区交给应该会有人释放吧的时候OOM Killer 就是那个准时上门的收债人。