Java 内存泄漏排查实战:从 MAT 到 jmap——堆外内存暴增的元凶

引言

"凌晨 2 点网关告警内存 85%,3 点 92%,4 点容器被 OOMKilled,重启后一切正常,然后 6 小时后再来一遍。" 值班的同事第一反应是堆内存泄漏,jmap、jstat、堆 dump 全做了,发现 Xmx 2G 的堆只用了 800M,Young GC 规律、Old 区平稳,MAT 里 Dominator Tree 翻了三遍没有任何大对象——但容器 RSS 实实在在涨到了 3.7G(limit 4G)。

堆没涨,进程内存涨了——这就是堆外内存泄漏的典型特征。 它比堆内泄漏阴险得多:jmap 看不见、堆 dump 里找不到、OOM 时连 hprof 都不会生成(因为是内核 OOM Killer 直接杀的进程),监控图上只有一条让你绝望的平滑上涨曲线。

这篇文章按那次事故的真实排查路径,走完六个台阶:jmap 看堆 → RSS 对不上账 → NMT 给本地内存分类 → MAT 从 DirectByteBuffer 小对象顺藤摸瓜 → Netty LEAK 检测打出 Created at 堆栈 → jemalloc 做最终归因。元凶是一个 WebSocket 推送 Handler 在异常分支上漏调了 ByteBuf.release()。文末附全套可直接抄的排查命令和一张排查决策树——记住一句话:堆外泄漏的内存虽然分配在堆外,但"谁申请的"这张借条(DirectByteBuffer 对象)往往还躺在堆里,排查堆外问题,最终常常要回到堆里找证据。


一、全景:Java 进程的内存到底花在哪

1.1 RSS 的构成

很多人以为"JVM 内存 = 堆 + Metaspace",实际一个 Java 进程的 RSS(常驻物理内存)是这样的:

进程 RSS(top/ps 看到的)
├── Java Heap                -Xmx,最熟悉的部分,jmap 能看见
├── Metaspace                类元数据,-XX:MaxMetaspaceSize
├── Thread Stacks            每个线程 1MB(-Xss),500 个线程就是 500M
├── Code Cache               JIT 编译后的本地代码
├── GC 内部结构              Card Table、Remembered Set、标记位图等
├── Symbol / String Table    符号表、常量池
└── ── 以上 NMT 大多能记账,以下是事故高发区 ──
├── Direct Memory            ★ java.nio.DirectByteBuffer
│                            (Netty/Lettuce/gRPC/NIO 文件拷贝的主力)
├── Native 分配              ★ JNI 库、压缩库、Unsafe.allocateMemory
│                            (Netty 高版本自己 malloc 的池化内存也算这里)
└── malloc 碎片 + 竞技场     glibc ptmalloc 释放后不归还 OS 的空洞

排查的本质就是给 RSS 对账:把上面每一项的数字填出来,哪一项的数字和 RSS 涨幅对得上,问题就在哪。

1.2 排查路线图

容器/机器内存告警
  │
  ├─① 确认"谁在涨":top/ps 看 RSS,cgroup 看容器计数,dmesg 确认是否 OOM Killer
  │
  ├─② 排除堆内:jmap -heap / jstat -gcutil
  │     堆随 GC 平稳下降、Old 区不涨 → 不是堆内泄漏,转向堆外
  │
  ├─③ NMT 分类:jcmd VM.native_memory(需提前开 -XX:NativeMemoryTracking=detail)
  │     看 Internal / Other 项是否持续增长 → JVM 视角的直接内存
  │
  ├─④ 查 Direct ByteBuffer:
  │     JMX BufferPool direct 计数持续增长 → 直接内存泄漏实锤
  │
  ├─⑤ 堆 dump + MAT:DirectByteBuffer 对象在堆里
  │     OQL 筛出所有 DirectByteBuffer → 看 incoming references → 定位持有者
  │
  ├─⑥ 开 Netty LEAK=PARANOID:日志直接打印 "Created at" 分配堆栈
  │     若 NMT/MAT 都指不清第三方 native 分配 → jemalloc + jeprof 终极归因
  │
  └─ 修复:按引用计数所有权规则补 release + 预防措施

工具选型一句话:能在测试/预发复现的,直接开 LEAK=PARANOID 最快;只能在线上观察的,NMT + JMX + MAT 三件套;怀疑第三方 JNI 库或 glibc 碎片的,上 jemalloc。


二、第一步:确认现象,别一上来就 dump

2.1 先分清三件事

现象说明查看方式
容器内存涨cgroup 记账,到达 limit 被 OOMKilledmemory.current / docker stats / kubectl top pod
进程 RSS 涨操作系统视角的实际物理页top -p、ps -o rss
JVM 堆涨GC 分代视角jstat -gcutil、jmap -heap

三者必须同时采样对比,这一步能排掉大量误报:有的告警是 cgroup 统计口径问题,有的是 page cache(文件映射)被算进容器,还有的是堆确实涨了但跟堆外无关。

那次事故的第一组证据(容器 limit 4G):

