一、非堆内存泄漏:那些看不见的“内存黑洞”
说起内存泄漏,很多人第一反应就是堆内存。但说实话,我在实际项目中踩过最深的坑,反而是非堆内存。元空间、直接内存、JNI本地内存、线程栈——这些区域一旦泄漏,排查难度比堆内存高出一个数量级。
为什么?因为堆内存有成熟的GC工具,有可视化的监控面板。而非堆内存,很多时候就像个黑盒。你看着内存占用一点点往上涨,但就是不知道谁在吃。嗯,今天我就把这四种非堆内存泄漏的套路,一个一个拆开来讲。
核心观点:非堆内存泄漏的排查,本质上是“资源生命周期管理”的问题。谁申请,谁释放,这个原则一旦被打破,泄漏就来了。
1.1 元空间(Metaspace)泄漏
元空间,说白了就是用来存类的元数据的地方。Java 8之后,永久代被干掉了,换成了元空间。好处是默认不受JVM堆大小限制,坏处是——你可能会不知不觉把系统内存吃光。
泄漏的典型场景:
- 动态生成类(CGLIB、ASM、Javassist)但不卸载
- 频繁的类加载/卸载,但类加载器泄漏
- Groovy、Scala等动态语言在JVM上运行时
我记得有一次,一个同事用CGLIB给每个请求动态生成代理类。上线后不到半天,服务器直接OOM。查了半天,发现是类加载器被业务代码强引用着,GC根本回收不了。
避坑指南:我曾经遇到过一个案例,Spring的AOP代理类在热部署时不断生成,每次部署都产生新的类加载器。旧的类加载器因为被ThreadLocal引用,永远无法回收。元空间就这样被撑爆了。
排查方法:
- 使用
-XX:MaxMetaspaceSize设置上限,防止拖垮系统 - 通过
jstat -gcmetacapacity监控元空间使用量 - 用
jmap -clstats查看类加载器信息 - 开启
-XX:+TraceClassLoading -XX:+TraceClassUnloading跟踪类加载/卸载
# 查看元空间使用情况
jstat -gcmetacapacity <pid>
# 查看类加载器详情
jmap -clstats <pid>
# 开启类加载跟踪
-XX:+TraceClassLoading -XX:+TraceClassUnloading
1.2 直接内存(DirectBuffer)泄漏
直接内存,就是通过 ByteBuffer.allocateDirect() 分配的内存。它绕过了堆,直接操作操作系统内存。好处是减少了GC压力,坏处是——泄漏了连GC都救不了你。
你想想看,直接内存的回收依赖 Cleaner 机制。如果 DirectByteBuffer 对象没有被GC回收,或者Cleaner被意外清除了,这块内存就永远释放不了。
常见泄漏场景:
- Netty、Mina等NIO框架中,ByteBuf未正确释放
- 使用
FileChannel.map()映射文件后未unmap - 自定义的DirectBuffer池,归还逻辑有bug
个人经验:我习惯在Netty的ChannelHandler中,用 ReferenceCountUtil.release() 显式释放ByteBuf。别指望GC帮你收,直接内存的回收时机你控制不了。
排查手段:
- 监控
jinfo -flag MaxDirectMemorySize查看上限 - 使用
jcmd <pid> VM.native_memory查看Native内存分布 - 通过
pmap -x <pid>查看进程内存映射 - 在代码中埋点,统计DirectBuffer的分配和释放
// 显式释放DirectBuffer
ByteBuf buf = ...;
try {
// 使用buf
} finally {
ReferenceCountUtil.release(buf);
}
1.3 JNI本地内存泄漏
JNI这块,说实话,是很多Java工程师的盲区。大家习惯了Java的自动内存管理,到了C/C++的世界,malloc了不free,new了不delete,内存泄漏就来了。
而且JNI泄漏有个特点:它不会直接报OOM,而是表现为系统内存缓慢增长。你查堆内存、查元空间、查直接内存,都没问题。但服务器就是越来越慢,最后卡死。
泄漏根源:
- JNI方法中malloc/calloc/realloc未对应free
- NewGlobalRef创建了全局引用,但未DeleteGlobalRef
- JNI创建的线程,未正确分离(DetachCurrentThread)
我曾经...:遇到过一个图像处理服务,用JNI调用OpenCV。每次处理图片都malloc一块内存,但异常分支忘了free。线上跑了三天,内存从2G涨到12G,直接打挂了整个宿主机。
排查工具:
- Valgrind:检测C/C++内存泄漏的神器
- AddressSanitizer:编译时开启,运行时检测
- JNI的
CheckJNI模式:-Xcheck:jni - 手动在JNI代码中埋点,统计分配/释放次数
// JNI中正确的内存管理
JNIEXPORT void JNICALL Java_Example_process(JNIEnv *env, jobject obj) {
char *buffer = (char *)malloc(1024);
if (buffer == NULL) {
// 处理分配失败
return;
}
// 使用buffer...
// 记得释放
free(buffer);
buffer = NULL;
}
1.4 线程栈内存泄漏
线程栈,每个线程独享的内存区域。默认大小通常是1MB(Linux x64)。你想想看,如果创建了1000个线程,光栈内存就吃掉1GB。而且线程栈一旦分配,在线程死亡前是不会释放的。
泄漏模式:
- 线程池无限增长,没有最大线程数限制
- ThreadLocal持有大对象,线程长期存活
- 线程内部有死循环或阻塞,永不结束
我个人习惯在创建线程池时,一定要设置 maximumPoolSize 和合理的拒绝策略。别让线程数失控,那是灾难的开始。
关键指标:每个线程约占用1MB栈空间(可调)。1000个线程 = 1GB内存。这还没算线程内部的堆分配。
排查方法:
jstack <pid>查看线程数量和状态top -H -p <pid>查看线程CPU和内存- 使用
-Xss调整栈大小,但别调太小,容易StackOverflow - 监控
/proc/<pid>/status中的Threads字段
# 查看进程的线程数
cat /proc/<pid>/status | grep Threads
# 查看线程栈大小
java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
知识体系总览
下面这张图,是我梳理的非堆内存泄漏的完整知识体系。你可以把它当作排查时的路线图。
总结一下:非堆内存泄漏的排查,没有银弹。你需要对每种泄漏模式有清晰的认识,然后针对性地使用工具。我个人建议,在系统设计阶段就把监控埋好,别等线上出问题了再手忙脚乱地查。