Java NIO Buffer:DirectByteBuffer堆外内存回收的Cleaner机制详解

0 阅读9分钟

1. 为什么堆外内存回收这么难?

在深入 Cleaner 之前,我们先得明白一个核心矛盾:Java 对象在堆里,但它引用的内存却在堆外。

当你创建一个 DirectByteBuffer 时,JVM 堆里其实只是放了一个很小的壳子(对象头 + 几个字段),而真正存储数据的内存块,是调用 Unsafe.allocateMemory 向操作系统申请的。

这就导致了一个尴尬的局面:

  1. 壳子很小:可能只有几十字节,根本触发不了 Minor GC,更别提 Full GC 了。
  2. 内存很大:壳子背后可能挂着几百 MB 的堆外内存。
  3. 回收滞后:只要这个壳子没被回收,堆外内存就一直占着。只有当这个壳子被 GC 判定为“垃圾”并回收时,JVM 才有机会去清理背后的堆外内存。

这就好比你手里拿了一张只有 1 克的纸条(引用),但这张纸条却锁定了一艘 10 万吨的巨轮(堆外内存)。你觉得纸条不占地儿,随手乱扔,结果港口(系统内存)很快就被巨轮塞满了。


2. 核心机制解密:Cleaner 与 PhantomReference

JDK 为了解决这个问题,引入了 sun.misc.Cleaner(在 Java 9+ 迁移到了 jdk.internal.ref.Cleaner,但在 API 层面逻辑一致,本文以经典机制为主进行讲解)。

Cleaner 本质上是一个 虚引用(PhantomReference)

2.1 什么是虚引用?

Java 的四种引用(强、软、弱、虚)里,虚引用是最神秘的。你通过虚引用根本拿不到对象实例。它的唯一作用就是:当对象被 GC 回收时,系统会把这个虚引用加入到一个引用队列(ReferenceQueue)中,给你一个“收尸”的通知。

2.2 DirectByteBuffer 的构造逻辑

咱们看一段简化的 JDK 源码逻辑(基于 Java 8⁄11 逻辑抽象):

// DirectByteBuffer 构造函数片段
DirectByteBuffer(int cap) { 
    // 1. 申请堆外内存
    long base = unsafe.allocateMemory(cap);
    
    // 2. 初始化内存
    unsafe.setMemory(base, cap, (byte) 0);
    
    // 3. 创建 Cleaner
    // this 是 DirectByteBuffer 对象本身
    // Deallocator 是一个 Runnable,负责执行 unsafe.freeMemory
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
}

这里发生了什么?

  1. DirectByteBuffer 对象创建。
  2. Unsafe 分配物理内存。
  3. 创建一个 Cleaner 对象,它监控 DirectByteBuffer 对象。
  4. Cleaner 内部持有一个 Deallocator 任务,这个任务里存着内存地址 base

2.3 回收流程图解

当 DirectByteBuffer 对象(那个壳子)失去所有强引用后:

关键点:  这一切都是异步的!只有当 GC 发生,且 ReferenceHandler 线程处理到队列时,内存才会被释放。这就是为什么堆外内存容易 OOM 的根本原因——时间差


3. 实战:手动回收的“黑魔法”

既然自动回收不可控,在高性能场景下(比如 Netty 的 PooledByteBuf),我们往往需要主动回收

在 Java 9 之前,我们通常通过反射去调用 Cleaner.clean()。Java 9 之后,由于模块化限制,这段代码变得有点恶心,但原理没变。

案例 1:标准的 DirectByteBuffer 分配与隐式回收

这是最普通的用法,完全依赖 GC。

import java.nio.ByteBuffer;

public class ImplicitCleanDemo {
    public static void main(String[] args) throws InterruptedException {
        // 分配 1GB 堆外内存
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
        System.out.println("分配完成,此时堆外内存被占用...");
        
        // 模拟业务使用
        buffer.putInt(1);
        
        // 断开强引用
        buffer = null;
        
        // 这里如果不发生 GC,1GB 内存将一直占用
        // 显式调用 GC (生产环境不推荐,仅作演示)
        System.gc(); 
        
        // 给 ReferenceHandler 线程一点时间去处理
        Thread.sleep(1000);
        System.out.println("GC完成,堆外内存理论上已被释放");
    }
}

运行结果说明:  你可以通过操作系统的任务管理器或 top 命令观察进程内存。运行 System.gc() 后,内存占用会明显下降。如果不加 System.gc(),内存可能很久都不会降下来。

案例 2:Java 8 下的反射暴力回收(黑魔法)

这是 Netty 等框架早期的做法,绕过 GC,直接释放内存。

