前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

NIO内存管理机制剖析

作为软件开发工程师,深入理解 Java NIO(New I/O)的内存管理机制对于构建高性能、低延迟的分布式系统至关重要。Java NIO 的核心优势之一在于支持直接内存(Direct Memory),它允许 Java 程序直接分配 OS 本地堆内存,从而绕过 JVM 堆,减少数据在内核空间与用户空间之间的拷贝次数。

以下结合 OpenJDK 8u(具体对应 jdk8u/jdk/src/share/classes/java/nio/sun/misc/)的源码,对 Java NIO 内存管理机制进行全景式的深度剖析。


1. Java NIO 内存架构概述:Heap vs Direct

传统的 HeapByteBuffer(堆字节缓冲区)将数据存储在 JVM 堆内(通过 byte[] 数组)。在进行 I/O 操作(如 Socket 读写、File 读写)时,JVM 必须先将堆内数据拷贝到操作系统内核态的临时缓冲区(Intermediate Buffer)中,然后再交给操作系统内核处理。这是因为 JVM 的垃圾回收器(GC)可能会随时移动堆内对象的位置,导致其物理地址发生改变。

DirectByteBuffer 则直接通过操作系统的 malloc 族函数在堆外分配内存,并在 Java 堆内保留一个极小的引用对象。
在 NIO 中,Buffer 分为两大类:HeapByteBuffer(堆缓冲区)和 DirectByteBuffer(直接缓冲区)。

