朕喜技术(ZhenxiJS)
2026.09.10
1 分钟阅读

JVM 堆外内存泄漏:Heap 只有几十 MB,进程 RSS 却一路涨到被 OOM Killer 杀掉(exit 137)—— DirectByteBuffer 直接内存排查

日期:2026-09-10 16:00 场 | 环境:Docker + JDK 17 (eclipse-temurin:17-jdk-jammy) 技术栈 / 关键词:Java、JVM、堆外内存(off-heap / native memory leak)、直接内存、DirectByteBuffer、ByteBuffer.allocateDirect、Cleaner、-XX:MaxDirectMemorySize、NMT(Native Memory Tracking)、jcmd VM.native_memory、BufferPoolMXBean、jmap -heap、jstat -gc、容器 OOM Killer、exit code 137、容器 RSS 持续上涨

一、问题背景

一个 Java 图片处理服务跑在 1GB 内存的容器里,每天会神秘重启一次,docker inspect 看到退出码是 137(被 SIGKILL,通常是内核 OOM Killer 干的)。

但去查 JVM 堆,一切正常:

jstat -gc <pid>     # 年轻代/老年代都正常,Full GC 后堆能掉回几十 MB
jmap -heap <pid>    # Heap Usage 不到 60MB,MaxHeapSize 256MB,完全没满

堆明明是好的,为什么容器还是 OOM? 三个典型特征:

  • 堆(Heap)用了不到 60MB,-Xmx256m 远没到;
  • 容器 **RSS(真实物理内存)**一路涨到 1GB 上限,最后被 OOM Killer 杀掉;
  • 每次内存告警都伴随请求量上升;重启后正常,过一天又复发。

这就是典型的 JVM 堆外内存泄漏(off-heap / native memory leak):泄漏的内存不在 Java 堆里,而在堆外 (DirectByteBuffer 直接内存、Metaspace、线程栈、JNI 等),所以 jmap / jstat 这些"看堆"的工具完全查不出来。

二、复现步骤

场景代码里埋了 bug:每次请求都用 ByteBuffer.allocateDirect() 多分配一块堆外内存,然后塞进静态 List 长期持有。

// app/ImageServer.java(BROKEN)
private static final java.util.List<ByteBuffer> LEAKED = new java.util.ArrayList<>();

// 每次处理图片请求
if (leak) {
    ByteBuffer cached = ByteBuffer.allocateDirect(leakBytes);  // 默认 8MB 堆外内存
    cached.put((byte) 0x01);
    cached.flip();
    LEAKED.add(cached);   // 静态集合长期强引用,永不清理 → 泄漏
}

一键拉起(Docker Compose,LEAK=1,每次请求泄漏 8MB):

cd scenario/2026-09-10-1600
docker compose up -d --build
docker compose logs app | tail -3
# [ImageServer] listening on :8080 leak=true leakBytes=8388608 maxHeap=256MB

打 20 次请求,看堆 vs 堆外的分道扬镳:

for i in $(seq 1 20); do curl -s -o /dev/null http://localhost:8080/image; done
curl -s http://localhost:8080/stats
requests=20
heapUsedMB=8            ← 堆内只用了 8MB(上限 256MB),完全正常
heapMaxMB=256
directBufferCount=49
directBufferUsedMB=160  ← 堆外 160MB = 20 × 8MB,随请求数线性增长

看进程真实内存(容器 OOM 判定依据):

docker exec 3day-jvm-directbuf sh -c 'PID=$(pgrep -f "java -cp"|head -1); grep VmRSS /proc/$PID/status'
# VmRSS:  216292 kB

压到进程被杀:

N=200 ./scripts/leak_demo.sh
# [120 次] requests=140 heapUsedMB=15 directBufferUsedMB=1120
# [!] 请求失败(可能已被 OOM Killer 杀掉),i=129

docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' 3day-jvm-directbuf
# 137 false

docker compose logs app | tail -2
# [ImageServer] FATAL: RSS=1243MB 超过阈值 900MB,模拟内核 OOM Killer 终止进程(exit 137)
# [ImageServer] 提示:Heap 正常而 RSS 爆炸 = 堆外内存泄漏,请用 jcmd VM.native_memory 排查

约第 129 次请求时 RSS=1243MB 进程被杀,而 heap 一直只有 15MB。

用 NMT 精确定位"哪一类内存在涨"

这是本题最有价值的一步 —— jmap 看不出来,NMT 能直接指出增长类别:

docker compose exec app bash -c 'PID=$(pgrep -f "java -cp"|head -1); jcmd $PID VM.native_memory baseline'
for i in $(seq 1 10); do curl -s -o /dev/null http://localhost:8080/image; done
docker compose exec app bash -c 'PID=$(pgrep -f "java -cp"|head -1); jcmd $PID VM.native_memory summary.diff'
Total: reserved=1817860KB +92879KB, committed=432988KB +86499KB
-                 Java Heap (reserved=262144KB, committed=262144KB)   ← 堆,一点都不涨
-                  Internal (reserved=242KB +20KB, committed=242KB +20KB)
-                     Other (reserved=82016KB +81994KB, ...)           ← ★ 就是这里(DirectByteBuffer 记账在此)