import java.nio.ByteBuffer;
import java.lang.reflect.Method;

public class DirectCleanJava8 {
    public static void clean(final ByteBuffer byteBuffer) {
        if (!byteBuffer.isDirect()) return;
        try {
            // 获取 cleaner() 方法
            Method cleanerMethod = byteBuffer.getClass().getMethod("cleaner");
            cleanerMethod.setAccessible(true);
            Object cleaner = cleanerMethod.invoke(byteBuffer);
            
            // 调用 clean() 方法
            Method cleanMethod = cleaner.getClass().getMethod("clean");
            cleanMethod.setAccessible(true);
            cleanMethod.invoke(cleaner);
            
            System.out.println("堆外内存已手动释放");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 500); // 500MB
        System.out.println("分配 500MB...");
        
        // 手动回收
        clean(buffer);
        
        // 此时 buffer 对象还在,但访问会报错
        try {
            buffer.putInt(1);
        } catch (Exception e) {
            System.out.println("访问已释放内存,抛出异常: " + e); // 通常是 SegFault 或 JVM Crash,或者 Java 层面的异常
        }
    }
}

运行结果说明:  调用 clean 后,内存立即释放。再次尝试写入数据,大概率会导致 JVM 崩溃(Segment Fault)或者抛出异常,因为底层地址已经无效了。这是极度危险的操作,必须确保没有任何线程再引用该 Buffer。

案例 3:Java 9+ 使用 Unsafe 这种更“邪门”的方式

Java 9 模块化封禁了 sun.misc.Cleaner 的访问。我们可以利用 Unsafe 的 invokeCleaner (如果可用) 或者沿用反射但需要 --add-opens 参数。这里演示一种基于 Unsafe 获取内部字段的思路(注意:Java 21 可能需要特定参数开启 Unsafe 访问)。

import sun.misc.Unsafe;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;

public class UnsafeCleanDemo {
    private static Unsafe unsafe;

    static {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            unsafe = (Unsafe) f.get(null);
        } catch (Exception e) { throw new RuntimeException(e); }
    }

    public static void free(ByteBuffer buffer) {
        if (!buffer.isDirect()) return;
        // Java 9+ 推荐使用 Unsafe.invokeCleaner(ByteBuffer)
        // 这里演示的是原理层面的调用,实际生产建议使用 Netty 的 PlatformDependent
        try {
             unsafe.invokeCleaner(buffer);
             System.out.println("通过 Unsafe.invokeCleaner 释放内存");
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100);
        free(buffer);
    }
}

运行结果说明:  使用 unsafe.invokeCleaner(buffer) 是 JDK 官方给出的后门,比反射更安全一点,它会检查是否已经被释放,避免重复释放。

案例 4:OOM 陷阱复现

这个例子展示了大量分配小对象导致堆外 OOM。

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class OOMTrap {
    // 禁用显式 GC: -XX:+DisableExplicitGC
    // 限制堆外内存: -XX:MaxDirectMemorySize=100M
    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                // 每次分配 10MB
                ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 10);
                // 这里故意不持有引用,看 GC 是否来得及回收
                // 但为了演示 OOM,我们稍微持有一下,或者分配速度极快
                // list.add(buffer); // 如果取消注释,必然 OOM
                
                // 即使不加入 list,如果分配速度 > GC 回收速度,也会 OOM
                i++;
                if (i % 5 == 0) System.out.println("已分配: " + (i * 10) + "MB");
            }
        } catch (OutOfMemoryError e) {
            System.out.println("发生 OOM! 总次数: " + i);
        }
    }
}

运行结果说明:  如果你添加了 -XX:+DisableExplicitGC 参数(很多生产环境脚本默认会加),System.gc() 失效,DirectByteBuffer 内部在申请不到内存时会尝试调用的 System.gc() 也会失效,导致直接抛出 OOM。

案例 5:Netty 的 NoCleaner 策略(邪修版本)

Netty 为了追求极致性能,默认情况下可能根本不用 JDK 的 Cleaner,而是自己管理内存引用计数。

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;

public class NettyStyle {
    public static void main(String[] args) {
        ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
        
        // Netty 的 Direct Buffer
        ByteBuf buf = allocator.directBuffer(1024);
        
        System.out.println("RefCnt: " + buf.refCnt()); // 默认为 1
        
        buf.writeBytes(new byte[10]);
        
        // 必须手动释放,否则内存泄露!
        // Netty 不依赖 JVM GC 来回收 Pooled Direct Buffer
        buf.release(); 
        
        System.out.println("RefCnt: " + buf.refCnt()); // 变为 0
        
        try {
            buf.writeByte(1); // 报错
        } catch (Exception e) {
            System.out.println("访问已释放内存: " + e.getClass().getSimpleName());
        }
    }
}