特性HeapByteBufferDirectByteBuffer
内存分配位置JVM 堆内(Java Heap)操作系统本地内存(Native Memory / Off-heap)
分配与销毁开销极低(仅底层 byte 数组分配)较高(涉及 OS 系统调用 malloc/free
GC 影响受 GC 管辖,频繁并发可能导致存活对象晋升不受 JVM 堆 GC 直接管辖,减轻 GC 暂停压力
I/O 性能慢(需要先拷贝到临时的 Native Buffer 再调用内核)快(通过零拷贝或单次拷贝直接与内核交互)

为什么 HeapByteBuffer 无法直接用于内核 I/O?

JVM 在执行垃圾回收(GC)时,通常会移动对象以压缩堆空间(例如 Mark-Compact 算法)。如果直接将 JVM 堆内数组的内存地址传给操作系统的 readwrite 系统调用,在 I/O 读写过程中一旦发生 GC,数组的物理地址就会发生改变,从而导致内存损坏。因此,JVM 在处理 HeapByteBuffer 的 I/O 时,必须先在堆外复制一份临时的 Direct Buffer,再将堆外地址传给操作系统。


2. DirectByteBuffer 分配流程源码解析

DirectByteBuffer 的创建始于 ByteBuffer.allocateDirect(int capacity)

2.1 构造函数入口

在线索追踪到 java.nio.DirectByteBuffer 的构造函数中:

// java/nio/DirectByteBuffer.java
DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    // 1. 是否需要页面对齐,由 -XX:+PageAlignDirectMemory 参数控制
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    
    // 2. 核心:预留内存控制与配额检查
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 3. 调用 Unsafe 分配物理内存
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    
    // 4. 初始化内存空间(清零)
    unsafe.setMemory(base, size, (byte)0);
    
    // 5. 计算实际有效的内存地址
    if (pa && (base % ps != 0)) {
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    
    // 6. 核心:构建 Cleaner 机制用于后续的内存自动释放
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

2.2 内存准入网关:Bits.reserveMemory

Bits.reserveMemory 是控制直接内存大小时的核心闸门。它负责检查当前已分配的直接内存是否超过了 -XX:MaxDirectMemorySize 指定的上限。如果不足,它会尝试触发 GC 来回收直接内存。

// java/nio/Bits.java
static void reserveMemory(long size, int cap) {
    // 获取允许的最大直接内存,默认约等于 Runtime.getRuntime().maxMemory()
    if (!maxMemoryInitialized) {
        if (!VM.isBooted()) return;
        maxMemory = VM.maxDirectMemory();
        maxMemoryInitialized = true;
    }

    // 乐观锁:尝试直接原子增加预留计数
    if (tryReserveMemory(size, cap)) {
        return; // 分配成功
    }

    // 如果空间不够,进入悲观控制链:尝试回收
    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    
    // 循环尝试执行挂起的 Reference 的 clean 操作(释放由于 DirectByteBuffer 死亡带来的堆外内存)
    while (jlra.tryHandlePendingReference()) {
        if (tryReserveMemory(size, cap)) {
            return;
        }
    }

    // 显式触发 System.gc(),寄希望于 Full GC 来回收不可达的 DirectByteBuffer
    System.gc();

    // 阶梯式休眠等待 GC 线程异步执行 Cleaner
    long sleepTime = 1;
    int maxSleeps = 9; // 最大重试 9 次,总休眠时间 1+2+4+8+...+256 ms
    for (int i = 0; i < maxSleeps; i++) {
        try {
            Thread.sleep(sleepTime);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        if (tryReserveMemory(size, cap)) {
            return;
        }
        sleepTime <<= 1; // 倍增休眠时间
    }

    // 彻底没有空间,抛出 OOM
    throw new OutOfMemoryError("Direct buffer memory");
}

系统工程师笔记: > 很多高并发系统会设置 -XX:+DisableExplicitGC 来防止应用显式调用 System.gc() 引发长时间 STW。但在 OpenJDK 8中,如果同时设置了该参数且直接内存用尽,Bits.reserveMemory 内部的 System.gc() 将会变成空操作(No-op),导致上面代码中的 tryReserveMemory 大概率持续失败,最终频繁引发 java.lang.OutOfMemoryError: Direct buffer memory


3. Direct Memory 的自动回收机制

由于 DirectByteBuffer 本身是在 Java 堆内创建的对象(占用几十个字节),而它指向的堆外纯净内存在 OS 侧。当 DirectByteBuffer 对象在 Java 堆中不可达并被 GC 回收时,必须有一种机制同步释放对应的堆外内存,否则会导致严重的本地内存泄漏

OpenJDK 8使用 sun.misc.Cleaner(基于虚引用 PhantomReference)来实现这一自动释放过程。

3.1 Cleaner 与 Deallocator 的绑定

看回构造函数中的这一行:

cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

Deallocator 是一个实现了 Runnable 接口的内部静态类,保存了本地内存的基地址 base 和大小 size

// java/nio/DirectByteBuffer.java -> Deallocator
private static class Deallocator implements Runnable {
    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            return;
        }
        // 调用 Unsafe 释放底层 OS 内存
        unsafe.freeMemory(address);
        address = 0;
        // 扣减 Bits 中的内存计数
        Bits.unreserveMemory(size, capacity);
    }
}

回收的完整闭环

  1. 对象死亡DirectByteBuffer 在 Java 堆内变为垃圾。
  2. 触发 GC:JVM 进行 Scavenge/Full GC。发现该对象仅有虚引用指向。
  3. 入队挂起:JVM 将该 Cleaner 放入 Reference.pending 队列。
  4. 异步唤醒:Java 高优先级守护线程 ReferenceHandler 循环取出队列中的元素,如果发现是 Cleaner,则调用 cleaner.clean()
  5. 执行释放cleaner.clean() 内部会调用 Deallocator.run(),最终执行 unsafe.freeMemory(address),内存归还给操作系统。

3.2 sun.misc.Cleaner 的执行原理

Cleaner 继承自 java.lang.ref.PhantomReference。当 JVM 检测到 DirectByteBuffer 对象仅被虚引用指向(即 Java 堆中已无强引用)时,GC 会将该 Cleaner 对象加入到 java.lang.ref.Referencepending 链表中。

JVM 的全局守护线程 Reference Handler 会不断轮询该链表,并最终调用 Cleaner.clean() 方法:

// sun/misc/Cleaner.java
public class Cleaner extends PhantomReference<Object> {
    // 双向链表,用于防止 Cleaner 本身被 GC 回收
    private static Cleaner first = null;
    private final Runnable thunk; // 保存传入的 Deallocator

    public void clean() {
        if (!remove(this))
            return;
        try {
            // 调用 Deallocator.run() 执行真正的内存释放
            thunk.run();
        } catch (final Throwable x) {
            // 保护机制:如果清理失败,打印错误,防止阻塞 Reference Handler 线程
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    if (System.err != null)
                        new Error("Cleaner terminated abnormally", x).printStackTrace();
                    return null;
                }});
        }
    }
}