# 1) 进程视角:RSS 3.7G,且每小时稳定上涨约 500M
$ ps -o pid,rss,vsz,etime,cmd -p 1
  PID   RSS    VSZ     ELAPSED CMD
    1 3854336 5316xxx 04:12:00 java -Xms2g -Xmx2g ...

# 2) cgroup 视角:容器记账 3.75G,和 RSS 基本吻合,说明不是统计误差
$ cat /sys/fs/cgroup/memory.current        # cgroup v2,单位字节
3980890112
# v1 的机器上是:
$ cat /sys/fs/cgroup/memory/memory.usage_in_bytes

# 3) 确认是被内核杀的(重启前的宿主机上)
$ dmesg -T | grep -i -A2 "killed process"
[Sat Feb 21 04:03:11] Memory cgroup out of memory: Killed process 1 (java)
                       total-rss:3892316kB anon-rss:3870004kB file-rss:...

anon-rss 占绝对大头、file-rss 很小,说明不是 mmap 文件/页缓存,是匿名内存——堆和堆外都是匿名内存,继续往下分。

2.2 再排除堆内泄漏

# 每 1 秒打印一次 GC 情况,看 10 分钟
$ jstat -gcutil 1 1000 600
  S0     S1     E      O      M     CCS    YGC   YGCT   FGC  FGCT   GCT
  0.00  93.22  41.50  38.11  94.72  91.05  12580 82.3   3    1.8   84.1
  ...        # O 列(Old 区占用率)始终在 37~39% 之间波动,不随时间上涨
             # FGC 只有 3 次,没有频繁 Full GC

# 堆配置与实际使用
$ jmap -heap 1 | head -30
Heap Configuration:
   MaxHeapSize  = 2147483648 (2048.0MB)
Heap Usage:
New Generation (Eden + 1 Survivor Space):
   used = 560214440 (≈534MB)
Old Generation:
   used = 805306368 (≈768MB)

结论很清晰:Old 区稳定在 768M、Full GC 次数不增长,但 RSS 3.7G 且仍在涨。 堆用了 1.3G,RSS 比堆多出 2.4G——这 2.4G 就是堆外(含 Metaspace、线程栈等正常开销和泄漏部分)。

提示:jmap -heap 在 JDK9+ 已废弃,用 jcmd 1 GC.heap_info 替代,输出信息等价。

一个容易误判的点:堆外泄漏有时会伴随频繁 Full GC。因为 DirectByteBuffer 对象本身很小(在堆里),但它通过 Cleaner(虚引用)持有堆外内存的释放逻辑;当直接内存接近 -XX:MaxDirectMemorySize 上限时,JDK 的 Bits.reserveMemory 会主动触发 Full GC 试图回收堆外内存。所以"Full GC 频繁但堆很空"反而是直接内存泄漏的强信号,别只看 Old 区。


三、第二步:NMT 给 JVM 的本地内存记账

3.1 什么是 NMT

NMT(Native Memory Tracking)是 HotSpot 自带的本地内存追踪器,能追踪 JVM 自己分配的本地内存:堆、类、线程、代码、GC、内部(含部分 Direct 分配)。它有两个硬限制,提前知道免得误判:

  1. 必须进程启动时开启,运行中无法补开:-XX:NativeMemoryTracking=detail(约 5%~10% 性能开销,容量充足的网关/中间件服务可以常开 summary)。
  2. 追踪不到第三方 JNI 库通过 malloc 直接分配的内存,也追踪不到 Netty 高版本绕过 DirectByteBuffer 的 Unsafe 分配——这部分要靠第七步的 jemalloc。

建议所有服务的启动参数里默认加上:

-XX:NativeMemoryTracking=detail

3.2 三个核心命令

# 1) 当前总账(scale 统一单位,数字才好读)
$ jcmd 1 VM.native_memory summary scale=MB

Native Memory Tracking:
Total: reserved=4682345KB, committed=3712304KB   # committed ≈ JVM 实际向 OS 拿的

-                 Java Heap (reserved=2097152KB, committed=2097152KB)
                            (mmap: reserved=2097152KB, committed=2097152KB)

-                     Class (reserved=1056896KB, committed=49280KB)
...