Other 类别 +81994KB ≈ +80MB,正好等于 10 次请求 × 8MB。JDK 把 DirectByteBuffer 的堆外内存记在 NMT 的 Other(malloc)类别下 —— 证据链闭环,直接指向 allocateDirect()。

三、根本原因

链条是这样的:

  1. ByteBuffer.allocateDirect() 分配的是直接内存(Direct Memory),由 JVM 直接 malloc 向 OS 申请, 不在 Java 堆里,不受 -Xmx 管理;
  2. 真正释放这块堆外内存,靠的是 DirectByteBuffer 对象上的 Cleaner(对象被 GC 回收时才触发);
  3. 代码用 static final List<ByteBuffer> LEAKED 长期强引用 DirectByteBuffer 对象 → 对象永远不会被 GC → Cleaner 永远不触发 → 堆外内存永远不还给 OS;
  4. -XX:MaxDirectMemorySize=8g 等于没上限,泄漏可以一路涨到把容器/整机撑爆。

于是出现完美错位:堆内指标一切正常,进程 RSS 持续增长,最终被 OOM Killer 杀掉(exit 137)。

关键认知:DirectByteBuffer 对象本身很小(在堆里),它只是那块堆外内存的"句柄"。堆里的句柄活着,堆外的内存就死不掉。

四、解决办法

4.1 代码层:有界堆外缓冲池 + 用完立刻归还

删掉静态 LEAKED List,改为有硬容量上限的对象池,池满时主动释放:

// 修复点 1:不再用静态集合长期强引用 DirectByteBuffer
private static final OffHeapPool POOL = new OffHeapPool(
        Integer.parseInt(System.getenv().getOrDefault("POOL_SIZE", "8")),
        Integer.parseInt(System.getenv().getOrDefault("LEAK_BYTES", String.valueOf(8 * 1024 * 1024))));

// 修复点 2:借出 → 使用 → finally 归还(不再长期持有)
if (leak) {
    ByteBuffer cached = POOL.borrow();
    try {
        cached.clear();
        cached.put((byte) 0x01);
        cached.flip();
        // ... 模拟真实业务对这块堆外缓冲的使用 ...
    } finally {
        POOL.release(cached);   // ★ 关键:用完一定归还
    }
}

池的实现要点(容量有界 + 池满主动释放):

static final class OffHeapPool {
    private final java.util.ArrayDeque<ByteBuffer> free;
    private final int maxSize;

    ByteBuffer borrow() {                       // 池空则新建,用完全部归还,总量依然有界
        synchronized (free) {
            ByteBuffer b = free.pollFirst();
            if (b != null) return b;
        }
        return ByteBuffer.allocateDirect(bufferBytes);
    }

    void release(ByteBuffer b) {                // 池未满 → 缓存复用;池已满 → 主动释放堆外内存
        boolean keep;
        synchronized (free) {
            keep = free.size() < maxSize;
            if (keep) free.addLast(b);
        }
        if (!keep) cleanDirectBuffer(b);
    }

    /** JDK9+ 主动释放堆外内存:Unsafe.invokeCleaner;退路 cleaner().clean() */
    static void cleanDirectBuffer(ByteBuffer buf) {
        try {
            java.lang.reflect.Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            ((sun.misc.Unsafe) f.get(null)).invokeCleaner(buf);
        } catch (Throwable t) {
            try {
                java.lang.reflect.Method m = buf.getClass().getMethod("cleaner");
                m.setAccessible(true);
                Object cleaner = m.invoke(buf);
                if (cleaner != null) cleaner.getClass().getMethod("clean").invoke(cleaner);
            } catch (Throwable ignored) { }
        }
    }
}

4.2 参数层:给堆外内存一个合理上限

# 原来:等于没限制
ENV JAVA_TOOL_OPTIONS="-Xmx256m -XX:MaxDirectMemorySize=8g -XX:NativeMemoryTracking=summary"
# 修复后:与 -Xmx 对齐,万一再泄漏会尽早抛 OutOfMemoryError: Direct buffer memory
ENV JAVA_TOOL_OPTIONS="-Xmx256m -XX:MaxDirectMemorySize=256m -XX:NativeMemoryTracking=summary"

五、验证

保持 LEAK=1(没有靠关掉环境变量绕过问题)重新构建验证:

docker compose up -d --build
for i in $(seq 1 200); do curl -s -o /dev/null http://localhost:8080/image; done
curl -s http://localhost:8080/stats
requests=200
heapUsedMB=17
heapMaxMB=256
directBufferCount=217
directBufferUsedMB=64    ← ★ 关键:稳定封顶 64MB,不再是 200×8=1600MB
poolIdle=8
poolMax=8

继续加码到 1421 次请求,仍然封顶;RSS 稳定;容器没被杀;功能正常:

