JVM零拷贝实现机制解析


前言

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

DirectBuffer和DMA协同实现零拷贝

1. 数据传输路径与 DMA 硬件演进

1.1 传统 I/O 模式的缺陷(4 次上下文切换,4 次数据拷贝)

在传统的 Java 网络编程中,从磁盘文件通过 Socket 发送数据需经历以下步骤:

[ Disk ] --(1. DMA Copy)--> [ Page Cache ] --(2. CPU Copy)--> [ JVM Heap Buffer ]
                                                                       |
[ NIC ]  <--(4. DMA Copy)-- [ Socket Buffer ] <--(3. CPU Copy)---------+

  1. 磁盘至内核页缓存(Page Cache):应用程序发起 read() 系统调用,触发上下文切换(用户态 → \rightarrow 内核态)。DMA 控制器将磁盘数据拷贝至内核空间页缓存。
  2. 内核页缓存至 JVM 堆:CPU 将内核页缓存中的数据拷贝至用户空间 JVM 堆缓冲区(byte[])。系统调用返回,触发上下文切换(内核态 → \rightarrow 用户态)。
  3. JVM 堆至 Socket 缓冲区:应用程序发起 write() 系统调用,触发上下文切换(用户态 → \rightarrow 内核态)。CPU 将 JVM 堆中的数据拷贝至内核 Socket 缓冲区(sk_buff)。
  4. Socket 缓冲区至网卡(NIC):DMA 控制器将 Socket 缓冲区数据异步拷贝至网卡物理缓冲区,传输完成后发起硬件中断(IRQ)。系统调用返回,触发上下文切换(内核态 → \rightarrow 用户态)。

物理痛点:步骤 2 和 3 涉及跨越用户/内核边界的 CPU 移动数据,会锁住 CPU 核心周期、占用 L1/L2 Cache,引发 TLB(Translation Lookaside Buffer)刷屏与 Cache 失效。

1.2 DirectBuffer 与 DMA 的协同机制

为了消除步骤 2 的 CPU 拷贝,引入了 DirectByteBuffer。直接缓冲区在 JVM 堆外分配(Native C-Heap),其物理内存位于用户态可寻址空间,但其地址不受 JVM GC 管理(内存地址固定,无垃圾回收重定位风险)。

[ Disk ] --(1. DMA Copy)--> [ Page Cache ] --(2. CPU Copy)--> [ DirectByteBuffer (Native Heap) ]
                                                                             |
[ NIC ]  <--(4. DMA Copy)-- [ Socket Buffer ] <--(3. CPU Copy)---------------+

DMA(Direct Memory Access,直接内存访问)硬件必须要求传输目标物理内存地址在传输期间是固定且连续的(或者通过 IOMMU 提供 Scatter-Gather 页表支持)。

  • HeapBuffer 的局限:Java 堆内数组(byte[])的内存地址会因为 GC 的标记-整理(Mark-Compact)或 Copying 算法发生变更。若直接将堆内地址丢给 DMA,DMA 传输过程中如果触发 GC 导致对象被移动,DMA 就会将数据写入错误的物理内存,造成严重的内存损坏。
  • DirectBuffer 的突破:内存直接通过 malloc() / mmap() 从 C-Heap 分配,逻辑地址在生命周期内固定不变。JVM 仅保存一个 64 位指针 address 指向这块内存。当发起系统调用时,直接传递 address 给内核,系统调用可以直接利用 DMA 在该地址和硬件设备之间传送数据。

2. DirectBuffer 源码剖析:避免内核到用户态拷贝

在 JDK 源码层面,HeapByteBuffer 试图与 Native 接口交互时,底层强制触发了一次隐式拷贝;而 DirectByteBuffer 则直通 Native 指针。

2.1 JVM 底层对 HeapBuffer 的强制临时拷贝机制 (IOUtil.java)

// jdk/src/share/classes/sun/nio/ch/IOUtil.java