-                    Thread (reserved=524800KB, committed=47800KB)
                            (thread #512)                            # 500 个线程栈预留,实际驻留 47M

-                      Code (reserved=249856KB, committed=41200KB)

-                        GC (reserved=201334KB, committed=201334KB)

-                  Internal (reserved=2285400KB, committed=2285400KB)  # ← 重点嫌疑
                            (mmap: reserved=...)
                            (malloc=2285400KB #890231)                # ← malloc 2.18G,89 万次分配!

-                    Symbol (reserved=14560KB, committed=14560KB)

看到 Internal (malloc=2285400KB #890231) 这一行时基本可以锁定:2.18G 全部来自 malloc 类内部分配,且分配次数 89 万持续增长——在纯 Java 业务服务里,这种量级的 Internal malloc 几乎都是直接内存(DirectByteBuffer 的底层页或 Netty 的池化竞技场)。

# 2) 打基线(在内存正常时打,例如刚启动后)
$ jcmd 1 VM.native_memory baseline
Baseline succeeded

# 3) 两小时后对比差异——这是发现"持续增长"最有力的方式
$ jcmd 1 VM.native_memory summary.diff scale=MB

Total:  committed=3212304KB +1034240KB     # 两小时 JVM 本地内存涨了约 1G

-                  Internal (reserved=...)
                            (malloc=...)    # malloc=... +1014784KB  ← 增量几乎全在这里
                            (mmap ...)

+1014784KB 与 RSS 两小时的涨幅(约 1G)严丝合缝,账对上了。

3.3 NMT 的 JDK 版本差异(容易踩坑)

运行时Netty/JDK 直接内存分配方式NMT 里归到哪
JDK 8Netty 反射创建 DirectByteBuffer(带 Cleaner),走 Bits 记账Internal,且受 -XX:MaxDirectMemorySize 限制
JDK 9+JDK 封死了 DirectByteBuffer 反射构造,Netty 改用 Unsafe.allocateMemory 自建池/自管释放Internal(统计为 Other/Internal),不受 MaxDirectMemorySize 限制

这张表解释了两个线上怪象:为什么 JDK11 上 -XX:MaxDirectMemorySize 好像"失灵"了(Netty 的池化直接内存不经过 Bits);为什么 NMT 能看到增长量但 detail 看不到 Java 调用栈(Unsafe 分配的调用方对 NMT 是黑盒,需要 jemalloc 补上)。


四、第三步:锁定 DirectByteBuffer——证据在 JMX 和堆里

4.1 看直接内存的官方计数

JVM 把直接内存的使用情况暴露成了标准 JMX 指标,不用 dump 就能看趋势:

java.nio:type=BufferPool,name=direct
  ├─ Count      # 当前 DirectByteBuffer 个数
  ├─ MemoryUsed # 直接内存总字节
  └─ TotalCapacity

命令行三种取法:

# 方式一:JMX 客户端(JConsole/VisualVM/Mission Control)连上去看 MBean
#   java.nio → BufferPool → direct,重点看 Count 曲线是否只升不降

# 方式二:线上用 Arthas(不用重启、不用开端口)
$ java -jar arthas-boot.jar 1
[arthas@1]$ mbean java.nio:type=BufferPool,name=direct
 count                                       : 289123
 memoryUsed                                  : 2156433408      # 2.0G
 totalCapacity                               : 2156433408

# 隔 30 分钟再执行一次
 count                                       : 304561           # +15438
 memoryUsed                                  : 2390753280       # +224M,只涨不跌

Count 和 MemoryUsed 单调递增、GC 后不回落,直接内存泄漏实锤。如果 Full GC 后数字短暂回落又继续涨,说明 Cleaner 回收机制在工作,但申请速度大于回收速度,依然是泄漏(或容量配置问题)。

4.2 为什么"堆外泄漏"要回到堆里查

DirectByteBuffer 是一个经典的双料对象,理解它的结构是整个排查的关键:

堆内(小,几十字节)                  堆外(大,可能 16KB~16MB)
┌──────────────────────┐
│ DirectByteBuffer 对象 │  ──持有地址──→  ┌────────────────────┐
│  long address  0x7f..│                │ 真正的数据页(malloc)│
│  int capacity 16384  │                └────────────────────┘
│  Cleaner(虚引用)     │
└──────────────────────┘
       ↑ GC 回收这个对象时,
         Cleaner 才会调用 Unsafe.freeMemory(address) 释放堆外页

释放靠的是 Cleaner(PhantomReference 的子类):只有 DirectByteBuffer 堆内对象被 GC 回收,堆外内存才会被释放。正常代码里显式调 ((DirectBuffer)buf).cleaner().clean()(Netty 会主动调);漏掉时只能干等 GC——而 DirectByteBuffer 对象太小,Young 区一旦存活晋升到 Old 区,就要等下一次 Full GC 才释放,期间堆外内存只涨不回收。

所以排查思路反转过来:去堆 dump 里数 DirectByteBuffer 对象、看它们被谁引用着,引用链的顶端就是泄漏代码的入口。

4.3 不 dump 也能先粗筛一把

# 类直方图(不加 :live 不触发 GC;加 :live 会触发 Full GC,生产慎用!)
$ jcmd 1 GC.class_histogram | grep -i -E "DirectByteBuffer|ByteBuffer"
   2:        289123       45034840  java.nio.DirectByteBuffer
   8:        288900       34668000  java.nio.DirectByteBuffer$Deallocator

近 29 万个 DirectByteBuffer 实例,而正常服务通常只有几百到几千个(Netty 池化后更稳定)——数量级异常本身就是证据。


五、第四步:堆 dump + MAT 顺藤摸瓜

5.1 安全地 dump

# 推荐:jcmd  dump(JDK8u+ 都支持),heap dump 是 safepoint 操作,会有短暂停顿
$ jcmd 1 GC.heap_dump /tmp/gateway-$(date +%H%M).hprof

# 老写法(效果相同)
$ jmap -dump:format=b,file=/tmp/gateway.hprof 1

# 注意:
# 1) dump 文件大小≈堆大小,确保磁盘有 2G+ 空间,别写满容器盘
# 2) 生产环境避开高峰,停顿时间与堆大小正相关(2G 堆通常数百毫秒~数秒)
# 3) 容器里 dump 出来后,用 kubectl cp / docker cp 拷到本地用 MAT 分析

5.2 MAT 里的四步操作

第一步:别只看 Dominator Tree。 堆外泄漏在堆里的表现是"几十万个小对象",Dominator Tree 按 retained heap 排序,它们单个都很小,会淹没在列表里。事故第一次排查就是在这卡住的。

第二步:用 OQL(Object Query Language)直接点名 DirectByteBuffer:

SELECT s FROM java.nio.DirectByteBuffer s

结果 28 万条,capacity 大多是 16384(16KB,正是 Netty 聚合帧的常用大小)。

第三步:按 capacity 聚合并抽一条看引用链。 选中样本 → List Objects → with incoming references(谁引用着它),再 Path To GC Roots → exclude weak/soft references,典型链路:

java.nio.DirectByteBuffer
  ← io.netty.buffer.PooledByteBuf$PooledUnsafeDirectByteBuf  (buf)
  ← io.netty.channel.socket.DatagramPacket / BinaryWebSocketFrame (content)
  ← java.util.ArrayList$Itr / Object[]                       (消息批处理列表)
  ← com.xxx.push.WsFrameAggregator.pendingFrames             ← ★ 业务类!
  ← cn.dev33... DefaultHandlerPipeline

引用链顶端停在自己的业务类 WsFrameAggregator 上——这就是"持有者"。该类用一个 List<Object> pendingFrames 聚合 WebSocket 分片帧,正常路径处理完会 release,但下游推送失败的 catch 分支只打了日志、没释放帧内容,失败越多,持有的 ByteBuf 越多。

第四步:验证 Netty 层统计。 如果引用链是 Netty 对象,可以顺便看 PooledByteBufAllocator 的池化指标(JMX 或 Arthas):

io.netty.pool:type=PooledByteBufAllocatorMetric
  directArenas[].chunkList[].activeBytes   # 竞技场活跃内存,持续上涨不归还是泄漏特征
  directArenas[].numActiveAllocations      # 活跃分配数

池化 ByteBuf 泄漏和未池化的区别:池化泄漏时内存在 Netty 的 PoolArena 里不归还(arena 只增不减),未池化泄漏则表现为 DirectByteBuffer/malloc 数量增长。 两种在事故里都会让 RSS 上涨,但修复点都是同一个:配对 release。

5.3 一个应急手段(不是修复)

确认是 Cleaner 回收不及时(而非彻底的引用泄漏)时,可以让应用定时触发并发 Full GC 临时压住曲线:

-XX:+ExplicitGCInvokesConcurrent   # 让 System.gc() 走 CMS/G1 并发周期,而不是 Serial Full GC

仅用于争取修复窗口,严禁当作解决方案——靠 Full GC 释放直接内存说明释放时机已经失控,根因仍是漏 release。


六、第五步:Netty LEAK 检测——让框架亲口说出分配点

MAT 引用链是静态推断,Netty 还提供了更直接的武器:泄漏检测会在 ByteBuf 被 GC 前检查 refCnt,发现没 release 就打印分配时的访问记录堆栈。

6.1 四个级别

级别采样率用途
DISABLED0关闭
SIMPLE默认,约 1% 抽样生产默认,告诉你"有泄漏"
ADVANCED1% 抽样 + 访问记录看泄漏 buf 的访问轨迹
PARANOID100%预发/压测环境定位问题,开销大

开启方式(JVM 系统属性,应用启动时设置):

-Dio.netty.leakDetection.level=PARANOID
-Dio.netty.leakDetection.targetRecords=40   # 保留最近40条访问记录(默认10条,堆栈可能不够深)

压测复现后,日志里会出现那段标志性输出:

ERROR  ResourceLeakDetector:238 - LEAK: ByteBuf.release() was not called before it's
garbage-collected. Recent access records:
Created at:
    io.netty.buffer.UnpooledByteBufAllocator.newDirectBuffer(UnpooledByteBufAllocator.java:96)
    io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
    io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:178)
    io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame.<init>(...)
    com.xxx.push.WsFrameAggregator.channelRead0(WsFrameAggregator.java:118)   ← 直接指到业务行号
    io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(...)
    ...
: 128 leak records were discarded in this period.

Created at 堆栈直接打在 WsFrameAggregator.java:118——和 MAT 引用链结论交叉验证一致,这就是铁证。

一个关键经验:不要看到 Netty 的堆栈就认定是 Netty 的 bug。先按堆栈确认 pipeline 归属——同样的 LengthFieldBasedFrameDecoder 堆栈,可能属于 Lettuce 的 Redis 客户端、也可能属于自研 TCP 服务,查错模块会原地踏步。那次事故里日志线程名是 nio-http-server-(网关自己的 ServerBootstrap),直接锁定服务端推送链路,排除了 Lettuce 侧。

6.2 ByteBuf 引用计数的所有权规则(修复的理论基础)

修复漏 release 不能靠"看到 buf 就 release",必须先明确所有权,否则会引发更难查的 use-after-release:

  1. 入站消息:channelRead(ctx, msg) 收到的 ByteBuf/Frame,当前 Handler 消费完就要释放——要么继承 SimpleChannelInboundHandler(框架在 channelRead0 返回后自动 release),要么手动 ReferenceCountUtil.release(msg)。
  2. 出站消息:write/writeAndFlush 的 ByteBuf,pipeline 负责最终释放(成功失败都释放),调用方别再自己 release。
  3. retain 必须配对 release:把 buf 传给异步线程、放进聚合列表跨方法持有,必须先 retain(),消费方完成后对应一次 release()。
  4. 异常路径与正常路径同等重要:catch 分支、return 提前退出、循环 continue,每一条都要检查引用计数去向——事故就出在 catch 分支。

6.3 修复前后对比

问题代码(异常分支泄漏 + 聚合列表释放不全):

// 自定义 Handler,不是 SimpleChannelInboundHandler → msg 不会自动释放
public class WsFrameAggregator extends ChannelInboundHandlerAdapter {

    private final List<BinaryWebSocketFrame> pendingFrames = new ArrayList<>();

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (!(msg instanceof BinaryWebSocketFrame)) {
            ctx.fireChannelRead(msg);
            return;
        }
        BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg;
        pendingFrames.add(frame);

        if (frame.isFinalFragment()) {
            try {
                ByteBuf payload = aggregate(ctx.alloc(), pendingFrames);
                downstream.push(ctx.channel(), payload);   // 推送下游,失败会抛异常
            } catch (Exception e) {
                // ✗ 只打日志:pendingFrames 里每个 frame.content()(堆外内存)全部泄漏
                log.error("push failed", e);
            } finally {
                pendingFrames.clear();   // ✗ clear 掉的只是 Java 引用,ByteBuf 没释放
            }
        }
        // 非 final 分片时也没有任何释放约定,完全依赖聚合完成
    }
}

修复后(try-finally 兜底 + 所有权下沉到消费点):

public class WsFrameAggregator extends SimpleChannelInboundHandler<WebSocketFrame> {
    // ① 继承 SimpleChannelInboundHandler:channelRead0 返回后,入站 frame 由框架自动 release

    private final List<BinaryWebSocketFrame> pendingFrames = new ArrayList<>();

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame msg) {
        if (!(msg instanceof BinaryWebSocketFrame)) {
            ctx.fireChannelRead(msg);
            return;
        }
        BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg;
        // ② 入站帧框架会自动释放,但我们要跨方法聚合持有 → 显式 retain
        frame.retain();
        pendingFrames.add(frame);

        if (!frame.isFinalFragment()) {
            return;
        }

        List<BinaryWebSocketFrame> batch = new ArrayList<>(pendingFrames);
        pendingFrames.clear();
        ByteBuf payload = null;
        try {
            payload = aggregate(ctx.alloc(), batch);   // 聚合出新 buf(归本方法所有)
            downstream.push(ctx.channel(), payload);    // push 内部 writeAndFlush,
                                                          //   payload 所有权移交 pipeline
            payload = null;                             // 移交成功,本地不再负责
        } catch (Exception e) {
            log.error("push failed", e);
        } finally {
            // ③ 聚合失败/推送失败:新 buf 没移交出去,本地兜底释放
            if (payload != null) {
                ReferenceCountUtil.release(payload);
            }
            // ④ 每个 retain 过的分片帧配对 release,无论成功失败
            for (BinaryWebSocketFrame f : batch) {
                ReferenceCountUtil.release(f);
            }
        }
    }
}

