文章目录
一、网络传输
基本的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 是一种用于在计算机系统的不同组件之间快速传输数据的技术,它可以提高系统的性能和效率。
从内核态切换为用户态, 将数据从
内核缓冲区
拷贝到用户缓冲区(byte[] buf)
当中.这期间cpu会参与拷贝, 无法再利用DMA
了.调用write方法, 这时从
用户缓冲区(byte[] buf)
写入socket
缓冲区, cpu会参与拷贝.接下来要向网卡写入数据, 网卡属于硬件设备, Java无法直接操作,此时会从
用户态
切换为内核态
, 调用操作系统写的能力, 使用DMA
将Socket缓冲区
的数据写入网卡, 不会使用到cpu.
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 对象被垃圾回收,将虚引用加入引用队列
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
- 减少了一次数据拷贝,用户态与内核态的切换次数没有减少
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
,你可以更高效地传输大文件,减少了内存的使用和数据拷贝的次数,提高了整体的性能。
- Java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu.
- 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
- 只发生了一次用户态与内核态的切换
- 数据拷贝了 3 次
在linux 2.4,可以进一步优化.
- Java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
- 使用 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()
的零拷贝流程
📊 零拷贝的优势
项目 | 传统 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 最佳实践
选择合适的零拷贝方式:
- 文件到网络:优先使用
FileChannel.transferTo()
- 文件到文件:Java 9+ 用
Files.copy()
,旧版本用transferTo()
- 网络数据处理:使用直接缓冲区和 Netty 复合缓冲区
- 文件到网络:优先使用
注意缓冲区大小:
- 直接缓冲区:建议 1MB-8MB
- 避免频繁创建和销毁直接缓冲区(成本高)
- 考虑使用缓冲区池复用直接缓冲区
- 处理 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;
}
- 结合异步 IO:零拷贝 + 异步 IO 可以进一步提升性能
2.7 零拷贝技术优缺点
✅ 优点
优点 | 说明 |
---|---|
减少 CPU 开销 | CPU 不参与数据搬运,可用于计算 |
提高吞吐量 | 减少拷贝和切换,I/O 速度更快 |
降低延迟 | 数据路径更短 |
适合大文件传输 | 如视频、静态资源、日志同步 |
❌ 缺点
缺点 | 说明 |
---|---|
平台依赖性 | 效果依赖操作系统支持(Linux 最佳) |
灵活性差 | 无法在传输过程中修改数据 |
调试困难 | 数据不经过用户空间,难以监控 |
小文件收益低 | 设置开销可能抵消优势 |
2.8 最佳实践总结
✅ 推荐使用场景
场景 | 说明 |
---|---|
Web 服务器静态资源 | Nginx、Netty 都使用零拷贝发送文件 |
消息队列持久化 | Kafka 使用零拷贝高效传输日志 |
大数据传输 | HDFS、Spark shuffle |
文件同步工具 | 如 rsync(部分模式) |
⚠️ 使用建议
- 仅用于大文件(> 64KB)传输
- 确保目标通道支持(如
SocketChannel
) - 配合 DirectBuffer 可进一步优化
- 监控系统调用:使用
strace
观察是否真正调用sendfile
🧪 验证是否使用了零拷贝
# 监控 sendfile 系统调用
strace -e trace=sendfile java ZeroCopyNetworkSend
2.9 零拷贝的核心价值
维度 | 说明 |
---|---|
核心思想 | 让数据在内核空间“直达”,避免无谓搬运 |
关键技术 | sendfile 、splice 、DMA |
Java 实现 | FileChannel.transferTo/transferFrom |
性能收益 | 减少 CPU 使用率,提升 I/O 吞吐量 2~3 倍 |
适用领域 | 高性能网络服务、大数据、存储系统 |
2.10 一句话总结
💡 一句话总结:
- 零拷贝是 “让合适的人做合适的事” 的典范——让 DMA 控制器搬运数据,让 CPU 专注逻辑计算。它是现代高性能系统(如 Netty、Kafka、Nginx)的底层基石。