Java NIO内存管理与零拷贝深度剖析

Java传统NIO内存管理机制剖析

  • 前言
  • Java传统NIO内存管理机制剖析
    • [一、 NIO 内存管理:DirectByteBuffer 剖析](#一、 NIO 内存管理:DirectByteBuffer 剖析)
      • [1. 为什么需要堆外内存?](#1. 为什么需要堆外内存?)
      • [2. DirectByteBuffer 对象的创建(Java 层源码)](#2. DirectByteBuffer 对象的创建(Java 层源码))
      • [3. 底层 C++ 内存分配(JVM 源码)](#3. 底层 C++ 内存分配(JVM 源码))
      • [4. 堆外内存的回收机制(Cleaner 与虚引用)](#4. 堆外内存的回收机制(Cleaner 与虚引用))
    • [二、 NIO 零拷贝原理剖析](#二、 NIO 零拷贝原理剖析)
      • [1. 机制一:MappedByteBuffer 与 mmap](#1. 机制一:MappedByteBuffer 与 mmap)
      • [2. 机制二:FileChannel.transferTo() 与 sendfile](#2. 机制二:FileChannel.transferTo() 与 sendfile)
    • [三、 架构总结](#三、 架构总结)

前言

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

Java传统NIO内存管理机制剖析

作为软件开发程师,深入理解 Java NIO(New I/O)的内存管理与零拷贝(Zero-Copy)原理,需要将目光从 Java 堆内延伸到 JVM 源码(OpenJDK 8u)、C++ 运行时库以及 Linux 内核的系统调用。

以下结合 OpenJDK 8u 源码,逐层剖析 DirectByteBuffer 内存管理MappedByteBuffer / FileChannel.transferTo 零拷贝 的底层实现。


一、 NIO 内存管理:DirectByteBuffer 剖析

NIO 引入了 DirectByteBuffer(直接缓冲区),允许 Java 程序直接访问堆外内存(Off-Heap Memory)。

1. 为什么需要堆外内存?

  • 避免二次拷贝 :如果是传统的堆内内存(HeapByteBuffer),当进行 I/O 操作(如 write 系统调用)时,JVM 必须先将堆内数据拷贝到堆外的一块临时缓冲区(C 堆),然后再传给操作系统。因为 JVM 存在 GC(垃圾回收),如果直接把堆内内存地址传给内核,一旦发生 GC 导致内存地址移动(Compact),I/O 数据就会损坏。
  • 减少 GC 压力:堆外内存不受 JVM 堆大小限制,也不直接参与垃圾回收器的常规扫描。

2. DirectByteBuffer 对象的创建(Java 层源码)

当我们调用 ByteBuffer.allocateDirect(size) 时,实际上初始化的是 DirectByteBuffer

java 复制代码
// openjdk/jdk/src/share/classes/java/nio/DirectByteBuffer.java

DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    // 1. 是否需要按页对齐,默认不需要
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    
    // 2. 预分配内存计数,如果超出了 -XX:MaxDirectMemorySize 则触发 GC 或抛出 OOM
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 3. 调用底层 Unsafe 分配堆外内存,返回 64 位内存绝对地址
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    
    // 4. 初始化内存空间为 0
    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;
}

3. 底层 C++ 内存分配(JVM 源码)

unsafe.allocateMemory(size) 映射到 JVM 源码中的 unsafe.cpp

cpp 复制代码
// openjdk/hotspot/src/share/vm/prims/unsafe.cpp

UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory(JNIEnv *env, jobject unsafe, jlong size))
  UnsafeWrapper("Unsafe_AllocateMemory");
  size_t sz = (size_t)size;
  if (sz != size) return 0;
  
  // 调用 standard C 的 os::malloc 分配堆外内存
  void* x = os::malloc(sz, mtInternal);
  if (x == NULL) {
    THROW_0(vmSymbols::java_lang_OutOfMemoryError());
  }
  
  // 返回内存指针的 64 位长整型值给 Java 层
  return addr_to_java(x);
UNSAFE_END

系统级原理os::malloc 在 Linux 环境下,底层调用的是 glibc 的 malloc()。根据申请空间的大小,内核会选择使用 brk()(小内存,移动堆顶指针)或 mmap()(大内存,匿名私有映射)系统调用来分配虚拟内存。

4. 堆外内存的回收机制(Cleaner 与虚引用)

DirectByteBuffer 本身是一个普通的 Java 对象,占用堆内极小的空间(仅保存 addresscapacity 等属性)。如果长寿代对象持有堆外内存,极易导致堆内空间充足,但堆外内存被耗尽(OOM)

OpenJDK 使用 sun.misc.Cleaner(一种基于虚引用 PhantomReference 的机制)来追踪 DirectByteBuffer 的生命周期:

java 复制代码
// openjdk/jdk/src/share/classes/java/nio/DirectByteBuffer.java

private static class Deallocator implements Runnable {
    private static unsafe = Unsafe.getUnsafe();
    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;
        }
        // 1. 释放堆外内存
        unsafe.freeMemory(address);
        address = 0;
        // 2. 更新 Bits 中记录的直接内存使用量
        Bits.unreserveMemory(size, capacity);
    }
}
  • 触发时机 :当 DirectByteBuffer 对象在 Java 堆中不再被强引用,并被垃圾回收器(GC)标记为可回收时,JVM 会将其加入到 ReferenceQueue。Java 的 ReferenceHandler 线程会周期性地执行 Cleaner.clean(),最终调用 Deallocator.run() 内的 unsafe.freeMemory(address),释放底层的 C 堆内存。

二、 NIO 零拷贝原理剖析

传统 I/O(如 InputStream.read + OutputStream.write)涉及 4 次上下文切换4 次数据拷贝

  1. 读:网卡/磁盘 → \rightarrow → 内核读缓冲区(DMA 拷贝) → \rightarrow → 用户空间缓冲区(CPU 拷贝)。
  2. 写:用户空间缓冲区 → \rightarrow → 内核写(Socket)缓冲区(CPU 拷贝) → \rightarrow → 网卡/磁盘(DMA 拷贝)。

NIO 主要通过两种方式实现"零拷贝"(通常指减少或消除 CPU 拷贝):

  1. FileChannel.map() (基于 mmap 系统调用)
  2. FileChannel.transferTo() (基于 sendfile 系统调用)

1. 机制一:MappedByteBuffer 与 mmap

FileChannel.map() 将文件的一部分直接映射到进程的虚拟内存空间中。

Java 源码入口:
java 复制代码
// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java

public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
    // ... 权限与边界检查 ...
    int pagePosition = (int)(position % allocationGranularity);
    long mapPosition = position - pagePosition;
    long mapSize = size + pagePosition;
    try {
        // 调用 native 方法进行内存映射
        addr = map0(imode, mapPosition, mapSize);
    } catch (IOException x) {
        // ... 异常处理 ...
    }
    
    // 返回 DirectByteBuffer 的子类 MappedByteBuffer
    int isize = (int)size;
    Unmapper um = new Unmapper(addr, mapSize, isize, fd);
    return Util.newMappedByteBuffer(isize, addr + pagePosition, fd, um);
}
Native 底层实现:

map0 对应 FileChannelImpl.c

c 复制代码
// openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this, jint imode,
                                     jlong jpos, jlong size)
{
    // ... 获取文件描述符 fd ...
    void *mapAddress = 0;
    
    // 调用 Linux 标准的 mmap64 系统调用
    mapAddress = mmap64(
        0,                      // 由内核决定映射区的起始虚拟地址
        size,                   // 映射空间的长度
        protections,            // 映射区的读写权限 (PROT_READ / PROT_WRITE)
        flags,                  // MAP_SHARED (共享映射,修改会同步到磁盘)
        fd,                     // 映射文件的描述符
        jpos                    // 文件偏移量
    );

    if (mapAddress == MAP_FAILED) {
        JNU_ThrowIOExceptionWithLastError(env, "Map failed");
        return 0;
    }

    return ((jlong)mapAddress);
}
系统级零拷贝原理解析(mmap):
  • mmap 建立了用户虚拟空间到内核文件页缓存(Page Cache)的直接映射。
  • 拷贝次数减半 :当用户进程读写这块内存时,通过缺页中断(Page Fault)将磁盘数据通过 DMA 拷贝到 Page Cache。由于用户空间和内核空间共享这段物理内存,不需要进行从内核态到用户态的 CPU 拷贝
  • 代价与限制:数据仍然需要经过 Page Cache,写出时需要 DMA 拷贝到网卡。适合大文件的随机读写。

2. 机制二:FileChannel.transferTo() 与 sendfile

当需要将文件发送到 Socket 网卡时,transferTo 可以实现真正意义上的零(CPU)拷贝。

Java 源码入口:
java 复制代码
// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java

public long transferTo(long position, long count, WritableByteChannel target) throws IOException {
    // ... 检查通道是否开启 ...
    
    // 1. 尝试使用 Linux 特有的 sendfile 机制(最高效)
    long n;
    if ((n = transferToDirectly(position, icount, target)) >= 0)
        return n;

    // 2. 如果 target 不是普通 Socket 通道,退化为使用 MappedByteBuffer 
    if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
        return n;

    // 3. 最坏情况:普通循环读写拷贝
    return transferToArbitraryChannel(position, icount, target);
}

我们重点看 transferToDirectly 如何触发系统调用:

java 复制代码
// openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java

private long transferToDirectly(long position, int icount, WritableByteChannel target) 
    throws IOException 
{
    // 确保 target 是一个来自 SocketChannel 的文件描述符
    FileDescriptor targetFD = ((SelChImpl)target).getFD();
    int thisFDVal = IOUtil.fdVal(fd);
    int targetFDVal = IOUtil.fdVal(targetFD);

    long n = 0;
    try {
        // 调用 Native 的 transferTo0
        n = transferTo0(fd, position, icount, targetFDVal);
    } finally {
        // ... 跟踪统计 ...
    }
    return n;
}
Native 底层实现:

transferTo0 映射到 Linux 平台下的 FileChannelImpl.c

c 复制代码
// openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jobject srcFDOBuffer, jlong position,
                                            jlong count, jint dstFD)
{
    // ... 获取源文件描述符 srcFD ...
    
    off64_t offset = (off64_t)position;
    
    // 直接调用 Linux 内核的 sendfile64 系统调用
    ssize_t result = sendfile64(
        dstFD,         // 目标描述符:通常是 Socket fd
        srcFD,         // 源描述符:文件 fd
        &offset,       // 文件读取的偏移量
        (size_t)count  // 传输的字节数
    );

    if (result < 0) {
        // 处理 EAGAIN 或 EINTR 信号
        if (errno == EAGAIN) return IOS_UNAVAILABLE;
        if (errno == EINTR) return IOS_INTERRUPTED;
        // 如果系统不支持 sendfile(例如不支持该文件类型),返回 UNSUPPORTED 让 Java 层退化处理
        if (errno == ENOSYS || errno == EINVAL) return IOS_UNSUPPORTED_CASE;
        JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");
        return 0;
    }
    return result;
}
系统级零拷贝原理解析(sendfile):

sendfile 系统调用将上下文切换从 4 次减少到了 2 次。数据流转过程如下:

  1. Linux 内核 2.1 版本(标准 sendfile)
  • 磁盘 → DMA \xrightarrow{\text{DMA}} DMA 内核 Page Cache。
  • CPU 拷贝 :内核 Page Cache → CPU \xrightarrow{\text{CPU}} CPU Socket 缓冲区。
  • Socket 缓冲区 → DMA \xrightarrow{\text{DMA}} DMA 网卡。
  • 总计:2 次上下文切换,3 次拷贝(2次DMA,1次CPU)。
  1. Linux 内核 2.4+ 版本(带 SG-DMA 技术的 sendfile)
  • 磁盘 → DMA \xrightarrow{\text{DMA}} DMA 内核 Page Cache。
  • 消除 CPU 拷贝 :不再拷贝整个数据到 Socket 缓冲区,而是仅仅把 内存地址和偏移量的描述符(Descriptor) 复制到 Socket 缓冲区。
  • 网卡的 DMA 引擎直接通过 Scatter-Gather (分散-聚集) 功能,根据描述符的指引直接去 Page Cache 读取数据并发送。
  • 总计 :2 次上下文切换,2 次拷贝(均为 DMA 拷贝,0 次 CPU 拷贝)。

三、 架构总结

机制 OpenJDK 8u 底层实现 Linux 核心系统调用 CPU 拷贝次数 主要解决痛点
DirectByteBuffer unsafe.allocateMemory malloc / brk / mmap I/O 时 0 次 (不需复制到C堆) 消除 JVM 堆到 C 堆的二次拷贝,减轻 GC 压力
MappedByteBuffer FileChannelImpl.map0 mmap64 0 次(进程与内核共享内存) 大文件高频随机读写,减少内核态到用户态的拷贝
FileChannel.transferTo FileChannelImpl.transferTo0 sendfile64 (带 SG-DMA) 完全 0 次 消除网络文件传输(如 Kafka、Tomcat)中的所有 CPU 拷贝
相关推荐
爱笑的源码基地1 小时前
一款全开源的信创云PACS影像云平台解决方案,支持多模态医学影像处理(CT/MR/DR/超声/病理等)
java·源码·国产化·pacs·云影像·区域pacs
我命由我123451 小时前
Android 开发问题:ClickableSpan 的点击事件没有生效
java·java-ee·android studio·android jetpack·android-studio·android runtime
万亿少女的梦1682 小时前
基于Python的高考志愿填报辅助系统设计与实现
java·spring boot·python·mysql·vue
大卡片2 小时前
linux内核驱动开发
linux·运维·驱动开发
微信开发api-视频号协议2 小时前
企业微信外部群开发自动化实践过程
java·前端·微信·自动化·企业微信·ipad
犀利豆3 小时前
#AI in Harness(一)
java·ai编程
极***涯3 小时前
深度拆解:多源BFS路径重构难题——跳出单层遍历误区,搞定高阶最短路径全量解
java·重构·宽度优先
熊猫_豆豆3 小时前
QT6 Android C++ 自制美观闹钟
android·c++·qt·闹钟
心心喵3 小时前
[linux] nohup和pm2的区别 进程保活
linux·运维·服务器
YuK.W3 小时前
Leetcode100: 70.爬楼梯、118.杨辉三角、198.打家劫舍
java·算法·leetcode