四条所有权规则全部落地:自动释放入站帧、跨层持有先 retain、移交后置空避免重复释放、finally 兜底全部路径。上线后开 PARANOID 压测 2 小时,LEAK 日志归零,direct MemoryUsed 稳定在 300M 不再上涨。


七、第六步:jemalloc——第三方 native 分配的终极归因

不是每次都能直接看到 DirectByteBuffer。如果 NMT 里 Internal 涨、BufferPool direct 计数却平稳,说明内存是绕过 JVM 直接走 malloc 的:JNI 压缩库(图片/Snappy/ZSTD)、Netty 高版本 Unsafe 池化、 RocksDB 客户端、glibc 碎片等。此时上 jemalloc 做 native 侧的分配 profiling(Linux 生产环境)。

7.1 安装与挂载

# CentOS
$ yum install -y jemalloc jemalloc-devel
# Ubuntu
$ apt-get install -y libjemalloc-dev
# 确认路径(一般在 /usr/lib64/libjemalloc.so.2 或 /usr/local/lib/libjemalloc.so)
$ ls /usr/lib64/libjemalloc.so*

以 agent 方式预加载,不需要改任何业务代码,改启动参数重启即可:

# MALLOC_CONF 关键参数:
#   prof:true          开启分配采样
#   lg_prof_sample:17   每分配 2^17=128KB 采样一次(数值越小越精确、开销越大)
#   prof_prefix        dump 文件前缀
#   lg_prof_interval:30 每 2^30=1GB 分配自动 dump 一次(可选)
export MALLOC_CONF="prof:true,lg_prof_sample:17,prof_prefix:/tmp/jeprof.out,lg_prof_interval:30,prof_leak:true"
export LD_PRELOAD=/usr/lib64/libjemalloc.so.2

