Netty从0到1系列之JDK零拷贝技术

发布于:2025-09-06 ⋅ 阅读:(16) ⋅ 点赞:(0)

一、网络传输

基本的I/O模型可以分为两个阶段, 分别为调用阶段执行阶段;

调用阶段: 用户进程向内核发起系统调用.

执行阶段: 内核等待I/O请求处理完成返回.

  • 第一步: 等待数据就绪, 并写入内核缓冲区
  • 第二步: 将内核缓冲区数据拷贝至用户态缓冲区

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

二、零拷贝

✅ 零拷贝的目标

  • 减少数据拷贝次数(理想情况:0 次 CPU 拷贝)
  • 减少上下文切换
  • 让数据直接在内核空间流动

💡 注意:

  • “零拷贝”不是真的 0 次拷贝,而是指 CPU 不参与数据拷贝,数据在内核中直接流转。

2.1 问题的引出

场景: 假设现在通过网络传输一个文件.类似代码如下所示:

// 第一步
File file = new File("tom.txt");
RandomAccessFile f = new RandomAccessFile(file, "r");

// 第二步
byte[] buf = new byte((int)file.length());
f.read(buf);

// 第三步
Socket s = new ..
s.getOutputStream.write(buf);

Java本身不具体直接操作io的能力, 必须调用c库函数方法,才能操作文件. 当调用read方法的时候,通过源码可知,调用的实际上:

private native int read0() throws IOException; // native方法.

1. 此时会从用户态切换到内核态(kernel), 将数据读取到内核缓冲区.而线程会阻塞住, 操作系统使用DMA(direct memorty Access),来实现文件的读, 期间不会使用到CPU.

❓DMA技术

  • 直接内存访问(Direct Memory Access,DMA)是一种计算机技术,用于在不涉及中央处理器(CPU)的情况下,在计算机系统的不同组件之间快速传输数据。

  • 想象一下,你有一个装满了文件的文件柜。你想把文件从一个文件柜移到另一个文件柜,但你不想每次都亲自去取文件并把它们放回去。DMA 就像是一个小机器人,可以在文件柜之间移动文件,而不需要你的干预。

  • 在计算机中,DMA 用于在设备(如硬盘、网卡、显卡等)和内存之间传输数据。通常,当设备需要将数据写入内存时,它会向 CPU 发送一个请求,CPU 然后将数据从设备读取并将其写入内存中。这会占用 CPU 的时间和资源,并且在数据量较大时可能会导致性能下降。

  • DMA 则允许设备直接将数据写入内存,【而不需要 CPU 的参与】。设备会向 DMA 控制器发送一个请求,DMA 控制器然后会将数据从设备读取并将其写入内存中,而不需要 CPU 的干预。这可以大大提高数据传输的速度和效率,因为 CPU 可以专注于其他任务,而 DMA 控制器可以处理数据传输。

总的来说,DMA 是一种用于在计算机系统的不同组件之间快速传输数据的技术,它可以提高系统的性能和效率。

  1. 从内核态切换为用户态, 将数据从内核缓冲区拷贝到用户缓冲区(byte[] buf)当中.这期间cpu会参与拷贝, 无法再利用DMA了.

  2. 调用write方法, 这时从用户缓冲区(byte[] buf)写入socket缓冲区, cpu会参与拷贝.

  3. 接下来要向网卡写入数据, 网卡属于硬件设备, Java无法直接操作,此时会从用户态切换为内核态, 调用操作系统写的能力, 使用DMASocket缓冲区的数据写入网卡, 不会使用到cpu.

在这里插入图片描述

