JVM堆内平稳,内存却持续飙升?一文搞定堆外内存泄漏排查与实战 前言 在Java应用的内存问题排查中,我们经常会遇到一种”诡异”的现象:通过 jstat 等工具查看,JVM的堆内存(Heap)和元空间(Metaspace)都稳稳当当,没有明显增长。但是,用 top 命令一看,整个进程的 常驻内存(RES) 却在持续攀升,最终导致服务器内存耗尽,应用被操作系统”Out Of Memory”杀死。
这背后,十有八九就是 堆外内存(Off-Heap Memory) 泄漏在作祟。本文将深入剖析堆外内存泄漏的常见原因、系统化的排查方法论、以及从代码层面的解决方案,并附上一个 Netty网关的完整实战案例 。
一、什么是堆外内存?为什么需要关注它? 堆外内存,是Java进程在JVM管理的堆内存之外分配的内存,由操作系统直接管理,JVM常规监控工具(如 jstat 、 VisualVM 的堆视图)无法覆盖。
常见堆外内存区域:
内存类型
分配方式
说明
直接内存(Direct Buffer)
ByteBuffer.allocateDirect()
NIO常用,绕过JVM堆直接分配
线程栈内存
线程创建时分配
每个线程独有的栈空间(默认约1MB)
JNI / Native Code内存
JNI调用C/C++库
本地代码分配的内存
内存映射文件
FileChannel.map()
大文件读写时使用
JVM内部结构
JVM自动分配
G1的Region Table、JIT编译缓存等
当应用内存持续增长而堆内和元空间没有变化时, 问题大概率出在上述区域中 。
二、六大”元凶”:堆外内存泄漏常见原因
原因分类
典型场景
核心风险点
1. Direct Buffer 泄漏
Netty、Kafka、RocketMQ等高性能网络框架
未正确释放 ByteBuffer(未调用 cleaner 或 close() )
2. JNI / Native Code 泄漏
图像处理库、Oracle OCI驱动、自定义本地扩展
C/C++代码中 malloc 后没有 free
3. 线程栈内存增长
线程池配置不当、线程未回收
线程数无限增长,每个线程占用约1MB
4. 内存映射文件未释放
大文件读写、日志处理
未关闭 FileChannel 或 MappedByteBuffer
5. JVM内部结构增长
超大堆内存(>32G)、频繁Full GC
G1 Region Table膨胀、GC线程栈增长
6. 第三方库内存泄漏
Netty的 PooledByteBufAllocator 、TensorFlow会话
未调用 release() 、未关闭Session
三、系统化排查工具箱 第一步:现象确认与复现 1 2 3 4 5 6 7 top -p <pid> jstat -gcutil <pid> 1000
第二步:启用NMT(Native Memory Tracking) JVM官方提供的堆外内存最强分析工具,生产环境建议开启(性能损耗约5%-10%)。
1 2 3 4 5 6 7 8 java -XX:NativeMemoryTracking=detail -jar your_app.jar jcmd <pid> VM.native_memory summary jcmd <pid> VM.native_memory detail
重点关注 : Internal 、 Direct 、 Thread 区域是否异常增长。
第三步:检查Direct Buffer 1 jcmd <pid> VM.directbuffer
第四步:操作系统级别分析 1 2 3 4 5 pmap -x <pid> | sort -k3 -n -r | head -20 valgrind --tool=massif java -jar your_app.jar
第五步:线程栈检查 1 2 3 jstack <pid> | grep "native_thread" -c ps -eLf | grep java | wc -l
第六步:Profiling工具辅助
VisualVM / JProfiler :监控堆外内存和线程数
Netty专属 :开启内存泄漏检测
1 System.setProperty("io.netty.leakDetection.level" , "ADVANCED" );
四、实战案例:Netty网关的DirectBuffer泄漏 案例背景 某基于 Netty 的统一接入网关,在大促压测中出现内存持续增长。网关负责接收客户端HTTP请求,通过内部RPC调用后端服务并返回结果。
现象
监控告警 :压测2小时后,ECS内存使用率从20%飙升至85%
JVM层面 : jstat -gcutil 显示堆内存和元空间 非常平稳 ,GC正常
业务表现 :少量请求报错 IOException: Direct buffer memory ,部分连接意外关闭
排查过程 1. 初步确认
2. 启用NMT分析 1 2 jcmd <pid> VM.native_memory summary
输出显示:
1 2 Direct (reserved=1024MB, committed=850MB) - ByteBuffer.allocateDirect (reserved=850MB, committed=850MB)
配置的 -XX:MaxDirectMemorySize=512MB 已被严重超出,问题聚焦在直接内存。
3. 检查DirectBuffer详情 1 jcmd <pid> VM.directbuffer
发现大量 DirectByteBuffer 实例未被回收。
4. 代码审查定位 在自定义编码器中发现了问题代码:
1 2 3 4 ByteBuf out = ctx.alloc().directBuffer();out.writeBytes(...); ctx.writeAndFlush(out);
根本原因 : writeAndFlush 过程中发生异常(如客户端连接突然断开), ByteBuf 未被释放。压测后期大量连接超时断开,加剧了泄漏。
解决方案 1. 开启Netty内存泄漏检测 1 ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
日志精确定位到泄漏代码位置。
2. 修复代码,确保资源释放 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ByteBuf out = ctx.alloc().directBuffer();try { out.writeBytes(...); ctx.writeAndFlush(out).addListener(future -> { if (!future.isSuccess()) { out.release(); } }); } catch (Exception e) { out.release(); throw e; } byte [] data = ...;ByteBuf out = Unpooled.wrappedBuffer(data); ctx.writeAndFlush(out);
3. 优化配置
设置 -Dio.netty.allocator.type=unpooled 作为兜底
配置 -XX:MaxDirectMemorySize=512M
添加 -XX:+ExplicitGCInvokesConcurrent 辅助回收
最终效果
同样压测强度下,进程内存 稳定在2GB左右 ,不再增长
jcmd VM.directbuffer 显示未回收的 DirectByteBuffer 数量极低
网关稳定支撑大促活动,未再出现内存问题
五、解决方案速查表
原因
排查方法
解决方案
Direct Buffer泄漏
NMT、jcmd VM.directbuffer
Cleaner释放、池化管理、Netty leakDetection
线程栈内存增长
jstack、top
合理设置线程池上限、调整-Xss
JNI/Native泄漏
valgrind、pmap
修复本地代码的malloc/free不匹配
内存映射文件未释放
pmap、代码审查
显式close()、try-with-resources
第三方库泄漏
Profiling、升级版本
升级依赖、检查资源释放
六、预防监控体系 关键监控指标(Prometheus格式) 1 2 3 4 5 6 7 8 # 进程常驻内存 process_resident_memory_bytes{application="myapp"} # 直接内存最大值 jvm_memory_direct_bytes_max{application="myapp"} # 线程总数 jvm_threads_live_threads{application="myapp"}
熔断机制(高级) 1 2 3 if (NativeMemory.getCommitted() > threshold) { throw new MemoryLimitExceededException ("Off-heap memory exceeded limit" ); }
工具链速查表
工具
用途
NMT
JVM级别堆外内存追踪(首选)
pmap
进程内存映射分析
jcmd
DirectBuffer、线程、NMT查询
valgrind
本地代码内存泄漏检测
Netty LeakDetection
Netty框架ByteBuf泄漏检测
总结 堆外内存泄漏是Java生产环境中 最隐蔽、最棘手 的内存问题之一。掌握以下核心要点,你就能从容应对:
核心排查思路 1 2 3 4 5 6 7 现象确认(top + jstat) ↓ NMT快速定位(jcmd VM.native_memory) ↓ 专项深入(DirectBuffer / 线程栈 / pmap) ↓ 代码修复 + 监控验证
三个关键教训
NMT是首选利器 :遇到堆外内存问题,第一时间开启NMT,它能告诉你内存到底去哪了。
框架资源管理要敬畏 :使用Netty、DirectBuffer、JNI等涉及显式资源管理的技术时,必须确保 所有路径都有释放逻辑 。
预防胜于排查 :在开发/压测环境开启Netty的 leakDetection 和NMT,把问题消灭在上线之前。
面试一句话总结
“堆外内存泄漏排查,我遵循’ 现象确认 → NMT定位 → 专项工具深入 → 代码修复 ‘四步法。以Netty网关为例,通过NMT发现DirectBuffer异常,结合Netty的泄漏检测工具定位到 ByteBuf 在异常路径未释放,用 try-finally 和 ChannelFutureListener 确保资源正确回收,最终内存稳定。”