# 然后正常启动 Java(K8s 里写进容器 env + 把 .so 打进基础镜像)
java -XX:NativeMemoryTracking=detail -jar app.jar

7.2 触发 dump 与分析

# 方式一:等自动 dump(每 GB),/tmp 下会生成 jeprof.out.<pid>.i<序号>.heap
# 方式二:手动触发(给进程发特定信号,jemalloc 约定)
$ jcmd 1 VM.native_memory           # 只是顺便对照 NMT 时间点
# jemalloc 开启 prof_active 控制:可以用 jeprof 的触发开关或直接 kill -12(需 conf 支持)
# 实操中最常用:启动时一个 dump,内存涨上来后再等一个 dump,用增量对比

# 文本报告:对比两个时间点的分配增量(最能说明"谁在持续分配不释放")
$ jeprof --show_bytes --text --base=/tmp/jeprof.out.1.i0.heap \
    $(which java) /tmp/jeprof.out.1.i6.heap | head -30

Using local file /tmp/...i6.heap.
Using local file /tmp/...i0.heap (as base).
Total: 2147483648 Bytes
         0   0.0%   0.0%  2040109465  95.0%  io_netty_buffer_PoolArena_allocateRun  [这是JIT本地符号示意]
1610612736  75.0%  75.0%  1610612736  75.0%  je_calloc
         ...          536870912  25.0%  Java_io_netty_..._Unsafe_AllocateMemory  ← native 调用栈

