堆外内存排查
提出问题
Java 应用的内存问题不止发生在堆上。堆外内存(Off-Heap / Direct Memory)泄漏是生产环境最头疼的疑难杂症之一:堆内存正常、GC 正常,但进程 RSS 一路飙升,最终被 OOM Killer 杀掉。Netty、gRPC、Kafka Client 等主流框架都重度依赖 DirectByteBuffer 做零拷贝 IO,一旦分配和回收不匹配,内存就凭空消失。更要命的是,堆外内存不在 GC 的直接管辖范围内,常规的 jmap -heap 根本看不到。
面试官高频追问:假如你的服务 RSS 从 4GB 涨到 8GB 但堆内存只有 2GB,你从哪下手?—— 这道题问的就是堆外内存排查,不是堆内。
另一个追问变体:重启就好了,但三天后又涨回来,为什么?—— 答案:堆外内存泄漏不像堆内泄漏那样"累积到 OOM 才崩溃",而是进程被 OS 的 OOM Killer 杀掉,没有 Java 异常堆栈,重启后重新分配,所以始终没有 Java 层面的 OOM 日志,只有系统日志里 Out of memory: Kill process 的痕迹。
分析问题
DirectByteBuffer 的分配与回收机制
DirectByteBuffer 通过 ByteBuffer.allocateDirect(capacity) 分配,底层调用 Unsafe.allocateMemory 向操作系统申请堆外内存。分配时它会在堆上创建一个很小的 DirectByteBuffer 对象(通常几十字节),这个对象内部持有一个 Cleaner 实例。当 DirectByteBuffer 对象被 GC 回收时,Cleaner 触发 clean() 方法,通过 Unsafe.freeMemory 释放堆外内存。
分配流程时序:
调用方 -> allocateDirect(N) -> new DirectByteBuffer(cap)
-> Unsafe.allocateMemory(N) // 系统调用 mmap/malloc,RSS 立刻涨 N
-> new Cleaner(Deallocator) // 注册 GC 回调,持有一个虚引用
-> 返回 DirectByteBuffer 对象 // 堆上只占几十字节
回收流程时序:
GC 发现 DirectByteBuffer 对象不可达 -> Cleaner 入队到 ReferenceQueue
-> ReferenceHandler 线程处理 -> Cleaner.clean()
-> Unsafe.freeMemory(address) // 归还堆外内存,RSS 下降
-> 结束// 分配 1GB 堆外内存
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
// 此时进程 RSS 暴涨 1GB,但堆内只多了几十字节关键问题:如果 DirectByteBuffer 对象本身没有被 GC,堆外内存就永远不会释放。而年轻代 GC 非常频繁,晋升到老年代的 DirectByteBuffer 对象只有 Old GC 或 Full GC 才能回收。在高吞吐场景下,堆外内存分配速度 > 回收速度,就会导致堆外内存持续增长,触发 OutOfMemoryError: Direct buffer memory。
面试官追问:为什么 DirectByteBuffer 分配会触发 Full GC?
源码 java.nio.Bits.reserveMemory() 中有一段逻辑:当已有分配量超过 -XX:MaxDirectMemorySize 的 1/2 时,会主动调用 System.gc() 触发 Full GC,试图回收那些不再可达的 DirectByteBuffer 对象。但 -XX:+DisableExplicitGC 被启用时(生产环境常见配置),这个 System.gc() 就是个空调用,堆外内存泄漏会加速恶化。所以排查时一定要确认 DisableExplicitGC 是否开启。
// java.nio.Bits 中 reserveMemory 的简化逻辑:
if (totalCapacity > maxMemory / 2) {
System.gc(); // 试图触发 Full GC 回收 DirectByteBuffer
// 如果 -XX:+DisableExplicitGC 开启,这一行毫无作用
Thread.sleep(100);
// 再次检查,如果还超限就抛 OOM
}一个真实案例:某 Kafka Consumer 服务,每条消息处理完执行 ByteBuffer.allocateDirect(256KB) 做临时解码,但漏了 clean() 调用。100 TPS 跑 1 小时 = 256KB × 100 × 3600 ≈ 90GB 堆外分配。虽然大多数被 YGC 回收了,但偶尔有晋升到老年代的没被 Full GC 触发,RSS 从 3GB 涨到 7GB 后进程被 Kill。最终排查:加 -XX:MaxDirectMemorySize=512m 限制上限,同时修复了代码中的释放逻辑。
对比表:堆内 vs 堆外内存
| 维度 | 堆内内存 (Heap) | 堆外内存 (Direct Memory) |
|---|---|---|
| 分配方式 | new Object() | ByteBuffer.allocateDirect() / Unsafe.allocateMemory() |
| 管理方式 | GC 自动管理 | Cleaner 触发回收,依赖 DirectByteBuffer 引用的可达性 |
| 可见性 | jmap -heap / VisualVM | NMT / pmap / gdb |
| 分配速度 | 指针碰撞,ns 级别 | 系统调用 mmap/malloc,μs 级别 |
| 零拷贝 | 需先拷贝到堆外 | 直接操作,省去一次 copy |
| GC 影响 | 大对象触发 STW | 堆外对象本身不影响 GC 时长,但 Cleaner 入队有延迟 |
| 典型泄漏原因 | 忘记断开引用(集合类) | 忘记 clean() / Netty refCnt 未归零 / glibc arena 不释放 |
| 限制手段 | -Xmx | -XX:MaxDirectMemorySize |
| 默认上限 | 堆可用内存 ≈ -Xmx 的 100% | 等于 -Xmx(未显式指定时) |
面试官追问:-XX:MaxDirectMemorySize 默认值是多少?
很多人以为是无限。实际上默认等于 Runtime.getRuntime().maxMemory() 即 -Xmx 的值。如果 -Xmx 是 4GB,MaxDirectMemorySize 默认也是 4GB。这意味着堆 + 堆外总和可能达到 8GB,加上 JVM 本身开销,RSS 轻松超过 10GB。很多线上 OOM 不是因为堆溢出,而是堆外 + 堆超过了物理内存。
Native Memory Tracking(NMT)定位内存分布
NMT 是 JVM 内置的本地内存追踪工具,可以精确呈现堆外内存各区域的用量。开启方式在 JVM 参数中:
-XX:NativeMemoryTracking=summary|detail
-XX:+UnlockDiagnosticVMOptions运行时通过 jcmd 查看:
# 启用 NMT 后,查看内存汇总
jcmd <pid> VM.native_memory summary
# 输出示例(截取关键部分):
# Native Memory Tracking:
# Total: reserved=8192MB, committed=6144MB
# - Java Heap (reserved=2048MB, committed=2048MB)
# - Class (reserved=256MB, committed=128MB)
# - Thread (reserved=512MB, committed=512MB)
# - Code (reserved=128MB, committed=64MB)
# - GC (reserved=256MB, committed=256MB)
# - Compiler (reserved=4MB, committed=4MB)
# - Internal (reserved=64MB, committed=64MB)
# - Other (reserved=1024MB, committed=1024MB)
# - Symbol (reserved=32MB, committed=32MB)
# - Native Memory Tracking (reserved=16MB, committed=16MB)
# - Arena (reserved=256MB, committed=256MB)
# - Logging (reserved=4MB, committed=4MB)
# - Arguments (reserved=2MB, committed=2MB)
# - Module (reserved=2MB, committed=2MB)
# - Unknown (reserved=512MB, committed=512MB)NMT 快速判断法:重点关注 Internal、Other、Unknown 三个区域。如果它们加起来超过堆内存的 50%,基本可以确认堆外内存异常。比如上面例子中这三项合计 1.6GB,而堆只有 2GB,已经超过 50%,属于高危信号。
各区域含义:
| NMT 区域 | 包含内容 | 常见异常值 |
|---|---|---|
| Java Heap | 堆内对象 | 正常 ≈ -Xmx |
| Class | 元空间、类加载信息 | 动态代理/反射多时偏高 |
| Thread | 线程栈空间 | 线程数 × 默认栈大小(1MB) |
| Code | JIT 编译后的代码缓存 | 长期运行后通常 128-256MB |
| GC | GC 内部数据结构 | G1 的 RememberedSet 可能占几十 MB |
| Internal | 命令行解析、NIO 直接缓冲区等 | 堆外内存泄漏时偏高 |
| Other | 无法归类的内存 | 堆外内存泄漏时偏高 |
| Arena | glibc malloc arena 元数据 | 高并发线程多时偏高 |
| Unknown | 无法归类的内存 | 堆外内存泄漏时偏高 |
NMT 的 detail 模式:summary 只显示总量,detail 还能给出每个调用栈的分配记录:
jcmd <pid> VM.native_memory detail
# 输出会按调用栈分组显示分配量,比如:
# [0x7f...] Unsafe_AllocateMemory + 0xXX
# -> java.nio.DirectByteBuffer.<init> + 0xXX
# -> com.example.MyService.process + 0xXXdetail 模式性能开销约 5-10%,建议诊断时临时开启,排完就关掉。生产环境建议常态化开启 summary 模式(开销低于 1%)。
用 pmap 和 gdb 暴力定位
当 NMT 不方便开启(比如已经出问题的老进程,或者 NMT 在生产环境默认关闭),可以用系统工具直接从 OS 层面看:
# 查看进程虚拟内存分布,按大小排序
pmap -x <pid> | sort -k 3 -n -r | head -20
# 重点关注 anonymous 大块内存(标注 [anon] 的)
# 通过地址范围可以推测是 mmap 还是 malloc 分配的pmap 实战技巧:
- 连续多个 64MB 大小的
[anon]映射 → glibc arena 的典型特征 - 4KB 对齐的小块 → 大概率是 malloc 分配的字节数组
- 出现 1GB 以上单块映射 → 基本可以确定是 DirectByteBuffer 分配
- 地址范围连续且大小一致 → 大概率是线程栈(每个线程默认 1MB)
更深入的手段是用 gdb 挂载进程,查看 Unsafe 的分配情况:
gdb -p <pid> -batch -ex "info threads" -ex "thread apply all bt" > stack.txt
# 然后搜索 "Unsafe_AllocateMemory" 定位调用栈Netty 堆外内存泄漏的典型场景
Netty 是 DirectByteBuffer 的最大用户。泄漏最常见的原因:
- ByteBuf 未释放:
ByteBuf引用计数泄漏,refCnt未被减到 0,导致无法归还池化内存。 - 池化泄漏:Netty 默认使用
PooledByteBufAllocator,内存从 PoolArena 分配。如果写代码时漏了release(),内存会卡在池里不归还给 OS。 - 系统调用链断开:入站消息被传给业务线程后,Netty 的 EventLoop 线程不再持有引用,但业务线程处理完后忘记
release()。
// 正确用法:确保在 finally 中释放
ChannelHandlerContext ctx;
ByteBuf buf = ctx.alloc().buffer();
try {
// 处理 buf
ctx.writeAndFlush(buf);
} finally {
// 如果 writeAndFlush 已经接管了释放,这里不要重复 release
// ReferenceCountUtil.release(buf);
}
// 排查利器:开启 Netty 的泄漏检测
// -Dio.netty.leakDetectionLevel=paranoid
// 会在日志中输出泄漏时的调用栈Netty 泄漏检测四个级别:
| 级别 | 参数 | 采样率 | 性能影响 | 用途 |
|---|---|---|---|---|
| DISABLED | -Dio.netty.leakDetectionLevel=disabled | 0% | 无 | 生产稳定期 |
| SIMPLE | =simple | 1% | 极低 | 生产常开,发现泄漏调用栈 |
| ADVANCED | =advanced | 1% | 低 | 临时诊断,给出具体泄漏点 |
| PARANOID | =paranoid | 100% | 高 | 测试环境定位,每条 ByteBuf 都追踪 |
踩坑实录:某次线上事故,gRPC 服务每 30 分钟 RSS 涨 500MB,重启后恢复。jmap 看堆只有 2GB 正常,但 pmap 发现大量 64MB 匿名映射。先怀疑 glibc arena,替换 jemalloc 后 RSS 涨速下降 60%,但仍在涨。最终开启 NMT + Netty PARANOID,发现是 gRPC 的 MessageReadListener 中一个拦截器在异常路径漏调了 release()。修复后 RSS 稳定在 4GB 以下,再没触发过 OOM。教训:堆外内存泄漏往往是多重原因叠加,glibc arena + 代码泄漏同时存在,解决一个不够,全排干净才稳。
glibc arena 与 jemalloc 替换
有时候堆外内存不是程序泄漏,而是 glibc 的 malloc 行为导致的。glibc 为了多线程性能,会为每个线程预分配 arena(默认 64 核系统下最多 64 个 arena × 64 线程竞争),每个 arena 初始 mmap 大块内存但不释放回 OS,导致进程 RSS 看起来虚高。
glibc arena 的详细机制:
- 每个 arena 是一块连续内存,通过 mmap 分配,默认 64MB
- 64 核机器上 glibc 最多创建 64 个 arena(
2 × CPU核数) - 多个线程争用同一个 arena 时,会触发锁竞争,glibc 会创建新的 arena 来缓解
- 线程退出后,arena 内存不会主动归还 OS,而是等下一个线程复用
- 最终效果:空载时 RSS 也可能有 2-4GB 的 glibc arena 残留
# 查看 glibc arena 数量
ldd <java_binary> | grep libc
# 检查 /proc/<pid>/smaps 中是否有大量 64MB 的匿名映射
# 替换为 jemalloc(线程独立缓存,内存碎片低,释放更积极)
# 下载 jemalloc 并 LD_PRELOAD
export LD_PRELOAD=/path/to/libjemalloc.sojemalloc 替换前后对比数据(来自某 16 核 32GB 生产实例):
| 指标 | glibc malloc | jemalloc | 变化 |
|---|---|---|---|
| RSS (稳态) | 7.2GB | 4.8GB | -33% |
| 堆外内存 (NMT Other) | 1.8GB | 0.8GB | -55% |
| 进程启动后 RSS 峰值 | 12.5GB | 6.8GB | -45% |
| Full GC 触发频率 | 每 2 小时一次 | 每 8 小时一次 | -75% |
| OOM 次数/月 | 3-5 次 | 0 次 | 100% 降低 |
美团、Netflix 等大厂在 Java 服务中广泛使用 jemalloc 替代 glibc malloc,典型效果是堆外内存减少 30%-50%,RSS 更稳定。但注意:jemalloc 不是万能的,如果代码本身有 DirectByteBuffer 泄漏,它只是让增长曲线更平缓,不会根除问题。
面试官追问:jemalloc 和 glibc malloc 的底层区别是什么?
| 维度 | glibc malloc | jemalloc |
|---|---|---|
| 内存分配策略 | 首次适配(First Fit) | 最佳适配(Best Fit) + 缓存 |
| 线程隔离 | 多 arena 竞争,锁粒度粗 | 每个线程独立缓存 (tcache) |
| 碎片控制 | 差,长时间运行碎片严重 | 好,通过 size class 减少内碎片 |
| 内存归还 | 不主动归还 OS,复用策略保守 | 更积极,可以配置 dirty_decay_ms |
| 大块分配 | mmap 直接分配 | 通过 extent 管理,可复用 |
| 适用场景 | 通用 | 高并发、多线程、长运行服务 |
堆外内存泄漏的其它隐藏来源
除了 DirectByteBuffer 和 Netty,还有几个常见但容易被忽略的堆外内存消耗点:
1. 线程栈:每个 Java 线程默认栈大小 1MB(-Xss 可调)。如果线程数 1000,线程栈就占 1GB。使用虚拟线程时,栈大小可降到 2KB 级别,大幅减少堆外内存开销。
2. JIT 代码缓存:-XX:ReservedCodeCacheSize 默认 240MB,配合 Graal 编译器或大量动态代理时可能撑满。
3. Metaspace:-XX:MaxMetaspaceSize 如果不限制,类加载过多的应用(如动态代理、CGLIB 代理)会一直涨到物理内存上限。
4. 直接内存映射(MappedByteBuffer):FileChannel.map() 分配的 MappedByteBuffer 在 GC 时不会自动释放,只有调用 Cleaner.clean() 或进程退出时才会归还。如果不手动释放,会持续累积。
5. ZGC/Shenandoah 的染色指针:ZGC 的染色指针需要额外的内存映射,通常额外占用几百 MB。
实践清单:堆外内存排查步骤
面试官考这道题,按下面顺序答,一步不跳:
- 确认现象:
top看 RSS,jmap -heap看堆,如果 RSS 远大于堆,走下一步。 - NMT 描轮廓:
jcmd <pid> VM.native_memory summary,看Internal + Other + Unknown占比。 - pmap 找大块:
pmap -x <pid> | sort -k 3 -nr | head -20,区分 glibc arena (64MB 重复) 和 DirectByteBuffer (单块巨量)。 - Netty 泄漏检测:改
-Dio.netty.leakDetectionLevel=paranoid,重启复现,看日志堆栈。 - gdb 深挖:如果 NMT 和 Netty 都找不到,gdb 挂载看
Unsafe_AllocateMemory调用栈。 - glibc 替换验证:替换 jemalloc,观察 RSS 变化。如果降了但还在涨,大概率代码有泄漏,回步骤 2-4。
- 加限制熔断:
-XX:MaxDirectMemorySize设上限,防止泄漏拖垮整台机器。
面试官可能会追问的细节:
- 问:
System.gc()在堆外内存排查中有什么用?→ 答:NMT 的reserveMemory中会调用System.gc()试图回收 DirectByteBuffer,但如果-XX:+DisableExplicitGC开启就无效了。 - 问:堆外内存 OOM 和堆内 OOM 的日志有什么区别?→ 答:堆内 OOM 有 Java 异常堆栈,堆外 OOM 通常被 OS 的 OOM Killer 直接杀进程,只有系统日志里有
Out of memory: Kill process。 - 问:K8s 容器里怎么排查堆外内存?→ 答:使用
cadvisor看容器级 RSS,配合-XX:+ExitOnOutOfMemoryError让进程在 OOM 时自动退出,配合-XX:+HeapDumpOnOutOfMemoryError来 dump,但堆外泄漏的 dump 里看不到堆外内存。
总结
堆外内存排查的核心思路是分层定位:先确认是 JVM 管理的内存泄漏还是 OS 层面 malloc 行为,再分别用 NMT 描轮廓、用 pmap 找大块、用 gdb 深挖调用栈。生产环境建议常态化开启 -XX:NativeMemoryTracking=summary(开销极低),并配合 -Dio.netty.leakDetectionLevel=simple 捕获 Netty 泄漏。如果最终确认是 glibc arena 问题,替换 jemalloc 是性价比最高的解法。
一句话记住:堆外内存泄漏 = 堆正常但 RSS 涨 → NMT 描轮廓 → pmap 找大块 → Netty 检测 → gdb 深挖 → jemalloc 兜底。
参考
参考:JVM 源码
src/hotspot/share/services/nmt*;Netty 官方文档 Reference counted objects;美团技术博客《Java 堆外内存排查》;阿里的arthas的vmtool命令也可辅助排查。