static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd)
    throws IOException
{
    // 1. 如果传入的是 DirectBuffer,直接调用本地 Native 接口传输
    if (src instanceof DirectBuffer)
        return writeFromNativeBuffer(fd, src, position, nd);

    // 2. 核心避坑点:如果是 HeapByteBuffer,必须在 Native 堆分配一块临时的 DirectBuffer
    // 原因是:必须确保传递给 POSIX read/write 系统调用的物理地址在系统调用期间不会被 GC 移动
    ByteBuffer bb = Util.getTemporaryDirectBuffer(src.remaining());
    try {
        // 将 Heap 缓冲区中的 byte[] 重新 CPU 拷贝一次到 Temporary DirectBuffer
        bb.put(src);
        bb.flip();
        src.position(pos);
        
        // 真正将 Temporary DirectBuffer 的 Native 地址传给底层 OS 系统调用
        int n = writeFromNativeBuffer(fd, bb, position, nd);
        if (n > 0) {
            src.position(pos + n);
        }
        return n;
    } finally {
        // 回收临时的 DirectBuffer 到 ThreadLocal 缓存池
        Util.offerFirstTemporaryDirectBuffer(bb);
    }
}

2.2 DirectByteBuffer 的物理内存分配与销毁 (DirectByteBuffer.java)

// jdk/src/share/classes/java/nio/DirectByteBuffer.java

DirectByteBuffer(int cap) {
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    
    // 追踪堆外内存配额(配合 -XX:MaxDirectMemorySize 参数)
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 调用 Unsafe 分配 C-Heap 原生内存,底层即 POSIX malloc() / unsafe.cpp
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    
    // 初始化内存区域
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // 内存页对齐优化(为了更好地适配 DMA 按 Block / Page 粒度传输)
        address = base + ps - (base % ps);
    } else {
        // 保存原生内存的首地址指针!此 long 值直接对应 POSIX 虚拟内存地址
        address = base;
    }
    
    // 注册 Cleaner,用于 GC 发现该 Java 对象不可达时通过 PhantomReference 回收 Native 内存
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

2.3 C-Native 层的直接寻址与 POSIX 写入 (IOUtil.c / FileDispatcherImpl.c)

// jdk/src/solaris/native/sun/nio/ch/IOUtil.c

JNIEXPORT jint JNICALL
Java_sun_nio_ch_IOUtil_write1(JNIEnv *env, jclass clazz,
                             jobject fdo, jlong address, jint len)
{
    // 传入的 address 即为 DirectByteBuffer 内部保存的 C-Heap 指针 long 值
    // 转型为 C 语言底层的 void* 指针,无任何内存偏移与重新拷贝
    void *buf = (void *)jlong_to_ptr(address);
    
    // 文件描述符提取
    int fd = fdval(env, fdo);
    
    // 直接发起 POSIX write 系统调用
    // 此时 OS 接收到用户态地址 buf,内核可以直接从该地址将数据写回文件/网络
    // 避免了传统 read/write 中“系统调用内部将内核 Buffer 复制到用户态内存”的二次复制
    return writeInternal(fd, buf, len);
}

static inline jint writeInternal(int fd, const void *buf, jint len)
{>
    int result;
    // 阻塞/非阻塞系统调用
    RESTARTABLE(write(fd, buf, len), result);
    return result;
}


3. FileChannel.transferTo() 与 Kernel sendfile 零拷贝

DirectBuffer 避免了用户空间与内核空间的数据拷贝,但在传输本地磁盘文件到网络时,仍需经过:PageCache → \rightarrow DirectBuffer → \rightarrow SocketBuffer 的 CPU 拷贝。

为了彻底消除用户态/内核态间所有不必要的 CPU 拷贝,Linux 引入了 sendfile 系统调用。

3.1 sendfile + Gather DMA 演进流程

[ Disk ] --(1. DMA Copy)--> [ Page Cache ]
                                 |
                                 | (2. 仅仅拷贝文件描述符/长度到 Socket Buffer)
                                 v
[ NIC ]  <--(3. Scatter-Gather DMA Copy)---

在支持 Scatter-Gather(SG-DMA)技术的网卡下:

  1. 零 CPU 拷贝:数据完全无需经过用户态内存(包括 DirectBuffer),直接在内核态完成。
  2. 描述符传输:内核仅将 Page Cache 的物理页帧描述符(Buffer Descriptors: 内存地址 + 偏移量)写入 Socket 缓冲区 sk_buff,无需复制物理字节。
  3. SG-DMA 聚合:网卡 DMA 控制器直接根据 sk_buff 中的描述符列表,从内核 Page Cache 中抓取物理页数据,合并后封装成以太网帧发送。

3.2 JVM 源代码追踪:FileChannelImpl.java 到 C Native 层

Java NIO 通过 FileChannel.transferTo() 包装了 sendfile