# 生成 SVG 火焰/调用图(肉眼看最大的扇形)
$ jeprof --show_bytes --svg --base=/tmp/jeprof.out.1.i0.heap \
    $(which java) /tmp/jeprof.out.1.i6.heap > /tmp/native-leak.svg

报告按分配调用栈聚合,能直接看到 Java_* 符号对应的 Java 类方法(JNI 符号名),或第三方 .so 内部的分配热点。那次事故如果发生在 JDK11 + Netty 自管池化内存的环境,NMT 只能告诉你 Internal 涨,jemalloc 会直接把 io.netty.buffer.PoolArena → allocateDirect 这条栈顶在榜首。

7.3 顺带解决的 glibc 碎片问题

还有一类"假泄漏":内存确实 free 了,但 glibc 的 ptmalloc 因为多线程竞技场(arena)锁竞争和碎片整理策略,不把空闲页归还 OS,RSS 只涨不降。jemalloc 自身的碎片率显著低于 ptmalloc,很多服务挂上 jemalloc 后 RSS 立刻下降 20%~40%——这本身就是一个收益:

# 验证是否碎片问题的旁证:pmap 看大块匿名映射的分布
$ pmap -x 1 | sort -k3 -n -r | head -20
# 大量 64MB(典型 arena 块)且 NMT 各项不涨 → 高度怀疑 malloc 碎片

注意区分:碎片问题的特征是 NMT/JMX/LEAK 全部正常但 RSS 涨;泄漏问题的特征是某一项计数单调增长。 两者经常同时出现,jemalloc 对两者都有效,但根因修复不能省。


八、修复与预防:七条落地措施

  1. 入站 Handler 优先继承 SimpleChannelInboundHandler,把业务写进 channelRead0,让自动释放兜底;必须用 ChannelInboundHandlerAdapter 时,try-finally + ReferenceCountUtil.release(msg) 紧贴消费边界,不跨层释放。

  2. 异步持有必配对:跨线程、放入聚合容器、塞进队列的 ByteBuf,入口 retain()、出口 release(),把"谁在什么时刻拥有引用计数"写进 Code Review checklist。

  3. 预发环境常开 PARANOID + 压测:把 -Dio.netty.leakDetection.level=PARANOID 写入压测环境启动脚本,压测脚本必须包含"下游异常/断连/超时"路径——泄漏大半藏在异常分支,只压正常路径永远发现不了。

  4. 给直接内存设上限,别用默认值:

    -XX:MaxDirectMemorySize=512m
    -Dio.netty.maxDirectMemory=536870912      # Netty 自己的池化上限(字节),JDK11+ 尤其要设
    -Dio.netty.allocator.numDirectArenas=... # 容器小规格应用调小 arena 数,降低碎片和虚高
    

    不设 MaxDirectMemorySize 时,JDK8 默认直接内存上限约等于 Xmx,容器极易出现"堆 2G + 直接内存 2G + 其他"撑爆 limit。

  5. 容器内存配额做加法:

    container limit ≥ Xmx + MaxDirectMemorySize + Metaspace(约256~512M)
                     + 线程数×Xss(500线程≈500M) + CodeCache(约256M)
                     + GC结构 + JVM本身(约300M) + 安全余量15%
    

    以 2G 堆的服务为例,容器 limit 给 4G 是底线,给 2G/2.5G 等于定时炸弹。

  6. 监控补齐三个指标(JMX/Actuator 都能采):

    • java.nio:type=BufferPool,name=direct 的 Count 与 MemoryUsed(直接内存)
    • 进程 RSS 与 cgroup memory.current(容器视角,配 OOM 事件告警)
    • Netty PooledByteBufAllocatorMetric 的 activeBytes / numActiveAllocations(有 Netty 的服务必加)
      告警策略:直接内存 30 分钟单调上涨且 GC 后不回落即 P1,别等 OOMKilled。
  7. 基础镜像统一 jemalloc:高并发 Netty 服务的基础镜像默认 LD_PRELOAD jemalloc(关 prof,零代码成本降碎片);出问题时再通过环境变量打开 prof 采样,省去临时安装。


