一个服务用 DirectByteBuffer 做"零拷贝"传输,堆内存稳定在 800M,而容器被 OOM Killer 杀掉——因为"直接内存"不在堆里,也不在 -Xmx 的管辖范围内。它只在"进程的 RSS"里出现。
直接内存的问题是"它的账在另一个本子上":-Xmx 管堆,这个"默认约等于堆大小"是"最容易踩的默认值":它意味着"堆越大,堆外可用的额度也越大",于是"扩堆"会同时扩大"总内存需求"——而容器配额没变。
而直接内存由"-XX:MaxDirectMemorySize"(默认约等于堆大小)与"实际使用"共同决定。
1. 三种内存的"管辖范围"
| 区域 | 由谁管 | 在哪里能看见 |
|---|---|---|
| 堆(Heap) | -Xmx |
jcmd GC.heap_info |
| 直接内存 | -XX:MaxDirectMemorySize |
BufferPoolMXBean、RSS |
| 元空间/Metaspace | -XX:MaxMetaspaceSize |
jcmd VM.metaspace |
第二项是"最容易被漏"的:**它的默认上限"约等于堆大小"**这个"倒算"的顺序是"先定容器配额,再分配各项"——反过来(先定堆、再看配额)就会出现"算下来是负数"的尴尬。
第二项的"修正"很直接:显式设 -XX:MaxDirectMemorySize(按"容器配额 − 堆 − 元空间 − 栈"倒算)——于是"堆外超额"会变成"明确的 OOM",而不是"被容器杀"。
,于是"堆设 4G"意味着"直接内存也能到 4G"——加起来 8G,而容器配额可能只有 6G。
2. "直接内存"的三种用途
| 用途 | 说明 |
|---|---|
| 零拷贝 IO(FileChannel/NIO) | 文件与网络传输 |
| 堆外缓存(如某些序列化) | 避免 GC |
| JNI/本地库 | 由本地代码申请 |
第一项的"量级"很容易超预期:一次"大文件传输"可能申请"几 MB 到几十 MB"的缓冲,而"并发 20 个"就是几百 MB——而它"分配后不立刻释放"(等 GC)。
第一项是"最常见的":FileChannel.transferTo 与 SocketChannel 会用直接内存做缓冲——于是"大文件传输"会"悄悄占掉几百 MB"。
3. "账怎么记"——三个观测点
| 观测点 | 得到 |
|---|---|
BufferPoolMXBean(java.nio:type=BufferPool,name=direct) |
直接缓冲的"已用/容量/数量" |
RSS 与"堆 + 元空间"的差 |
直接内存 + 栈 + 本地库 |
-XX:NativeMemoryTracking=summary |
分类明细(需重启) |
第二项是"最实用的":**"RSS − (堆 + 元空间 + 栈)"近似就是"直接内存 + 本地库"**第二项的"精度"取决于"栈"估算:线程数 × 1MB(默认 -Xss) 是可算的——于是"1 万个线程"就是 10GB 栈,而它常被完全忽略。
——于是"对不上账"的部分就是"堆外"。
// 三个数一起看(直接内存的账)
jcmd <pid> VM.native_memory summary | grep -A2 "Other" # 需提前开 NMT
jcmd <pid> GC.heap_info | head -5 # 堆
cat /proc/<pid>/status | grep VmRSS # RSS
// 判据:RSS 远大于"堆 + 元空间" → 差额在直接内存/栈/本地库
"内存配额的账"可以写成一行式子,避免"堆扩了容器没扩":
容器配额 ≥ 堆 + 元空间(256–512M) + 直接内存 + 线程栈(线程数×1M) + 代码/本地库 + 余量(10%)
// 例:堆 2G + 元 384M + 直接 512M + 栈 200M + 本地 200M + 余量 ≈ 3.4G → 配额设 4G
// 反例:只按"堆 × 1.5"设配额 → 大文件传输时必被 OOM Kill
4. "直接内存泄漏"的三种形态
| 形态 | 表现 |
|---|---|
未释放的 DirectByteBuffer |
direct 的 count 单调涨 |
| 池化缓冲未归还 | 池"用了不还" |
| 本地库申请未释放 | RSS 涨而 direct 不涨 |
第三项的"排查顺序"是"先看 direct 涨不涨":涨 → 是 Java 层的缓冲;不涨而 RSS 涨 → 去查 JNI/本地库/线程栈。
第三项最隐蔽:它不在 BufferPoolMXBean 里,只能靠"RSS 与明细的差"发现——而它是"JNI 或第三方库"造成的。
// 反例:手动申请直接内存,未释放(或依赖 GC,迟迟不回收)
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024 * 1024);
// 一旦这块不再被引用,回收要等"GC + Cleaner",而 GC 可能很久不发生
// 正解:池化 + 显式归还(或限制总量)
// 关键:直接内存的"释放"依赖 Cleaner,而 Cleaner 依赖 GC —— 于是"堆没压力"时它不释放
5. "为什么堆不涨它却爆"的机制
| 机制 | 说明 |
|---|---|
| GC 只按"堆压力"触发 | 堆空闲 → 不 GC → 不释放 |
| Cleaner 依赖 GC | 同上 |
| 上限是"约等于堆" | 于是"看起来有余量" |
第一项与第二项叠加,就是"最反直觉"的现象:堆很空、-Xmx 还有 3G,而直接内存已经用满并被 OOM Kill。这个"反直觉"的解释是"两套账本、两套触发条件":堆的 GC 看"堆占用",而直接内存的回收挂在"GC 触发的 Cleaner 上"——于是"堆不紧张"就等于"不回收堆外"。
"缓冲池"可以直接读,两行就能看出"涨不涨":
// 直接缓冲的"已用/容量/数量"(jmx 或 actuator)
curl -s localhost:8080/actuator/metrics/jvm.buffer.memory.used | jq '.measurements'
// 或 jcmd + jmxterm;关注"used 单调涨"与"count 单调涨"
// 判据:count 涨 = 有缓冲对象没释放;used 涨而 count 平 = 单个缓冲变大
6. 排错:五种"直接内存问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 容器 OOM 但堆正常 | 直接内存超额 | 设上限 + 查缓冲 |
direct count 单调涨 |
未释放 | 查分配点 |
| 大文件传输后内存不降 | 缓冲被缓存/持有 | 检查池与引用 |
OutOfMemoryError: Direct buffer memory |
达上限 | 调上限或修泄漏 |
堆外涨而 direct 不涨 |
本地库 | 查 JNI/第三方 |
第四项的"友好"在于"它发生在申请时":你能拿到"申请多大、已用多少"的上下文——而"容器 OOM Kill"只有"退出码 137"。
第四项的报错很"友好":它明确指出"直接内存不够",而"容器 OOM Kill"没有任何 JVM 报错(直接被杀)——后者更难查。
"大缓冲"的写法要能"用完就还",而这在池化里是"显式动作":
// 三种"归还"方式(从弱到强)
1 依赖 Cleaner:只置 null,等 GC(不可控)
2 显式清引用 + 触发 GC:仍不可控(不推荐)
3 池化:borrow/release(推荐,可控且可计量)
// 关键:池化的"release"要放在 finally,否则异常路径会漏
// 补充:池的"容量"要能被观测(借出数/归还数/等待数),否则"漏"只会表现为"池耗尽"
7. 边界:什么情况不必操心直接内存
| 场景 | 为什么 | 做法 |
|---|---|---|
| 无 NIO 大文件/大缓冲 | 用量很小 | 不管 |
| 堆外用量 < 100MB | 占比低 | 不管 |
| 容器余量充足(> 30%) | 不易触发 | 观察 |
已显式设 MaxDirectMemorySize |
有上限保护 | 观察 |
判断口径一句话:"这个服务有没有"大量 NIO 或堆外缓冲"。最后一条与"容量规划"有关的边界:"总内存 = 堆 + 元空间 + 直接内存 + 线程栈 + 代码/本地库 + 一点余量"——于是"容器配额"要按这个和来设,而不是"堆 + 1G"。
有(文件服务、消息网关、序列化密集),就必须"设上限 + 看 BufferPool";没有(普通 Web CRUD),它基本不构成问题。
8. 自测
- 三种内存分别由谁管?为什么"堆设 4G"可能让"直接内存也能到 4G"?
- 直接内存的三种用途里,哪一种最常"悄悄占掉几百 MB"?
- "RSS − (堆 + 元空间 + 栈)"近似是什么?为什么它是"最实用"的观测?
- 为什么"堆不涨它却爆"?GC 触发条件与 Cleaner 是什么关系?
- "容器 OOM Kill"与"
OutOfMemoryError: Direct buffer memory"哪个更难查?为什么? - 判断"是否需要操心直接内存"的那个问题是什么?