for i in $(seq 1 1000); do curl -s -o /dev/null http://localhost:8080/image; done
curl -s http://localhost:8080/stats      # requests=1421 directBufferUsedMB=64 poolIdle=8

docker exec 3day-jvm-directbuf sh -c 'PID=$(pgrep -f "java -cp"|head -1); grep VmRSS /proc/$PID/status'
# VmRSS:  170036 kB      ← 稳定(修复前是 1243MB 被 halt)

docker inspect -f 'status={{.State.Status}} exitCode={{.State.ExitCode}} oomKilled={{.State.OOMKilled}}' 3day-jvm-directbuf
# status=running exitCode=0 oomKilled=false

curl -s http://localhost:8080/image
# PNG-FAKE-IMAGE

NMT 复核(最强证据)—— 20 次请求 Other 只 +20KB:

docker compose exec app bash -c 'PID=$(pgrep -f "java -cp"|head -1); jcmd $PID VM.native_memory baseline'
for i in $(seq 1 20); do curl -s -o /dev/null http://localhost:8080/image; done
docker compose exec app bash -c 'PID=$(pgrep -f "java -cp"|head -1); jcmd $PID VM.native_memory summary.diff'
# -  Other (reserved=65707KB +20KB, committed=65707KB +20KB)   ← 修复前 10 次请求就 +81994KB

对照表:

指标修复前(泄漏)修复后
200 次请求后 heapUsedMB21(正常)17(正常)
200 次请求后 directBufferUsedMB1120+(线性增长)64(稳定封顶)
1421 次请求后 directBufferUsedMB崩溃前已 1600+64(不变)
进程 VmRSS1243MB → 被 halt(137)170MB 稳定
NMT Other 增量+81994KB / 10 次请求+20KB / 20 次请求
容器结果ExitCode=137 被杀running, OOMKilled=false

六、经验与避坑

  1. jmap -heap / jstat -gc 正常 ≠ 没有内存问题。 查容器 OOM 必须同时看 Heap + 进程 RSS + 堆外 (NMT / BufferPoolMXBean)。只看堆会 100% 漏掉堆外泄漏。
  2. -Xmx 管不到 allocateDirect()。 堆外内存由 -XX:MaxDirectMemorySize 管; 不设该参数时默认约等于 -Xmx,但很多生产配置会被人为调到很大(如 8g),等于取消限制 —— 泄漏就会一路涨到把整机拖垮,而不是尽早抛 OutOfMemoryError: Direct buffer memory。务必显式给合理上限。
  3. NMT 是定位堆外泄漏的利器:启动加 -XX:NativeMemoryTracking=summary, 用 jcmd <pid> VM.native_memory baseline 打基线,跑一段业务后 jcmd <pid> VM.native_memory summary.diff,哪个类别涨一目了然。 DirectByteBuffer 的堆外内存记在 Other(malloc) 类别下,不要看错地方。
  4. 一行拿到堆外用量(无需 NMT): ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class) 里 name=="direct" 的 getMemoryUsed() / getCount(),可直接暴露成 /stats 指标做告警。 (sun.misc.SharedSecrets 在新 JDK 已被移除,别再用它。)
  5. 修复思路不是"别用堆外",而是让"谁分配、谁释放"闭环:
    • 有界缓存 + 过期/淘汰策略,别用静态集合无限堆;
    • 明确用完时主动释放(Unsafe.invokeCleaner / Cleaner);
    • 或改用托管内存池(Netty PooledByteBufAllocator、Apache Arrow 内存池);
    • 或干脆改回 ByteBuffer.allocate() 走堆内,让 GC 管。
  6. OOMKilled=false 但 ExitCode=137 不矛盾:嵌套 Docker / Cgroup Driver: none 时 mem_limit 不真正生效(内核不来杀),此时可能是进程自身看门狗 Runtime.halt(137)。 判定要放宽为 ExitCode==137 || OOMKilled==true,并看日志里有没有 OOM 相关输出。
  7. 别用"关掉开关"冒充修复:把 LEAK=0 只是绕过问题。作业/面试要求的是改对代码逻辑, 本解法保持 LEAK=1,用例仍然真的在做堆外分配,只是不再泄漏。
  8. sun.misc.Unsafe 编译告警可忽略:javac 会报 Unsafe is internal proprietary API(warning,非 error), 这是 JDK9+ 主动释放堆外内存的标准做法,不影响运行。

七、一键复现

cd scenario/2026-09-10-1600
docker compose up -d --build
for i in $(seq 1 20); do curl -s -o /dev/null http://localhost:8080/image; done
curl -s http://localhost:8080/stats                                  # heap 小 / direct 大 → 堆外泄漏
N=200 ./scripts/leak_demo.sh                                         # 压到 exit 137
docker inspect -f '{{.State.ExitCode}}' 3day-jvm-directbuf
docker compose exec app bash -c 'jcmd $(pgrep -f "java -cp"|head -1) VM.native_memory summary'
docker compose down

完整操作过程(含每步命令与实测输出)见 scenario/2026-09-10-1600/SOLUTION.md。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-10-1600-jvmdirectbytebuffer

分享这篇文章