九、常见问题

9.1 jmap / jstat 都正常,为什么容器还是被杀?

容器 limit 约束的是整个进程 RSS(含堆外),不是 JVM 堆。堆只用 40% 但直接内存、线程栈、CodeCache、malloc 碎片加起来超过 limit,内核 OOM Killer 直接发 SIGKILL——JVM 没机会抛 OutOfMemoryError,也不会生成 hprof。排查时第一步就是 dmesg 确认 "Memory cgroup out of memory",看到这条就知道账要往堆外算。

9.2 NMT 显示 Internal 涨,但没开 NMT 怎么办?

NMT 必须重启加 -XX:NativeMemoryTracking=detail 才能用,不能动态开启。线上没开时的替代组合:① JMX BufferPool.direct 看 DirectByteBuffer;② jcmd GC.class_histogram 数 DirectByteBuffer 个数;③ pmap -x 看匿名大块;④ Arthas 观察 Netty allocator metric;⑤ 实在无法归因时,挂 jemalloc 重启(下次发布窗口带上)。重要服务建议常开 summary 级别,开销很小。

9.3 Full GC 之后直接内存短暂回落,算泄漏吗?

算。正常的直接内存使用(Netty 池化)应该在业务负载稳定后基本平稳,不依赖 Full GC。靠 GC 回收说明存在大量"未显式释放、等 Cleaner 兜底"的 DirectByteBuffer——短期侥幸不炸,一旦 DirectByteBuffer 晋升 Old 区、Full GC 周期变长,就会演变成持续上涨。LEAK 检测开起来抓分配点,不要把 GC 兜底当机制。

9.4 Netty 报 LEAK 但业务正常跑,可以先不管吗?

不行,但要先排除误报。确认方法:看 LEAK 日志的堆栈归属(线程名、pipeline 是你自己的 Server 还是 Lettuce/gRPC 客户端),一次抽样可能是框架内部在异常路径下的正常释放延迟;PARANOID 下同一堆栈稳定复现就是真泄漏。泄漏的 ByteBuf 在低流量下每天漏几兆,大促时几分钟 OOM,这类问题必须在压测期清零。

9.5 为什么我 release 了还报 LEAK / 报 IllegalReferenceCountException?

两种典型错误:① release 多了——msg 已经由 SimpleChannelInboundHandler 或 writeAndFlush 释放,又手动 release 一次,refCnt 减到负数抛异常;② release 早了——异步线程还没消费完就在提交方释放了,消费方 use-after-release。记住检查顺序:先画清这条消息从 channelRead 到最终消费的所有权转移图,再决定在哪一层 release,不要在桥接层无条件 release。

9.6 dump 堆会不会把线上服务搞挂?

jmap/jcmd heap dump 会触发 safepoint,停顿时间与堆大小和对象数量正相关,2G 堆通常数百毫秒到数秒,8G 以上大堆在高 QPS 服务上可能十几秒;同时 dump 文件写盘有 IO 压力。建议:① 优先在隔离实例/摘流量后的实例上 dump;② 磁盘预留 1.5 倍堆大小空间;③ JDK11+ 可用 jcmd GC.heap_dump -all=false 只 dump 存活对象减小体积;④ class_histogram(不带 :live)停顿很小,可先粗筛再决定要不要 full dump。


十、总结

排查决策树速查卡

内存告警
 ├─ dmesg 有 "Memory cgroup out of memory"? ── 是 ──→ 进程级 OOM,查 RSS 构成
 ├─ jstat -gcutil:Old 区持续上涨? ── 是 ──→ 堆内泄漏:dump + MAT 查大对象链(另一类问题)
 │                                  └─ 否(Old平稳)
 ├─ JMX BufferPool.direct 的 Count/MemoryUsed 单调涨?
 │    ├─ 是 → DirectByteBuffer 泄漏
 │    │       ├─ 预发:-Dio.netty.leakDetection.level=PARANOID 抓 Created at 堆栈
 │    │       └─ 线上:jcmd GC.heap_dump → MAT/OQL 查 DirectByteBuffer 的 incoming refs
 │    │                → 引用链顶端业务类 = 泄漏入口
 │    └─ 否,但 NMT Internal 涨 / pmap 匿名块增长
 │            → JNI/Unsafe 直接 malloc:jemalloc + jeprof 对比 dump
 │            → NMT/JMX 都正常只 RSS 涨:glibc 碎片,换 jemalloc
 └─ 修复后验证:LEAK 日志归零 + direct MemoryUsed 平稳 + RSS 24h 不再单调上涨

