ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Netty堆外内存泄漏排查实录:RSS飙升8GB找回之路

Netty堆外内存泄漏排查实录:RSS飙升8GB找回之路 凌晨两点半监控突然刷屏容器内存超过 limitJava 进程被内核 OOM 杀掉。我爬起来第一反应是堆内存又出问题了但调出监控一看Old 区根本没满GC 也正常RSS 却硬生生比-Xmx高了几个 G。这种Heap 没事、进程内存却爆了的场景十有八九是堆外内存的账没算清。这篇就完整复盘一下我当时怎么从 Netty 堆外内存泄露入手顺着 DirectByteBuffer 一路查到 Linux 物理内存最后把丢失的 8GB 找回来的全过程。整个过程不玄乎就是一层层记账、一步步排除希望给同样被 RSS 困扰的人一些可复用的排查思路。1. 内存告警的诡异之处Heap 没事RSS 却先爆了那是一个典型的业务高峰前夜服务已经连续运行了大概一周。容器配置的内存上限是 16GBJVM 启动参数里-Xmx是 8GB正常情况下 RSS 应该稳定在 10GB 左右heap metaspace 线程栈 JIT 等杂项。但告警显示 RSS 已经到了 15.9GB并且持续向上顶没过多久就被 cgroup 的 OOM killer 处决了。重启之后服务立刻恢复正常RSS 回到 9GB 出头。但业务流量一上来曲线又开始缓慢爬坡大概四五天就会再次逼近上限。这个重启即恢复、缓慢持续增长、最终 OOM的节奏非常典型基本可以确定是某种资源没有被正确回收。我先跑了几个最常规的命令确认问题不在堆内# 看系统整体内存 free -h # 看 JVM 堆和 GC 情况 jstat -gcutil pid 1000free显示 used 持续偏高但jstat里 Eden、Old 区的使用率都很平稳FGC 次数很少-Xmx 8G的堆大概只用了不到 5GB。也就是说JVM 堆内完全没有压力。这种时候我建议大家先建立一个认知JVM 进程的 RSS 不只是 heap。除了堆还有 Metaspace、线程栈、JIT Code Cache、GC 相关的数据结构、DirectByteBuffer 以及各种 Native 内存。很多人一看到 RSS 高就给 JVM 加-Xmx这其实是南辕北辙。我当时就是先确认了堆内无事才把怀疑目标锁定到堆外。另一个值得注意的细节是容器内看到的真实内存压力来自/sys/fs/cgroup/memory/memory.usage_in_bytes而top里看到的%MEM和容器内 RSS 不一定一致。排障时尽量以容器指标、free和进程实际 RSS 三者对照。当时我顺手看了进程的线程数top -H -p pid发现线程数量正常没有线程疯狂创建导致的栈内存膨胀Metaspace 用jstat -gc也看不出异常。那剩下最可疑的就是 DirectByteBuffer 和 Netty 的内存池了。这里先埋个伏笔真正的坑并不在 Netty 分配内存本身而在于分配出去之后有一条引用链让它看起来被持有了这个问题一直拖到我打开 Netty 泄漏检测器才彻底暴露。2. DirectByteBuffer、Netty 内存池和 glibc 的三角债既然怀疑堆外内存就得先把堆外内存的分配链条讲清楚。很多资料把 DirectByteBuffer 说得像个黑盒其实它本质上就是 JVM 在 Java 堆外按字节数申请的一块原生内存Java 对象里只保存了这块内存的基地址和大小。底层调用链大概是ByteBuffer.allocateDirect() - DirectByteBuffer 构造 - Bits.reserveMemory() 做配额记账 - unsafe.allocateMemory() 真正向 glibc 申请内存注意unsafe.allocateMemory()最终调用的是 C 库的malloc并不是某种Java 专用内存。所以在 Linux 上观察进程地址空间时DirectByteBuffer 占用的内存和普通 C 程序malloc出来的内存没有任何本质区别都会被记录在 RSS 里。这也是为什么后来我会借助 pmap 这类传统 C 程序排查工具。Netty 的内存分配又叠加了一层复杂性。它默认使用PooledByteBufAllocator不会为每个 ByteBuf 都直接向操作系统申请内存而是预先从 DirectByteBuffer 里申请一大块连续内存切成大小不同的内存块放进池子里复用。Netty 的内存池管理单元是PoolArena每个PoolArena下面挂着若干PoolChunk一个PoolChunk默认大小是 16MB 左右pageSize8192maxOrder11chunkSize pageSize maxOrder。这里就出现了一个很多人忽略的点Netty 池化回收的内存并没有真正还给操作系统。当一个 ByteBuf 被 release 后Netty 只是把它标记为空闲并放回池中物理内存仍然在进程手里。这是设计上为了性能做的取舍本身不是泄漏。但如果某个 ByteBuf 始终没有被 release那连池子都不会回收它它会一直占着物理内存直到整个进程结束。这类永远没人释放的编号是真正需要找回来的内存。再加上 glibc 的分配器本身也有缓存机制。malloc释放的内存未必立刻通过munmap还给内核尤其是小于mmap_threshold的块会留在 glibc 的 bins 里备用。于是同样的现象背后内存可能处于三种完全不同的状态状态本质表现是否算泄漏池化缓存Netty 池中空闲 chunk等待复用RSS 高但内存可复用否glibc 缓存malloc 释放后留在 glibc 里RSS 高后续 malloc 可复用否真泄漏ByteBuf 引用未释放对象不可达RSS 持续上涨不受控是一开始我并没有办法立刻区分这三种状态因为它们的表象都是 RSS 上涨。但持续缓慢上涨、重启恢复这个特征明显更偏向真泄漏。如果是池化缓存或 glibc 缓存通常涨到某个水位就该平稳而不是无休止地逼近 OOM。下面要做的就是用工具把这三笔账分别算清。3. 一步步缩小范围从 NMT 到 pmap 再到 malloc 审计排查内存问题一定要有记账思维。先把 JVM 内部的账记清楚再看操作系统层面的账两边如果对不上缺口就是我们要找的脏账。3.1 先给 JVM 打开记账本NMTJVM 自带的 Native Memory TrackingNMT可以统计 JVM 自身申请的原生内存分类虽然不是专门为 DirectByteBuffer 设计的但能帮我们快速把范围缩小到是 JVM 内部申请还是外部 Native 库申请。启用方式是在 JVM 启动参数里加-XX:NativeMemoryTrackingsummary这个参数最好在服务启动时就加上。如果服务已经在跑加不了那就只能等下一次重启了。我当时是观察到问题后立即操作重启并在新实例上打开了 NMT。等运行一段时间后执行jcmd pid VM.native_memory summary输出里会有一份分类统计重点关注Internal和Other。我那次看到的情况是Java Heap 稳定Class、Thread、Code Cache 等分类也稳定但Internal这一项持续增长。NMT 对 DirectByteBuffer 的直接内存有时候会计在Internal或Other里虽然分类并不绝对精确但至少能确认JVM 内部某个组件在持续申请原生内存。如果 NMT 看不出具体是哪一个组件可以把summary换成detail再跑一次会输出更细的调用点。不过 detail 模式开销更大线上长期开 detail 不划算我建议只在小流量验证阶段短暂开启。3.2 pmap 看地址空间的长相NMT 给出的是 JVM 的视角接下来我用 pmap 切到操作系统视角直接看进程地址空间里到底挂着哪些内存段。pmap -x pid | sort -k2 -n输出会列出所有地址段及其 RSS按大小排序。我注意到一个很扎眼的事实进程里有大量大小一致的匿名内存段数量还在慢慢变多。这些匿名段很像是 Netty 池化申请的 DirectBuffer 块——一个 PoolChunk 对应一块连续内存。为了进一步确认这些匿名段到底映射了多少物理内存我又去看了 smapsgrep -E ^(Size|Rss|Pss|Private_Clean|Private_Dirty) /proc/pid/smaps | awk NR%6{printf %s , $0; next} {print }通过 smaps 里的 Private_Dirty 可以判断哪些内存是进程真正独占且写过的。那时我看到的 Private_Dirty 总量远超预期进一步坐实了 DirectBuffer 相关的物理内存占用量居高不下。但 pmap 和 smaps 只能说明有很多匿名内存还不能区分哪些是池化缓存、哪些是真泄漏。这里需要引入一个更关键的视角从分配器内部去审计。3.3 给 malloc 做审计看已释放但未归还的规模在 Linux 上如果想看清 glibc malloc 到底滞留了多少内存最直接的办法是把默认分配器换成可观测的分配器比如 jemalloc 或 tcmalloc。这类分配器通常都提供统计接口能告诉你allocated业务实际在用的内存和mapped从操作系统拿到的内存之间的差额。如果服务用的是 glibc malloc也可以通过 gdb 调用malloc_info来输出统计但生产环境用 gdb 挂 Java 进程风险太大一般不建议。我当时是在测试环境用 jemalloc 预加载复现的MALLOC_CONFbackground_thread:true,stats_print:true LD_PRELOAD/usr/lib64/libjemalloc.so.2 java -jar app.jar在stats_print输出里重点看两个指标allocated和mapped。测试环境复现到同样量级后mapped比allocated高了非常多说明有大量内存虽然被释放过但分配器没有还给操作系统。这解释了为什么堆外内存明明释放了RSS 却还在涨——其中一部分确实是被 glibc 或 jemalloc 缓存住了。但请注意这并不能解释全部。因为如果是单纯的分配器缓存曲线会趋于平稳不会无限增长。我继续观察了一段时间发现allocated本身也在同步增长这就意味着应用侧确实存在没有归还给池子的真实占用。于是这把真泄漏的嫌疑推到了最高。3.4 Netty 泄漏检测器给出关键线索事情到这里常规的内存记账方式已经用尽。下一步要回答的问题不再是内存在哪而是谁没有释放。Netty 自带的泄漏检测器就是干这个的。Netty 内部维护了一个ResourceLeakDetector默认级别是SIMPLE线上建议至少在SIMPLE或ADVANCED。如果怀疑有 ByteBuf 泄漏可以直接调到最高的PARANOID-Dio.netty.leakDetection.levelparanoid注意paranoid 会对每个 ByteBuf 进行采样跟踪性能影响比较明显不能在核心链路上长期开。我当时是挑了一个低峰期一台机器单独开 paranoid跑了不到半小时日志里就出现了梦寐以求的警告LEAK: ByteBuf.release() was not called before its garbage-collected. Recent access records: ...这行日志后面通常会跟着最近几次访问 ByteBuf 的调用栈记录。根据栈里的类名我很快锁定了问题出在某个自定义 ChannelHandler 内部一个业务侧保存了 ByteBuf 的引用但没有释放。这里多说一句Netty 泄漏检测只能告诉你有一个 ByteBuf 没释放但它本身不直接告诉你业务代码的完整逻辑所以还需要结合代码 Review 和线程栈一起判断。不过对于定位这类问题它能把你从大海捞针变成按图索骥。4. 真凶现身ChannelHandler 里被暂存的 ByteBuf泄漏检测日志指向的是一个很常见的 Netty 使用误区。先看一段简化后的反例代码这种写法在团队里非常普遍public class BizHandler extends ChannelInboundHandlerAdapter { Override public void channelRead(ChannelHandlerContext ctx, Object msg) { // 假设上面经过了 LengthFieldBasedFrameDecoder ByteBuf buf (ByteBuf) msg; // 业务处理比如解析出业务对象 DeferredMessage message new DeferredMessage(); message.setBody(buf); // 把 ByteBuf 存进业务对象 asyncWorker.submit(message); // 丢到异步线程队列 // 这里没有调用 buf.release() } }这个反例的问题在于channelRead收到的ByteBuf是从解码器传下来的引用计数默认是 1。处理好之后要么调用ReferenceCountUtil.release(msg)释放要么明确转移所有权。上面这段代码把ByteBuf存在DeferredMessage里丢给异步线程本意可能是让异步线程慢慢处理但如果没有对应的释放逻辑这个 ByteBuf 的引用计数就永远停在 1永远无法回到 Netty 的池子里。更隐蔽的是异步线程消费完这条消息后可能只取走了里面的业务字段完全不知道ByteBuf需要手动释放。于是每条消息都泄漏一块 DirectBuffer。按照当时的消息量单条消息体平均 64KB只需要累积十几万条未释放的消息就能白白吃掉 8GB 左右的内存。看清楚这个数量级之后8GB 是怎么丢的就完全不意外了。为什么会犯这种错因为 Netty 的引用计数模型和普通 Java 对象生命周期是错位的。普通 Java 对象只要没有 GC Roots 可达就会被自动回收但ByteBuf的底层 DirectMemory 回收依赖引用计数归零计数不归零就不会回到池子也不会触发底层 free。很多刚从传统 BIO / Servlet 模型转过来的同学习惯性地认为对象不用了 GC 会处理这恰恰是堆外内存泄漏最常见的思维盲区。修复这段代码时我也没简单地改成在 channelRead 里直接 release因为异步线程确实还需要这份数据。正确的做法取决于后续链路是否需要共享同一个 ByteBuf。如果异步线程只关心数据内容就复制成独立的数据结构之后立刻释放原 ByteBuf如果确实需要共享那就要retain()增加一次引用并让异步线程在消费完毕后release()一次。我当时选择的是最稳妥的方案把 ByteBuf 里的内容转换成byte[]或者业务 POJO存到DeferredMessage里然后在channelRead的 finally 块中释放原始 ByteBuf。这样所有权清晰不会出现两个线程都不知道该谁释放的扯皮局面。再来补一个高频坑很多人会用SimpleChannelInboundHandler因为它默认会在channelRead0处理完后自动 release 入站消息。但是如果重写了channelRead而不是channelRead0自动释放就不会生效需要自己负责。这个细节非常容易踩我当时排查团队代码时就发现好几个 handler 是混着重写的。建议项目里统一规范要么全部走SimpleChannelInboundHandler的channelRead0要么全部显式管理引用计数不要混用两套模型。5. 修复与验证找回 8GB 之后的内存变化代码修复本身并不复杂复杂的是如何证明内存真的找回来了。我当时做了一次完整的上线验证大致分三步。5.1 修复代码并补齐防护修复后的核心逻辑变成这样public class BizHandler extends ChannelInboundHandlerAdapter { Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf buf (ByteBuf) msg; try { byte[] data new byte[buf.readableBytes()]; buf.readBytes(data); DeferredMessage message new DeferredMessage(); message.setBody(data); // 这里保存普通 byte[]不再持有 ByteBuf 引用 asyncWorker.submit(message); } finally { ReferenceCountUtil.release(buf); } } }同时我把-XX:NativeMemoryTrackingsummary和 Netty 泄漏检测的启动参数固化到了发布配置里。对于核心服务我建议将io.netty.leakDetection.level长期保持在advanced这样每次上线后能通过日志里的泄漏告警提前发现问题。paranoid 只有在怀疑泄漏时才临时开启。5.2 观察 RSS 和 NMT 的回落修复版本上线后我没有只等一天的监控而是连续盯了一周。重点看三个指标进程 RSS 是否仍然持续爬坡容器的 memory usage 是否稳定NMT 里的 Internal 项是否停止增长结果很直观上线前 RSS 每天涨 1.5GB 左右修复后曲线趋于平缓7 天运行下来 RSS 基本稳定在 9.5GB。pmap 里那些大量重复的匿名内存段数量也不再增加。更关键的是随着旧的泄漏实例被发布替换原本被占住的物理内存在进程退出时被操作系统回收容器整体内存压力立刻缓解。所谓的找回 8GB一方面是不再产生新的泄漏另一方面是让已经泄漏的内存随着老实例重启而归还给 Linux。5.3 配套治理限制直接内存上限并优化分配器光修一处代码还不够。我在这次排障后还做了几个治理动作防止以后再出现同类问题。一是给 JVM 显式设置直接内存上限。虽然 Netty 的分配不完全受-XX:MaxDirectMemorySize管控但设置一个合理值能让ByteBuffer.allocateDirect这类调用在失控时提前报错而不是拖到 OOM。二是设置 Netty 内部的直接内存池上限-Dio.netty.allocator.maxDirectMemory让池子不会无限制占用。三是用 jemalloc 替换 glibc malloc并开启background_thread:true让空闲的 Dirty Page 能定期归还 OS这个配置对 RSS 虚高很有帮助。这几项配合下来进程的 RSS 曲线明显更健康不再出现明明释放了内存却被操作系统记着账的情况。6. 这次排障留给我的几个排查习惯回头看这次经历我最大的感触是排内存问题本质上是排一本账。JVM 有一本账Netty 有一本账glibc 有一本账Linux 内核还有一本账。四本账对不上就会看到各种诡异现象。比如这次如果只看 JVM 堆一切正常只看 Netty 池内存已经被回收但物理内存层面那 8GB 就是被引用计数卡住死活回不来。现在我排任何服务的内存问题都会先按这个顺序走确认 RSS 和 Heap 的差值判断问题在堆内还是堆外。打开 NMT看 JVM 内部哪部分在涨。用 pmap / smaps 看进程地址空间的分布快速识别 DirectBuffer 特征。找出谁持有对象不释放。Netty 场景直接用ResourceLeakDetector定位。修完后连续观测至少一个完整业务周期确认曲线不再上涨。对于使用 Netty 的团队我还想特别强调在项目初期就把 ByteBuf 的所有权规则写清楚。谁创建、谁负责释放跨线程传递时要么复制数据、要么 retain/release 成对出现。这个规则如果能刻在团队的代码规范里基本可以避免绝大多数 Netty 堆外内存泄漏。最后说一句自己的体会那 8GB 并不是真的丢了它一直以引用计数未归零的 DirectByteBuffer的形式躺在进程的地址空间里。找到它不需要什么神秘技巧只需要一层层把账算清楚。希望这篇实录能帮你下次在遇到 RSS 突然飙升时少走几步弯路。
返回列表