// 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 = transferToDirectly(position, icount, target);
    if (n >= 0)
        return n;

    // 2. 次选:如果系统不支持 sendfile,回退到 DirectBuffer 映射 (mmap)
    n = transferToTrustedChannel(position, icount, target);
    if (n >= 0)
        return n;

    // 3. 最劣兜底:传统 loop 拷贝
    return transferToArbitraryChannel(position, icount, target);
}

private long transferToDirectly(long position, int count, WritableByteChannel target)
    throws IOException
{
    if (!nd.canTransferToDirectly(target))
        return -1;

    FileDescriptor srcFD = this.fd;
    FileDescriptor targetFD = ((SelChImpl)target).getFD();
    
    // 调用 Native 接口进行底层的 sendfile 系统调用
    long n = transferTo0(srcFD, position, count, targetFD);
    if (n == IOStatus.UNSUPPORTED_CASE) {
        return -1;
    }
    return n;
}

// Native 声明
private native long transferTo0(FileDescriptor src, long position, long count, FileDescriptor target);

3.3 Linux C Native 层源码映射 (FileChannelImpl.c)

// jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jobject srcFdo, jlong position,
                                            jlong count, jobject dstFdo)
{
    int srcFD = fdval(env, srcFdo);
    int dstFD = fdval(env, dstFdo);
    
    off64_t offset = position;
    
    // 映射到 Linux 系统的 sendfile64() 系统调用
    // 参数说明:
    // dstFD: 目标网络 Socket 描述符
    // srcFD: 源磁盘文件描述符
    // &offset: 读取文件的起始偏移量
    // count: 传输的总字节数
    jlong nbytes = sendfile64(dstFD, srcFD, &offset, count);

    if (nbytes < 0) {
        if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
            // 非阻塞 I/O 情况下, Socket 缓冲区满了,重试
            return IOS_UNSUPPORTED_CASE;
        }
        if (errno == EINVAL || errno == ENOSYS) {
            // 系统内核版本不支持或目标 FD 不支持 sendfile
            return IOS_UNSUPPORTED;
        }
        JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");
        return 0;
    }
    return nbytes;
}


4. 超高性能传输:Linux MSG_ZEROCOPY 底层探秘

sendfile 解决了“磁盘文件 → \rightarrow Socket”的零拷贝,但其限制是源端必须是支持 mmap/page 的文件描述符。如果数据是在内存中动态生成的(例如 RPC 序列化协议包、内存数据库响应),sendfile 无法适用。

Linux 4.14 引入了 MSG_ZEROCOPY 特性,允许用户态内存(包括 DirectBuffer)直接进行网卡 TX 零拷贝

[ DirectBuffer / User Page ] --(Page Pinning Locking)------------------+
                                                                      | (1. 物理页直接钉住)
                                                                      v
[ NIC Hardware ] <-----------(2. DMA Reads User Page directly)-------+

4.1 MSG_ZEROCOPY 的内核原理与生命周期

  1. 页面钉住(Page Pinning):内核通过 get_user_pages_fast() 锁住用户态 DirectBuffer 对应的虚拟内存物理页,递增其 Page Reference Count,防止其被 SWAP 换出或重分配。
  2. 零拷贝发送:内核 sk_buff 结构的 skb_frag_t 结构体直接指向用户态 DirectBuffer 的物理页帧地址。DMA 引擎直接从用户态内存拉取数据发送,不进行任何拷贝
  3. 异步完成通知(Completion Notification):网卡发送完成后,内核释放物理页引用,并将发送完成信号推进 Socket 的错误队列(Error Queue, MSG_ERRQUEUE。用户进程需要定期读取该队列以复用/释放内存 buffer。

4.2 JVM/Netty Native 结合 MSG_ZEROCOPY 源码解析

下面演示如何在基于 JNI/C++ 的 JVM 高性能扩展库(如 Netty Native Epoll 架构)中集成 MSG_ZEROCOPY

// Native 层 JNI 交互伪代码与底层 POSIX 系统调用流程

#include <sys/socket.h>
#include <linux/errqueue.h>
#include <netinet/in.h>

// 1. 在 Socket 创建阶段开启 SO_ZEROCOPY 选项
JNIEXPORT void JNICALL
Java_com_example_native_Socket_enableZeroCopy(JNIEnv *env, jclass clazz, jint fd) {
    int val = 1;
    // 启用内核 Socket 级别的 Zero-Copy 支持
    if (setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &val, sizeof(val)) < 0) {
        JNU_ThrowIOExceptionWithLastError(env, "Failed to set SO_ZEROCOPY");
    }
}

