2、堆外内存与DirectBuffer:Netty、RocketMQ等框架中的堆外内存使用,以及如何监控和回收DirectBuffer

2.1 为什么大厂框架都爱用堆外内存?

说实话,我刚接触Netty和RocketMQ源码时,也有过这个疑问。明明JVM堆内内存用得好好的,为什么这些框架非要绕道走堆外?

答案其实很直接:减少GC压力,提升IO性能

你想想看,Java程序读写网络或磁盘时,数据得先从内核态拷贝到用户态。如果数据在堆内,JVM还得再做一次从堆内到堆外的拷贝。这就是所谓的「二次拷贝」。而DirectBuffer直接分配在堆外,省掉了这次拷贝,IO路径短了一大截。

我在项目中遇到过这样一个场景:一个高吞吐的网关服务,堆内内存频繁触发Full GC,每次停顿几百毫秒。换成堆外内存做缓冲区后,GC频率直接降了70%。说白了,堆外内存就是「用空间换时间,用管理换稳定」。

核心结论:

  • 堆外内存不受JVM GC管理,避免长生命周期的Buffer频繁晋升老年代
  • DirectBuffer底层基于sun.misc.Unsafe,直接操作物理内存,IO效率更高
  • Netty、RocketMQ、Kafka等框架,底层都重度依赖DirectBuffer

2.2 DirectBuffer的分配与回收机制

DirectBuffer的分配,本质上是通过ByteBuffer.allocateDirect(capacity)来完成的。但底层发生了什么?

// 示例:分配1MB的堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);

这行代码背后,JVM会调用Unsafe.allocateMemory(),在堆外开辟一块连续内存。同时,JVM会在堆内创建一个DirectByteBuffer对象,这个对象很小,但它持有一个指向堆外内存的地址引用。

嗯,这里要注意:堆外内存的回收,依赖于堆内DirectByteBuffer对象的GC。当DirectByteBuffer被GC回收时,它的cleaner(一个虚引用)会被触发,进而调用unsafe.freeMemory()释放堆外内存。

避坑指南:

我曾经遇到过一个线上事故:某个服务频繁分配DirectBuffer,但忘记主动释放。结果堆外内存涨到8GB,直接导致操作系统OOM Killer把进程干掉了。事后分析发现,堆内的DirectByteBuffer对象虽然被GC了,但GC频率跟不上分配速度,堆外内存的释放严重滞后。

2.3 Netty中的堆外内存管理

Netty对DirectBuffer的使用,可以说是教科书级别的。它没有直接使用JDK的ByteBuffer.allocateDirect(),而是自己实现了一套池化机制——PooledByteBufAllocator

为什么这么做?因为频繁分配和释放堆外内存,代价太高了。我做过压测,直接用allocateDirect分配1KB的Buffer,每秒10万次,CPU的sys开销能占到30%以上。而Netty的池化机制,通过内存复用,把这个开销降到了5%以下。

// Netty中获取DirectBuffer的典型方式
ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
// 使用完后释放,归还到池中
buf.release();

Netty的池化策略,说白了就是「分级缓存」:

  • Tiny:512B以下,使用位图管理
  • Small:512B ~ 8KB,使用缓存数组
  • Normal:8KB ~ 16MB,使用伙伴算法
  • Huge:16MB以上,直接分配,不池化

我个人习惯在生产环境中,通过-Dio.netty.allocator.type=pooled强制启用池化。默认情况下,Netty会根据运行环境自动选择,但显式指定更稳妥。

2.4 RocketMQ中的堆外内存应用

RocketMQ的堆外内存使用,主要体现在MappedFileTransientStorePool上。

RocketMQ的消息存储,默认使用MappedByteBuffer(基于mmap的文件映射)。但为了提高写入性能,它还提供了一个堆外内存写缓冲池——TransientStorePool。

简单来说,消息先写入堆外内存的缓冲池,然后由后台线程批量刷盘。这样做的好处是:

  • 写入操作不直接落盘,延迟更低
  • 堆外内存的分配和释放,不影响JVM堆内GC
// RocketMQ中配置堆外内存缓冲池
// 在broker配置文件中设置
transientStorePoolEnable=true
// 堆外内存池大小,默认5个文件,每个文件1GB
maxTransientStorePoolSize=5

我记得有一次帮客户排查RocketMQ的写入抖动问题。发现堆外内存池满了之后,消息写入会退化为直接写MappedFile,性能瞬间掉了一半。后来调整了maxTransientStorePoolSize,并配合监控,才彻底解决。