内核空间
用户空间
2. DMA 拷贝
3. CPU 拷贝
5. CPU 拷贝
6. DMA 拷贝
1. read 系统调用
上下文切换
4. write 系统调用
上下文切换
内核缓冲区
Page Cache
Socket 缓冲区
应用程序
用户缓冲区
磁盘/网卡
应用程序 内核空间 磁盘 网卡 1. DMA拷贝: 文件数据 → 内核缓冲区 2. CPU拷贝: 内核缓冲区 → 用户缓冲区(read) 3. CPU拷贝: 用户缓冲区 → socket缓冲区(write) 4. DMA拷贝: socket缓冲区 → 网卡发送 应用程序 内核空间 磁盘 网卡
  • Java当中的IO实际上并不是物理层面上的读写操作,而是调用底层的操作系统完成的, 最后实际上是缓存的复制.
  • 2 次系统调用(read + write)
  • 4 次上下文切换(用户态↔内核态)
  • 4 次数据拷贝(2 次 DMA,2 次 CPU)

2.2 DirectByteBuffer优化

NIO中通过DirectByteBuf:

❓DirectByteBuffer

  • DirectByteBuffer是 Java NIO 中的一个类,用于表示直接缓冲区(Direct Buffer)。直接缓冲区是一种在 Java 中用于进行高效 I/O 操作的内存缓冲区。

  • 与普通的ByteBuffer不同,DirectByteBuffer是在堆外内存中分配的,可以直接与操作系统的内存交互,而不需要经过 Java 的垃圾回收机制。这意味着可以通过避免在 Java 对象和 native 内存之间的复制,提高 I/O 操作的性能。

使用DirectByteBuffer需要注意以下几点:

  • 直接缓冲区的创建和销毁需要更多的开销,因此应该尽量避免频繁地创建和销毁。

  • 直接缓冲区的大小是固定的,一旦创建后无法调整大小。

  • 直接缓冲区使用的是堆外内存,需要注意内存管理和释放,以避免内存泄漏和内存溢出等问题。

  • 在使用完毕后,应该及时通过clean()release()方法释放堆外内存。

ByteBuffer.allocate(10)  // HeapByteBuffer 使用的还是 jvm 内存
ByteBuffer.allocateDirect(10)  // DirectByteBuffer 使用的是操作系统内存

大部分步骤与优化前相同, Java可以使用DirectByteBuf将堆外内存映射到JVM内存中来直接访问使用.

  • java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步
    • DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列
    • 通过专门线程访问引用队列,根据虚引用释放堆外内存
  • 减少了一次数据拷贝,用户态与内核态的切换次数没有减少

在这里插入图片描述

直接缓冲区(Direct Buffer)
堆缓冲区(Heap Buffer)
内核缓冲区
磁盘
直接缓冲区
(堆外内存,共享访问)
内核Socket缓冲区
(复制1)
网卡
内核缓冲区
磁盘
JVM堆缓冲区
(复制1)
内核Socket缓冲区
(复制2)
网卡

2.3 sendFile优化