完整命令速查表

# ── ① 现象确认 ──
ps -o pid,rss,vsz,etime,cmd -p <pid>
jstat -gcutil <pid> 1000 60
jmap -heap <pid>                              # JDK9+ 用 jcmd <pid> GC.heap_info
cat /sys/fs/cgroup/memory.current             # v2;v1: memory/memory.usage_in_bytes
dmesg -T | grep -i "killed process"

# ── ② NMT(需 -XX:NativeMemoryTracking=detail 启动)──
jcmd <pid> VM.native_memory summary scale=MB
jcmd <pid> VM.native_memory baseline          # 内存正常时打基线
jcmd <pid> VM.native_memory summary.diff scale=MB

# ── ③ 直接内存计数 ──
# Arthas:
mbean java.nio:type=BufferPool,name=direct
# JMX 客户端:java.nio → BufferPool → direct,看 Count/MemoryUsed 趋势

# ── ④ 类直方图(:live 会触发 Full GC,慎用)──
jcmd <pid> GC.class_histogram | grep -i DirectByteBuffer
jmap -histo <pid> | grep -i ByteBuffer

# ── ⑤ 堆 dump + MAT ──
jcmd <pid> GC.heap_dump /tmp/app.hprof
# MAT: OQL → SELECT s FROM java.nio.DirectByteBuffer s
#      → incoming references → Path To GC Roots → 定位业务持有者

# ── ⑥ Netty 泄漏检测(预发/压测)──
-Dio.netty.leakDetection.level=PARANOID
-Dio.netty.leakDetection.targetRecords=40

# ── ⑦ jemalloc(Linux)──
export LD_PRELOAD=/usr/lib64/libjemalloc.so.2
export MALLOC_CONF="prof:true,lg_prof_sample:17,prof_prefix:/tmp/jeprof.out,lg_prof_interval:30"
jeprof --show_bytes --text --base=/tmp/jeprof.out.<pid>.i0.heap $(which java) /tmp/jeprof.out.<pid>.i6.heap
jeprof --show_bytes --svg  --base=...i0.heap $(which java) ...i6.heap > leak.svg
pmap -x <pid> | sort -k3 -n -r | head -20

给团队的建议

项建议
JVM 参数必带 -XX:NativeMemoryTracking=summary、显式 MaxDirectMemorySize,容器按加法留配额
预发环境LEAK=PARANOID 常开,压测必须覆盖下游异常/断连/超时路径
Code Review重点查 ByteBuf 所有权:入站释放、异步 retain/release 配对、catch/finally 分支
监控告警直接内存使用率 + 单调不回落趋势、RSS/容器内存、OOM 事件,三个缺一不可
工具准备Arthas 进基础镜像;jemalloc 打进基础镜像(默认不 prof,用时开关一开)
dump 纪律摘流量实例上 dump,磁盘预留 1.5 倍堆空间,大堆优先 class_histogram 粗筛
架构习惯网关/推送类服务别把 ByteBuf 长时间塞进业务集合,聚合边界即释放边界

一句话

堆外内存泄漏排查的核心心法是"对账":先用 RSS、cgroup、dmesg 确认是进程级匿名内存被 OOM Killer 杀,再用 jstat/jmap 排除堆内(Old 区平稳而 RSS 涨,是堆外泄漏最硬的特征);然后让 NMT 的 baseline diff 指出 Internal malloc 的持续增量、让 JMX BufferPool.direct 的 Count 和 MemoryUsed 实锤直接内存,最后记住"堆外内存的借条在堆里"——jcmd dump 堆后用 MAT 的 OQL 筛出 java.nio.DirectByteBuffer,沿 incoming references 找到持有它的业务类,或者干脆在预发打开 -Dio.netty.leakDetection.level=PARANOID 让 Netty 直接打印 Created at 堆栈;绕过 JVM 的 JNI/Unsafe 分配和 glibc 碎片则交给 jemalloc + jeprof 的 base 对比归因。修复永远不是"补一个 release"那么简单,而是落实引用计数的所有权规则:入站帧由 SimpleChannelInboundHandler 或 try-finally 释放、异步持有先 retain 再配对 release、异常分支和正常路径同等覆盖。容器时代配好 MaxDirectMemorySize、给直接内存和线程栈在 limit 里留出位置、监控 direct MemoryUsed 曲线,远比 OOM 后半夜翻日志轻松——堆外内存不可怕,账对不上才可怕。

互动话题:你们被堆外内存"教做人"是在哪种场景下——Netty 推送、Redis 客户端、文件下载还是图片处理?抓到元凶用的是 NMT、MAT 还是 jemalloc?评论区聊聊你的 RSS 对账经历。


标题:Java 内存泄漏排查实战:从 MAT 到 jmap——堆外内存暴增的元凶
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/29/1790517594721.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消