2.5 如何监控DirectBuffer?

监控堆外内存,不能只看JVM堆内指标。我常用的监控手段有这几个:

2.5.1 通过JMX获取DirectBuffer信息

// 获取DirectBuffer的容量和已使用量
BufferPoolMXBean directPool = ManagementFactory.getPlatformMXBeans(
    BufferPoolMXBean.class).get(0);
System.out.println("DirectBuffer总容量: " + directPool.getTotalCapacity());
System.out.println("DirectBuffer已使用: " + directPool.getMemoryUsed());
System.out.println("DirectBuffer对象数量: " + directPool.getCount());

2.5.2 使用NMT(Native Memory Tracking)

JDK8u40以后,可以通过-XX:NativeMemoryTracking=summary开启NMT。然后使用jcmd命令查看:

jcmd <pid> VM.native_memory summary

输出中会明确显示InternalOther区域,其中就包含了DirectBuffer的占用。

2.5.3 操作系统级别监控

有时候JVM层面的监控不够精确。我习惯用/proc/[pid]/smaps来查看进程的内存映射:

cat /proc/<pid>/smaps | grep -A 10 "anon" | grep "Size"

或者直接用pmap -x <pid>,看RSS和Dirty页的变化。

个人经验:

我建议在监控系统中,同时采集三个维度的数据:JVM的BufferPoolMXBean、NMT的native memory、以及OS的RSS。三者交叉验证,才能准确判断堆外内存是否泄漏。

2.6 如何主动回收DirectBuffer?

前面说过,DirectBuffer的回收依赖GC。但有些场景下,我们需要主动释放,比如:

  • Buffer使用完毕后,明确不再需要
  • 系统内存紧张,需要立即归还
  • 避免堆外内存碎片化

主动回收的方式,我推荐两种:

2.6.1 通过Cleaner直接释放

// 获取DirectBuffer的Cleaner并执行清理
import sun.misc.Cleaner;
import java.nio.ByteBuffer;
import java.lang.reflect.Field;

public static void freeDirectBuffer(ByteBuffer buffer) {
    if (buffer.isDirect()) {
        try {
            Field cleanerField = buffer.getClass().getDeclaredField("cleaner");
            cleanerField.setAccessible(true);
            Cleaner cleaner = (Cleaner) cleanerField.get(buffer);
            if (cleaner != null) {
                cleaner.clean();
            }
        } catch (Exception e) {
            // 处理异常
        }
    }
}

注意:

这种方式依赖内部API,在JDK9+的模块化系统中可能受限。我建议只在测试或紧急修复时使用,生产环境还是依赖Netty的池化回收机制更安全。

2.6.2 使用Netty的ReferenceCounted接口

如果你在用Netty,直接调用buf.release()即可。Netty内部会通过引用计数,在引用归零时自动回收底层DirectBuffer。

// Netty中正确的回收方式
ByteBuf buf = ...;
try {
    // 使用buf
} finally {
    buf.release(); // 引用计数减1,归零时回收
}

2.7 常见问题与避坑指南

最后,我总结几个实战中容易踩的坑:

  1. 堆外内存泄漏:最常见的原因是DirectBuffer分配后,没有及时释放。Netty中如果忘记调用release(),或者引用计数管理混乱,就会导致泄漏。我曾经排查过一个案例,某个业务线程池中的ByteBuf被反复传递,引用计数一直没归零,最终堆外内存涨到极限。
  2. 堆外内存碎片化:频繁分配和释放不同大小的DirectBuffer,会导致堆外内存碎片。Netty的池化机制能缓解,但如果你的应用直接使用allocateDirect,建议尽量复用固定大小的Buffer。
  3. MaxDirectMemorySize限制:JVM默认的堆外内存上限是64MB(或等于堆大小)。如果分配超过这个值,会抛出OutOfMemoryError: Direct buffer memory。我建议根据业务需求,显式设置-XX:MaxDirectMemorySize=2G
  4. GC与堆外内存释放的时差:即使堆内的DirectByteObject被GC了,堆外内存的释放也有延迟。在高并发场景下,这个时差可能导致堆外内存短暂飙升。解决方案是:降低GC阈值,或者使用Netty的io.netty.maxDirectMemory参数做软限制。

嗯,堆外内存这块,说白了就是「用得好是利器,用不好是利刃」。理解它的分配和回收机制,配合有效的监控手段,才能让大内存应用跑得稳、跑得快。