运行结果说明:  Netty 使用引用计数法。release() 减到 0 时,归还内存到内存池,而不是还给操作系统。这比 allocateDirect 快得多。

案例 6:监控堆外内存

如何知道生产环境堆外内存用了多少?

import java.lang.management.ManagementFactory;
import java.lang.management.BufferPoolMXBean;
import java.util.List;

public class MonitorDemo {
    public static void main(String[] args) {
        ByteBuffer.allocateDirect(1024 * 1024 * 50); // 50MB
        
        List<BufferPoolMXBean> pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
        for (BufferPoolMXBean pool : pools) {
            if (pool.getName().equals("direct")) {
                System.out.println("堆外内存已用: " + (pool.getMemoryUsed() / 1024 / 1024) + "MB");
                System.out.println("Buffer数量: " + pool.getCount());
            }
        }
    }
}

运行结果说明:  输出当前 JVM 进程中 DirectByteBuffer 占用的总内存和对象数量。这是做监控告警的基础。


4. 架构师思维拓展

4.1 为什么不要禁用 System.gc()?

很多运维脚本习惯加上 -XX:+DisableExplicitGC,认为 System.gc() 会导致 Full GC,影响性能。 这是一个巨大的坑!

在 NIO 场景下,JDK 的 Bits.reserveMemory() 方法在申请堆外内存不足时,会显式调用 System.gc()

// JDK Bits.java 伪代码
if (tryReserveMemory(size)) {
    return;
}
// 强制尝试回收
System.gc();
Thread.sleep(100);
if (tryReserveMemory(size)) {
    return;
}
throw new OutOfMemoryError("Direct buffer memory");

如果你禁用了 System.gc(),这行代码就变成了空操作。堆内有很多 DirectByteBuffer 的壳子没被回收,堆外内存爆满,JVM 却束手无策,直接 OOM。

最佳实践:  使用 -XX:+ExplicitGCInvokesConcurrent。这样既允许 System.gc() 触发回收,又是并发执行的(CMS/G1),不会 Stop The World 太久。

4.2 堆外内存泄露排查思路

  1. 现象:堆内存(Heap)充足,但进程 RES(物理内存)持续飙高,最后被 OS 的 OOM Killer 杀掉,或者抛出 OutOfMemoryError: Direct buffer memory
  2. 工具
  • jcmd <pid> VM.native_memory detail (需要开启 NMT)。

  • Arthas: memory 命令查看 direct 区。

  • BTrace/Arthas 拦截 ByteBuffer.allocateDirect,看是谁在疯狂分配。

  1. 常见疑犯
  • Netty ByteBuf 没有成对 release()

  • 使用了第三方 NIO 框架,异常处理流程中漏掉了资源释放。

  • 传输大文件时,频繁创建临时的 Direct Buffer。

4.3 邪修架构:池化与复用

既然创建 DirectByteBuffer 慢(涉及到系统调用),回收也慢(依赖 GC)。架构设计的核心思路就是:池化(Pooling)

不要每次都 allocateDirect。参考 Netty 的 PooledByteBufAllocator 或 HikariCP 的思路。

  • 申请一大块内存(比如 16MB 作为一个 Chunk)。
  • 自己维护内存分配算法(Buddy Allocation / Slab Allocation)。
  • 业务层申请内存时,只是在已经申请好的大块内存上切片(slice)。
  • 用完“归还”给池子,而不是还给 OS。

这样,你几乎完全避开了 Cleaner 机制和 GC 的干扰,性能达到极致。但代价是,你必须自己处理内存泄露的风险。


5. 总结

关于 DirectByteBuffer 和 Cleaner,请记住以下 Takeaway:

  1. 虚引用是核心Cleaner 基于虚引用,只有当 Java 堆内的 DirectByteBuffer 对象被 GC 回收时,堆外内存才会被释放。
  2. 时间差是杀手:堆内对象小,堆外内存大,GC 不敏感导致堆外先爆。
  3. 慎用 DisableExplicitGC:在大量使用 NIO 的场景下,禁用 System.gc() 等于自杀。请改用 -XX:+ExplicitGCInvokesConcurrent
  4. 主动管理优于被动依赖:在高性能网关、中间件开发中,尽量使用 Netty 等成熟框架的内存池,或者通过 Unsafe 显式释放(需谨慎),不要把命运完全交给 GC。

架构师的价值,不仅仅在于会用 API,更在于理解 API 背后的资源流转,并在性能与稳定性之间找到那个微妙的平衡点。