NIO中进一步优化(底层采用了linux 2.1后提供的sendFile方法, java中对应两个channel调用transferTo/transferFrom方法拷贝数据.

public abstract long transferTo(long position, long count, WritableByteChannel target)throws IOException;
public abstract long transferFrom(ReadableByteChannel src,long position, long count)throws IOException;
  • 在 Linux 系统中,sendFile 是一个系统调用,用于在两个进程之间通过文件描述符传输数据。它可以用于将文件内容发送到Socket,实现高效的数据传输。

  • 下面是一个通俗的解释:

    • 假设你有一个文件(比如一个大文件),你想将这个文件的内容发送到网络上的另一个进程或计算机。传统的方法是将文件内容读取到内存中,然后通过网络套接字将其发送出去。这涉及到多次数据拷贝,从文件到内存,然后从内存到网络。
  • sendFile 系统调用可以帮助你避免这些额外的数据拷贝。它允许你【直接将文件内容从文件缓冲区发送到网络套接字,而不需要将其复制到中间的内存缓冲区】。这减少了数据的拷贝次数,提高了传输效率。

  • 使用 sendFile,你可以告诉操作系统你想发送的文件描述符和网络套接字描述符,然后操作系统会负责将文件内容传输到网络上。它会在底层进行数据的读取和发送,而你不需要关心这些细节。

这样,通过使用 sendFile,你可以更高效地传输大文件,减少了内存的使用和数据拷贝的次数,提高了整体的性能。

在这里插入图片描述

  1. Java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu.
  2. 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
  3. 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
  • 只发生了一次用户态与内核态的切换
  • 数据拷贝了 3 次

在linux 2.4,可以进一步优化.

在这里插入图片描述

  1. Java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
  2. 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
  3. 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 cpu

整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是不会拷贝重复数据到 JVM 内存中,零拷贝的优点有:

  • 更少的用户态与内核态的切换
  • 不利用 cpu 计算,减少 cpu 缓存伪共享
  • 零拷贝适合小文件传输

2.4 transferTo()方法原理

Java 主要通过 FileChannel 的以下方法实现零拷贝:

方法 说明
transferTo(long position, long count, WritableByteChannel target) 将文件数据直接传输到目标通道
transferFrom(ReadableByteChannel src, long position, long count) 从源通道直接读取数据到文件

🌟 这些方法在底层会尝试使用操作系统提供的零拷贝机制,如:

  • Linux: sendfile() 系统调用
  • 支持 splice()(更高效)
内核空间
用户空间
transferTo 方法
1. DMA 拷贝
2. DMA 拷贝
FileChannel
SocketChannel
内核缓冲区
应用程序
磁盘
网卡

✅ 使用 transferTo() 的零拷贝流程

应用程序 内核空间 磁盘 网卡 1. DMA拷贝: 文件 → 内核缓冲区 2. DMA拷贝: 内核缓冲区 → 网卡 (通过 sendfile) CPU不参与数据搬运 应用程序 内核空间 磁盘 网卡
应用程序 内核空间 磁盘 网卡 1. 调用transferTo() 2. DMA: 磁盘→内核缓冲区 3. DMA: 内核缓冲区→网卡(零拷贝) 仅2次上下文切换 和2次DMA复制(无CPU复制) 应用程序 内核空间 磁盘 网卡

📊 零拷贝的优势

项目 传统 I/O 零拷贝(sendfile)
数据拷贝 4 次 2 次(均为 DMA)
上下文切换 4 次 2 次
CPU 参与 否(仅发起调用)

✅ 性能提升:减少 CPU 占用,提高吞吐量。

2.5 零拷贝实现原理

2.5.1 操作系统支持

Java 零拷贝技术依赖于操作系统提供的底层支持:

操作系统 零拷贝机制 Java 对应实现
Linux sendfile()、splice() FileChannel.transferTo()
Windows TransmitFile() FileChannel.transferTo()
macOS sendfile() FileChannel.transferTo()

以 Linux 的 sendfile() 为例,其工作流程如下

graph TD
    A["应用程序调用sendfile()"] --> B[内核检查文件描述符]
    B --> C[DMA: 磁盘→内核页缓存]
    C --> D[内核将文件数据从页缓存→Socket缓冲区]
    D --> E[DMA: Socket缓冲区→网卡]
    E --> F[返回发送的字节数给应用程序]

Linux 2.4 之后的内核进一步优化,甚至可以避免内核内部的复制,直接将页缓存中的数据描述符传递给网卡,实现真正的 “零拷贝”。

2.5.2 Java 零拷贝的 JVM 实现

Java 的零拷贝实现位于 sun.nio.ch 包中,不同平台有不同的实现类:

  • Linux: sun.nio.ch.FileChannelImpl 使用 sendfile()
  • Windows: sun.nio.ch.FileChannelImpl 使用 TransmitFile()
  • macOS: sun.nio.ch.FileChannelImpl 使用对应的系统调用

transferTo() 方法的实现伪代码

public long transferTo(long position, long count, WritableByteChannel target) {
    // 检查参数合法性
    if (position < 0 || count < 0)
        throw new IllegalArgumentException();
    
    // 如果是SocketChannel,则尝试使用零拷贝
    if (target instanceof SocketChannelImpl) {
        SocketChannelImpl sc = (SocketChannelImpl)target;
        // 调用native方法,使用底层零拷贝系统调用
        return transferToDirectly(sc, position, count);
    }
    
    // 否则使用普通方式传输
    return transferToArbitraryChannel(position, count, target);
}

// native方法,实际调用操作系统的零拷贝API
private native long transferToDirectly(SocketChannelImpl sc, long pos, long count);

linux上的sendFile系统调用

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • in_fd:输入文件描述符(如文件)

  • out_fd:输出文件描述符(如 socket)

  • 数据直接在内核中从文件缓冲区拷贝到 socket 缓冲区

  • CPU 不参与数据搬运

2.6 最佳实践

  1. 选择合适的零拷贝方式

    • 文件到网络:优先使用 FileChannel.transferTo()
    • 文件到文件:Java 9+ 用 Files.copy(),旧版本用 transferTo()
    • 网络数据处理:使用直接缓冲区和 Netty 复合缓冲区
  2. 注意缓冲区大小

  • 直接缓冲区:建议 1MB-8MB
  • 避免频繁创建和销毁直接缓冲区(成本高)
  • 考虑使用缓冲区池复用直接缓冲区
  1. 处理 transferTo () 的部分传输
// 正确处理transferTo()可能的部分传输
long position = 0;
long remaining = fileChannel.size();
while (remaining > 0) {
    long transferred = fileChannel.transferTo(position, remaining, socketChannel);
    if (transferred <= 0) {
        break; // 传输完成或出错
    }
    position += transferred;
    remaining -= transferred;
}
  1. 结合异步 IO:零拷贝 + 异步 IO 可以进一步提升性能

2.7 零拷贝技术优缺点

✅ 优点

优点 说明
减少 CPU 开销 CPU 不参与数据搬运,可用于计算
提高吞吐量 减少拷贝和切换,I/O 速度更快
降低延迟 数据路径更短
适合大文件传输 如视频、静态资源、日志同步

❌ 缺点

缺点 说明
平台依赖性 效果依赖操作系统支持(Linux 最佳)
灵活性差 无法在传输过程中修改数据
调试困难 数据不经过用户空间,难以监控
小文件收益低 设置开销可能抵消优势

2.8 最佳实践总结

✅ 推荐使用场景

场景 说明
Web 服务器静态资源 Nginx、Netty 都使用零拷贝发送文件
消息队列持久化 Kafka 使用零拷贝高效传输日志
大数据传输 HDFS、Spark shuffle
文件同步工具 如 rsync(部分模式)

⚠️ 使用建议

  1. 仅用于大文件(> 64KB)传输
  2. 确保目标通道支持(如 SocketChannel
  3. 配合 DirectBuffer 可进一步优化
  4. 监控系统调用:使用 strace 观察是否真正调用 sendfile

🧪 验证是否使用了零拷贝

# 监控 sendfile 系统调用
strace -e trace=sendfile java ZeroCopyNetworkSend

2.9 零拷贝的核心价值

维度 说明
核心思想 让数据在内核空间“直达”,避免无谓搬运
关键技术 sendfilesplice、DMA
Java 实现 FileChannel.transferTo/transferFrom
性能收益 减少 CPU 使用率,提升 I/O 吞吐量 2~3 倍
适用领域 高性能网络服务、大数据、存储系统

2.10 一句话总结

💡 一句话总结

  • 零拷贝是 “让合适的人做合适的事” 的典范——让 DMA 控制器搬运数据,让 CPU 专注逻辑计算。它是现代高性能系统(如 Netty、Kafka、Nginx)的底层基石。

网站公告

今日签到

点亮在社区的每一天
去签到