4. I/O 交互中的临时 Direct Buffer 机制

在日常开发中,即使我们显式使用了 HeapByteBuffer(例如 ByteBuffer.allocate(1024))来进行 Channel 读写,NIO 框架底层仍然会与直接内存产生隐式交互。

FileChannelImpl.write(ByteBuffer src) 为例,追踪至 sun.nio.ch.IOUtil

// sun/nio/ch/IOUtil.java
static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd)
    throws IOException
{
    // 如果本来就是 DirectBuffer,直接进 Native 层写
    if (src instanceof DirectBuffer)
        return writeFromNativeBuffer(fd, src, position, nd);

    // 程序走到这里,说明传入的是 HeapByteBuffer
    int pos = src.position();
    int limit = src.limit();
    int rem = (pos <= limit ? limit - pos : 0);
    
    // 1. 从线程局部缓存(ThreadLocal)中获取一个复用的临时 DirectByteBuffer
    ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
    try {
        // 2. 将 HeapByteBuffer 中的数据拷贝到临时的 DirectByteBuffer 中
        bb.put(src);
        bb.flip();
        src.position(pos); // 恢复原 buffer 指针
        
        // 3. 调用 Native 方法,传入直接内存的物理地址进行 I/O
        int n = writeFromNativeBuffer(fd, bb, position, nd);
        if (n > 0) {
            src.position(pos + n);
        }
        return n;
    } finally {
        // 4. 将临时的 DirectBuffer 放回 ThreadLocal 缓存池,供下次复用
        Util.offerFirstTemporaryDirectBuffer(bb);
    }
}

为什么 NIO 要引入 ThreadLocal 临时缓冲区?

  1. 规避 GC 带来的物理地址漂移:前面提到,内核必须拿到固定的内存物理地址。
  2. 极高地提升性能:如果不使用 Util.getTemporaryDirectBuffer 做 ThreadLocal 级别的复用,每一次对 HeapByteBuffer 的读写都会导致动态地 allocateDirect(引发本地系统调用 mallocCleaner 的注册),高并发下这将造成严重的内核态自旋和 OOM。

5. 系统视角的性能调优与监控建议

理解了 OpenJDK 8的源码后,在生产环境下进行 Java NIO 内存管理和调优时,有以下几个核心方针:

5.1 监控直接内存

由于直接内存不属于 JVM 堆,常规的 jstatvisualvm 默认图表无法直观看到它的精确用量。

  • 原生工具:使用 jcmd <pid> VM.native_memory detail(需要配置 -XX:NativeMemoryTracking=detail)来追踪 InternalGC 分类的堆外内存分配。
  • JMX 暴露:NIO 已经通过 MBean 将信息注册到了平台。可以通过代码或 JConsole 读取 java.nio:type=BufferPool,name=direct 属性下的 CountTotalCapacityMemoryUsed

5.2 参数调优规避隐患

  • 明确设置边界:务必显式设置 -XX:MaxDirectMemorySize=2G(结合机器物理内存与堆内存合理规划),防止堆外内存无限膨胀击穿 OS 限制被系统 OOM Killer 杀掉。
  • 谨慎使用 ExplicitGC 禁用:如果配置了 -XX:+DisableExplicitGC,请务必在 Netty 或高性能框架中依赖其自带的内存池(如 Netty 的 PooledByteBufAllocator),手动通过 ReferenceCountUtil.release() 显式释放内存,不要指望 Bits.reserveMemory 里的 System.gc() 能帮应用兜底。
  • 主动释放:在不需要利用虚引用等待 GC 回收的特殊场景(例如大文件单次传输映射),可以通过反射手动调用 DirectByteBuffercleaner().clean(),实现即时、确定性的堆外内存释放。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