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()。
三、根本原因
链条是这样的:
ByteBuffer.allocateDirect()分配的是直接内存(Direct Memory),由 JVM 直接malloc向 OS 申请, 不在 Java 堆里,不受-Xmx管理;- 真正释放这块堆外内存,靠的是
DirectByteBuffer对象上的Cleaner(对象被 GC 回收时才触发); - 代码用
static final List<ByteBuffer> LEAKED长期强引用DirectByteBuffer对象 → 对象永远不会被 GC →Cleaner永远不触发 → 堆外内存永远不还给 OS; -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 次请求后 heapUsedMB | 21(正常) | 17(正常) |
200 次请求后 directBufferUsedMB | 1120+(线性增长) | 64(稳定封顶) |
1421 次请求后 directBufferUsedMB | 崩溃前已 1600+ | 64(不变) |
进程 VmRSS | 1243MB → 被 halt(137) | 170MB 稳定 |
NMT Other 增量 | +81994KB / 10 次请求 | +20KB / 20 次请求 |
| 容器结果 | ExitCode=137 被杀 | running, OOMKilled=false |
六、经验与避坑
jmap -heap/jstat -gc正常 ≠ 没有内存问题。 查容器 OOM 必须同时看 Heap + 进程 RSS + 堆外 (NMT /BufferPoolMXBean)。只看堆会 100% 漏掉堆外泄漏。-Xmx管不到allocateDirect()。 堆外内存由-XX:MaxDirectMemorySize管; 不设该参数时默认约等于-Xmx,但很多生产配置会被人为调到很大(如 8g),等于取消限制 —— 泄漏就会一路涨到把整机拖垮,而不是尽早抛OutOfMemoryError: Direct buffer memory。务必显式给合理上限。- NMT 是定位堆外泄漏的利器:启动加
-XX:NativeMemoryTracking=summary, 用jcmd <pid> VM.native_memory baseline打基线,跑一段业务后jcmd <pid> VM.native_memory summary.diff,哪个类别涨一目了然。 DirectByteBuffer 的堆外内存记在Other(malloc) 类别下,不要看错地方。 - 一行拿到堆外用量(无需 NMT):
ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)里name=="direct"的getMemoryUsed()/getCount(),可直接暴露成/stats指标做告警。 (sun.misc.SharedSecrets在新 JDK 已被移除,别再用它。) - 修复思路不是"别用堆外",而是让"谁分配、谁释放"闭环:
- 有界缓存 + 过期/淘汰策略,别用静态集合无限堆;
- 明确用完时主动释放(
Unsafe.invokeCleaner/Cleaner); - 或改用托管内存池(Netty
PooledByteBufAllocator、Apache Arrow 内存池); - 或干脆改回
ByteBuffer.allocate()走堆内,让 GC 管。
OOMKilled=false但ExitCode=137不矛盾:嵌套 Docker /Cgroup Driver: none时mem_limit不真正生效(内核不来杀),此时可能是进程自身看门狗Runtime.halt(137)。 判定要放宽为ExitCode==137 || OOMKilled==true,并看日志里有没有 OOM 相关输出。- 别用"关掉开关"冒充修复:把
LEAK=0只是绕过问题。作业/面试要求的是改对代码逻辑, 本解法保持LEAK=1,用例仍然真的在做堆外分配,只是不再泄漏。 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。