// 2. 执行网络零拷贝发送
JNIEXPORT jint JNICALL
Java_com_example_native_Socket_sendZeroCopy(JNIEnv *env, jclass clazz, 
                                             jint fd, jlong directBufAddress, 
                                             jint len) {
    void* buf = (void*) jlong_to_ptr(directBufAddress);
    
    // 传入 MSG_ZEROCOPY Flag
    // 注意:系统将不会拷贝 buf 中的数据到 Socket Buffer,而是锁住内存页!
    ssize_t res = send(fd, buf, len, MSG_ZEROCOPY);
    
    if (res < 0) {
        if (errno == ENOBUFS) {
            // 如果内核锁住的内存页达到上限 (RLIMIT_MEMLOCK),需要回退到常规 send
            return fallbackStandardSend(fd, buf, len);
        }
        JNU_ThrowIOExceptionWithLastError(env, "send MSG_ZEROCOPY failed");
    }
    return (jint)res;
}

// 3. 轮询内核 Completion Queue (MSG_ERRQUEUE),清理与确认已完成传输的 Buffer
JNIEXPORT jboolean JNICALL
Java_com_example_native_Socket_reapZeroCopyCompletions(JNIEnv *env, jclass clazz, jint fd) {
    struct msghdr msg = {0};
    char control[100];
    msg.msg_control = control;
    msg.msg_controllen = sizeof(control);

    // 从错误队列 (MSG_ERRQUEUE) 中拉取 Linux 发送完成通知
    int res = recvmsg(fd, &msg, MSG_ERRQUEUE);
    if (res < 0) return JNI_FALSE;

    struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
    if (cm->cmsg_level == SOL_SOCKET && cm->cmsg_type == SO_EE_TYPE_ZEROCOPY) {
        struct sock_extended_err *serr = (struct sock_extended_err *)CMSG_DATA(cm);
        
        // serr->ee_info 到 serr->ee_data 包含发送成功的 Frame Sequence ID 范围
        uint32_t lo = serr->ee_info; 
        uint32_t hi = serr->ee_data;
        
        // 通知 Java 层:这批 DirectBuffer 对应的 Frame 已由网卡 DMA 发送完毕,可安全复用/释放!
        notifyBufferCanBeReused(env, lo, hi);
        return JNI_TRUE;
    }
    return JNI_FALSE;
}


5. 系统架构与决策对比矩阵

在系统架构演进中,各种 I/O 传输模式的技术指标对比汇总如下:

技术指标 / 优化维度传统 Standard I/O (HeapBuffer)用户态零拷贝 (DirectBuffer)内核零拷贝 (sendfile)内存级零拷贝 (MSG_ZEROCOPY)
CPU 拷贝次数2 次 (Kernel ↔ \leftrightarrow Heap)1 次 (Direct → \rightarrow Socket)0 次0 次
DMA 拷贝次数2 次2 次2 次 (SG-DMA 优化)2 次
上下文切换次数4 次4 次2 次2 次 (加上异步 Ack 轮询)
数据源限制无限制无限制必须是 File / Mmap任意内存 (含 DirectBuffer)
内存页锁住开销高 (get_user_pages)
GC 垃圾回收影响高 (内存频繁分配/移动)低 (不占用 Heap)极低 (无 JVM 介入)极低 (无 JVM 介入)
推荐适用场景小包处理、业务逻辑解包高频 RPC 网络 Socket API静态大文件下载/HTTP 静态资源极高吞吐的内存数据块/大包发送

系统级总结调优路径

  1. 进程内数据处理与协议解析:优先采用 DirectByteBuffer + Pooled Allocator(如 Netty ByteBufAllocator),避免 JVM GC 压力的同时,降低 IOUtil.java 内部隐式的临性堆外内存申请与 memcpy 开销。
  2. 磁盘文件网络分发(如 Kafka, Nginx, RocketMQ 级应用):严格使用 FileChannel.transferTo(),完全由操作系统内核 sendfile 接管,配合网卡的 Scatter-Gather DMA 能力实现全链路 Zero-Copy。
  3. 超大规模内存块传输(> 10GB/s 吞吐需求):结合 Linux Kernel 4.14+ 开启 Socket MSG_ZEROCOPY 支持,直接将 DirectBuffer 绑定的物理页 Lock 住并推送至硬件网卡 TX 队列,完全解除 CPU 在主存到 Socket 缓冲区的介入。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值