零拷贝
字数: 0 字 时长: 0 分钟
零拷贝(Zero Copy)是一种 I/O 优化技术。
它的核心目标并不是让数据真的“一次都不复制”,而是:
尽量减少数据在用户态和内核态之间的复制,以及减少 CPU 参与的数据搬运和上下文切换。
在 Java 中,零拷贝主要会接触到:
FileChannel.transferTo()
FileChannel.transferFrom()
MappedByteBuffer底层则经常涉及:
mmap
sendfile1. 用户态和内核态
理解零拷贝之前,需要先知道:
用户态 User Space
内核态 Kernel SpaceJava 程序运行在:
用户态而磁盘、网络等硬件设备需要通过:
操作系统内核进行访问。
简单来说:
Java 程序
↓
用户态
↓
System Call
↓
内核态
↓
磁盘 / 网卡当程序进行文件或者网络 IO 时,就可能发生:
用户态
↕
内核态之间的数据复制和状态切换。
2. 传统文件传输
假设现在要把一个文件发送到网络:
磁盘文件
↓
网络传统方式通常会写成:
FileInputStream input =
new FileInputStream("test.txt");
Socket socket =
new Socket("localhost", 8080);
OutputStream output =
socket.getOutputStream();
byte[] buffer =
new byte[8192];
int len;
while ((len = input.read(buffer)) != -1) {
output.write(buffer, 0, len);
}从 Java 代码上看,只是:
read()
↓
write()但底层的数据传输过程要复杂得多。
3. 传统 IO 的数据复制过程
典型情况下,数据可能经历这样的过程:
磁盘
↓
内核缓冲区
↓
用户缓冲区
↓
Socket 缓冲区
↓
网卡可以画成:
内核态 用户态
磁盘
↓
Page Cache
│
│ CPU Copy
└──────────────→ 用户 Buffer
│
│ CPU Copy
↓
Socket Buffer
↓
网卡通常可以把它理解为:
磁盘
↓
内核 Page Cache
↓
用户态 Buffer
↓
内核 Socket Buffer
↓
网卡这里既包含:
DMA Copy也可能包含:
CPU Copy传统 IO 最大的问题之一就是:
数据需要先从内核复制到用户空间,再从用户空间复制回内核空间。
也就是:
内核
↓
用户
↓
内核中间这两次数据搬运,在很多场景下其实没有必要。
4. 上下文切换
传统:
read()
write()不仅会发生数据复制,还会发生用户态和内核态之间的切换。
例如调用:
read()大致过程:
用户态
↓
read()
↓
内核态
↓
完成读取
↓
用户态然后:
write()又会发生一次类似过程。
所以:
read()
+
write()会产生多次:
用户态 ↔ 内核态上下文切换。
而零拷贝除了减少数据复制之外,通常也希望减少这种切换带来的开销。
5. 什么是零拷贝
零拷贝的核心思想就是:
如果数据最终只是从磁盘发送到网络,就没有必要先复制到 Java 用户空间,再复制回操作系统内核。
传统方式:
磁盘
↓
内核
↓
Java 用户空间
↓
内核
↓
网卡优化后,希望变成:
磁盘
↓
内核
↓
网卡也就是说:
Java 程序负责告诉操作系统:
把这段数据从这里传到那里。
而不需要真正把整块数据搬到 JVM 内存中再搬回去。
6. mmap
mmap 即:
Memory Mapped
内存映射它可以把文件的一部分区域直接映射到进程的虚拟地址空间。
传统读取:
磁盘
↓
内核缓冲区
↓
用户 Buffer使用 mmap 后:
文件
↓
内核 Page Cache
↕
用户虚拟地址空间映射程序可以像访问内存一样访问文件内容。
这样可以减少一次:
内核缓冲区
↓
用户缓冲区的数据复制。
7. Java 中的 MappedByteBuffer
Java NIO 中对应 mmap 的主要是:
MappedByteBuffer通过:
FileChannel.map()创建。
例如:
try (
RandomAccessFile file =
new RandomAccessFile(
"test.txt",
"rw"
);
FileChannel channel =
file.getChannel()
) {
MappedByteBuffer buffer =
channel.map(
FileChannel.MapMode.READ_WRITE,
0,
channel.size()
);
System.out.println(
(char) buffer.get(0)
);
}这里:
channel.map()会把文件的一部分区域映射到内存。
之后可以直接通过:
MappedByteBuffer访问。
8. MappedByteBuffer 的特点
传统文件读取:
File
↓
Channel
↓
ByteBuffer
↓
程序内存映射:
File
↓
MappedByteBuffer
↓
程序直接访问映射区域它比较适合:
大文件
随机访问文件
需要频繁访问文件中的部分数据但:
MappedByteBuffer不等于“所有场景都更快”。
因为它也存在:
映射成本
页缺失 Page Fault
资源释放
虚拟内存占用等问题。
9. sendfile
另外一种经典的零拷贝技术是:
sendfile它主要用于:
把文件数据直接发送到网络 Socket。
传统方式:
磁盘
↓
Page Cache
↓
用户 Buffer
↓
Socket Buffer
↓
网卡sendfile:
磁盘
↓
Page Cache
↓
Socket Buffer
↓
网卡这里绕过了:
用户 Buffer数据不需要进入 Java 用户空间。
10. sendfile 的进一步优化
在支持相关硬件和操作系统优化的情况下,还可以进一步减少 CPU 数据复制。
大致可以理解为:
磁盘
↓
Page Cache
↓
DMA
↓
网卡此时 CPU 更多只是负责:
描述数据在哪里
要发送多少数据而不是亲自把大量文件内容:
复制来
复制去具体的数据复制次数会受到:
操作系统
内核版本
硬件
DMA
网卡
JDK 实现等因素影响。
因此不要死记:
零拷贝 = 固定 0 次复制11. FileChannel.transferTo
Java NIO 中最典型的零拷贝 API:
FileChannel.transferTo()例如:
try (
FileChannel source =
new FileInputStream(
"source.txt"
).getChannel();
FileChannel target =
new FileOutputStream(
"target.txt"
).getChannel()
) {
source.transferTo(
0,
source.size(),
target
);
}调用:
source.transferTo()表示:
source
↓
target数据可以直接在 Channel 之间传输。
不需要自己:
ByteBuffer.allocate()然后:
read()
write()12. FileChannel.transferFrom
还有:
transferFrom()例如:
target.transferFrom(
source,
0,
source.size()
);方向:
source
↓
target区别主要是调用者不同:
source.transferTo(target)
target.transferFrom(source)可以简单记:
transferTo
↓
我把数据给别人
transferFrom
↓
我从别人那里拿数据13. transferTo 和 sendfile
在支持的操作系统和 JDK 实现中:
FileChannel.transferTo()可能利用操作系统提供的:
sendfile等机制完成高效数据传输。
从 Java 程序的角度:
FileChannel
↓
transferTo()
↓
SocketChannel可以避免自己手动:
FileChannel.read()
↓
ByteBuffer
↓
SocketChannel.write()例如:
FileChannel fileChannel =
new FileInputStream(
"video.mp4"
).getChannel();
SocketChannel socketChannel =
SocketChannel.open();
fileChannel.transferTo(
0,
fileChannel.size(),
socketChannel
);概念上:
传统:
File
↓
FileChannel
↓
ByteBuffer
↓
Java
↓
SocketChannel
transferTo:
File
↓
FileChannel
↓
操作系统优化
↓
SocketChannel具体是否使用真正的底层零拷贝机制,仍然取决于:
操作系统
JDK
Channel 类型
具体实现14. 传统 IO 与零拷贝对比
传统 IO:
磁盘
↓
内核缓冲区
↓
用户缓冲区
↓
Socket 缓冲区
↓
网卡零拷贝优化:
磁盘
↓
内核缓冲区
↓
Socket / 网卡核心减少的是:
内核态
↓
用户态
↓
内核态这种不必要的数据搬运。
简单对比:
| 对比 | 传统 IO | 零拷贝 |
|---|---|---|
| 用户态参与 | 较多 | 较少 |
| CPU 数据复制 | 较多 | 减少 |
| 上下文切换 | 较多 | 可以减少 |
| JVM Buffer | 通常需要 | 某些场景可绕过 |
| 大文件传输 | 开销较大 | 更有优势 |
15. 零拷贝真的是 0 次复制吗?
不是。
这是学习零拷贝最容易出现的误区。
所谓:
Zero Copy更多是指:
尽量减少 CPU 参与的数据复制,特别是减少用户空间和内核空间之间的数据复制。
例如磁盘数据仍然需要:
磁盘
↓
内存网卡发送数据也需要:
内存
↓
网卡这些数据移动依然存在。
因此:
零拷贝
≠
物理意义上的 0 次数据复制更准确的理解是:
减少无意义的数据复制
+
减少 CPU 参与
+
减少用户态 / 内核态切换16. 零拷贝的应用场景
零拷贝特别适合:
文件服务器
大文件传输
消息队列
网络框架
静态资源服务器常见技术中也经常会接触到零拷贝相关设计:
Kafka
Netty例如 Kafka 这类消息系统需要大量进行:
磁盘
↓
网络的数据传输。
如果每条消息都需要:
磁盘
↓
内核
↓
JVM
↓
内核
↓
网卡会产生大量额外的数据复制。
利用零拷贝思想,可以减少这部分开销。
17. mmap 和 sendfile 的区别
两者虽然都属于常见的零拷贝优化方式,但应用场景不同。
mmap
文件
↓
映射到进程地址空间
↓
程序可以直接访问更适合:
程序需要读取 / 修改文件内容
随机访问文件Java:
MappedByteBuffersendfile
文件
↓
直接发送到 Socket更适合:
程序本身不需要处理文件内容
只需要把文件发送出去Java 中典型对应:
FileChannel.transferTo()可以简单理解:
mmap
↓
我要访问这个文件的数据
sendfile
↓
我不关心数据内容
直接帮我发出去18. Java 中的零拷贝
Java 中主要记住:
MappedByteBuffer
↓
FileChannel.map()
↓
mmap 思想以及:
FileChannel.transferTo()
FileChannel.transferFrom()
↓
Channel 之间直接传输
↓
某些平台可利用 sendfile 等底层优化关系:
Java 零拷贝
│
┌─────────┴─────────┐
│ │
MappedByteBuffer transferTo()
│ transferFrom()
│ │
mmap sendfile 等优化19. 小结
传统 IO 传输文件时,数据可能经历:
磁盘
↓
内核
↓
用户空间
↓
内核
↓
网卡其中:
内核
↓
用户
↓
内核存在额外的数据复制。
零拷贝的核心思想就是:
让数据尽可能直接在内核、文件和网络设备之间传输,减少用户态参与的数据搬运。
Java 中主要关注:
MappedByteBuffer
↓
mmap
FileChannel.transferTo()
FileChannel.transferFrom()
↓
sendfile 等底层优化最终可以记成:
传统 IO
↓
数据经过 JVM
↓
复制次数较多
零拷贝
↓
尽可能绕过 JVM 用户空间
↓
减少 CPU Copy
↓
减少上下文切换
↓
提高大数据传输效率一句话理解:
零拷贝不是“完全不复制”,而是尽量不让数据在用户态和内核态之间来回搬运。