一、非堆内存泄漏:那些看不见的“内存黑洞”

说起内存泄漏,很多人第一反应就是堆内存。但说实话,我在实际项目中踩过最深的坑,反而是非堆内存。元空间、直接内存、JNI本地内存、线程栈——这些区域一旦泄漏,排查难度比堆内存高出一个数量级。

为什么?因为堆内存有成熟的GC工具,有可视化的监控面板。而非堆内存,很多时候就像个黑盒。你看着内存占用一点点往上涨,但就是不知道谁在吃。嗯,今天我就把这四种非堆内存泄漏的套路,一个一个拆开来讲。

核心观点:非堆内存泄漏的排查,本质上是“资源生命周期管理”的问题。谁申请,谁释放,这个原则一旦被打破,泄漏就来了。

1.1 元空间(Metaspace)泄漏

元空间,说白了就是用来存类的元数据的地方。Java 8之后,永久代被干掉了,换成了元空间。好处是默认不受JVM堆大小限制,坏处是——你可能会不知不觉把系统内存吃光。

泄漏的典型场景:

  • 动态生成类(CGLIB、ASM、Javassist)但不卸载
  • 频繁的类加载/卸载,但类加载器泄漏
  • Groovy、Scala等动态语言在JVM上运行时

我记得有一次,一个同事用CGLIB给每个请求动态生成代理类。上线后不到半天,服务器直接OOM。查了半天,发现是类加载器被业务代码强引用着,GC根本回收不了。

避坑指南:我曾经遇到过一个案例,Spring的AOP代理类在热部署时不断生成,每次部署都产生新的类加载器。旧的类加载器因为被ThreadLocal引用,永远无法回收。元空间就这样被撑爆了。

排查方法:

  1. 使用 -XX:MaxMetaspaceSize 设置上限,防止拖垮系统
  2. 通过 jstat -gcmetacapacity 监控元空间使用量
  3. 用 jmap -clstats 查看类加载器信息
  4. 开启 -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帮你收,直接内存的回收时机你控制不了。

排查手段:

  1. 监控 jinfo -flag MaxDirectMemorySize 查看上限
  2. 使用 jcmd <pid> VM.native_memory 查看Native内存分布
  3. 通过 pmap -x <pid> 查看进程内存映射
  4. 在代码中埋点,统计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,直接打挂了整个宿主机。

排查工具:

  1. Valgrind:检测C/C++内存泄漏的神器
  2. AddressSanitizer:编译时开启,运行时检测
  3. JNI的 CheckJNI 模式:-Xcheck:jni
  4. 手动在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内存。这还没算线程内部的堆分配。

排查方法:

  1. jstack <pid> 查看线程数量和状态
  2. top -H -p <pid> 查看线程CPU和内存
  3. 使用 -Xss 调整栈大小,但别调太小,容易StackOverflow
  4. 监控 /proc/<pid>/status 中的 Threads 字段
# 查看进程的线程数
cat /proc/<pid>/status | grep Threads

# 查看线程栈大小
java -XX:+PrintFlagsFinal -version | grep ThreadStackSize

知识体系总览

下面这张图,是我梳理的非堆内存泄漏的完整知识体系。你可以把它当作排查时的路线图。

非堆内存泄漏知识体系 非堆内存泄漏 元空间泄漏 直接内存泄漏 JNI本地内存泄漏 线程栈泄漏 动态生成类未卸载 类加载器泄漏 jstat / jmap 监控 NIO ByteBuf未释放 FileChannel未unmap jcmd VM.native_memory malloc/free不配对 全局引用未删除 Valgrind / ASan检测 线程池无限增长 ThreadLocal大对象 jstack / top -H 排查核心原则 谁申请,谁释放 | 显式管理生命周期 | 监控先行

总结一下:非堆内存泄漏的排查,没有银弹。你需要对每种泄漏模式有清晰的认识,然后针对性地使用工具。我个人建议,在系统设计阶段就把监控埋好,别等线上出问题了再手忙脚乱地查。

可可精品资料库