一、为什么需要零拷贝?一个真实的痛点
假设你正在开发一个文件下载服务,用户请求下载一个 1GB 的视频文件。传统方式下,这个简单的需求背后发生了什么?
// 传统方式:看似简单,实则低效
File file = new File("video.mp4");
FileInputStream in = new FileInputStream(file);
OutputStream out = socket.getOutputStream();
byte[] buffer = new byte[4096];
while(in.read(buffer) >= 0) {
out.write(buffer);
}
这段代码的执行过程令人震惊:数据被复制了 4 次,发生了 4 次上下文切换!
让我用一个快递配送的类比来说明:
-
传统方式:快递从仓库→配送站→分拣中心→配送员→你家,每一站都要拆包检查再重新打包
-
零拷贝:快递直接从仓库→你家,中间只做必要的路由,不拆包
二、传统 I/O 的四次拷贝噩梦
2.1 完整的数据流转过程

2.2 详细拆解每一步
第一次拷贝(DMA): 磁盘 → 内核缓冲区
-
磁盘控制器将数据读入内核空间的页缓存
-
这一步由 DMA(直接内存访问)完成,不占用 CPU
-
为什么需要? 操作系统需要缓存磁盘数据以提高性能
第二次拷贝(CPU): 内核缓冲区 → 用户空间
-
调用 read() 系统调用
-
CPU 将数据从内核空间复制到应用程序的缓冲区
-
问题所在: 这是完全多余的一次拷贝!
第三次拷贝(CPU): 用户空间 → Socket 缓冲区
-
调用 write() 系统调用
-
CPU 将数据从用户空间复制到内核的 Socket 缓冲区
-
又一次浪费: 数据刚从内核来,又回到内核
第四次拷贝(DMA): Socket 缓冲区 → 网卡
-
网卡通过 DMA 将数据发送出去
-
不占用 CPU
2.3 上下文切换的开销
除了数据拷贝,还有 4 次上下文切换:
-
read() 调用:用户态 → 内核态
-
read() 返回:内核态 → 用户态
-
write() 调用:用户态 → 内核态
-
write() 返回:内核态 → 用户态
举个例子:想象你在办公室(用户态)和档案室(内核态)之间来回跑,每次切换都要刷卡、登记,这个过程本身就很耗时。
三、DMA:零拷贝的硬件基础
3.1 什么是 DMA?
DMA(Direct Memory Access)是一种硬件机制,允许外设直接访问内存,无需 CPU 参与每个字节的传输。
没有 DMA 的时代(想象一下有多痛苦):
// CPU 需要逐字节搬运数据
for(int i = 0; i < fileSize; i++) {
byte b = disk.readByte(); // CPU 等待
memory[i] = b; // CPU 写入
}
// CPU 完全被 I/O 占用,无法做其他事
有了 DMA:
// CPU 只需发起命令
dma.transfer(disk, memory, fileSize);
// CPU 立即去做其他事
// DMA 完成后通过中断通知 CPU
3.2 DMA 的工作流程

四、零拷贝技术的三种武器
4.1 mmap (内存映射)
核心思想:将文件直接映射到用户空间,省去内核缓冲区到用户空间的拷贝。
// Java 中的 MappedByteBuffer
RandomAccessFile file = new RandomAccessFile("data.txt", "r");
FileChannel channel = file.getChannel();
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_ONLY, 0, channel.size()
);
// 直接读取,无需拷贝到用户空间
while(buffer.hasRemaining()) {
socket.write(buffer);
}
数据流转(减少到 3 次拷贝):

优点:
-
减少一次 CPU 拷贝
-
大文件随机访问效率高
-
多个进程可共享映射区域
缺点:
-
映射建立有开销
-
小文件不划算
-
文件被截断时可能导致 SIGBUS 信号
适用场景:大文件的随机读写,如数据库索引文件。
4.2 sendfile (真正的零拷贝)
核心思想:数据完全在内核空间流转,彻底不经过用户空间。
// Java NIO 的 transferTo
FileChannel fileChannel = new FileInputStream("source.txt").getChannel();
SocketChannel socketChannel = SocketChannel.open(
new InetSocketAddress("localhost", 8080)
);
// 魔法就在这里:零拷贝传输
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
Linux 底层实现:
// sendfile 系统调用
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
数据流转(仅 2 次拷贝):

等等,不是说零拷贝吗?为什么还有 2 次?
这里的"零"指的是零次 CPU 拷贝和零次经过用户空间。两次 DMA 拷贝是硬件必需的,不占用 CPU。
4.3 sendfile + DMA gather copy (终极形态)
在支持 scatter-gather DMA 的硬件上,可以进一步优化:
数据流转(真正的 1 次拷贝):

关键技术: Socket 缓冲区不复制数据,只记录数据在内核缓冲区的位置,网卡通过 scatter-gather DMA 直接从内核缓冲区读取。
五、Netty 的零拷贝实现
5.1 Netty 的四个层次
Netty 的零拷贝不仅仅是操作系统层面,还包括应用层面的优化:
层次一:操作系统级零拷贝(FileRegion)
// Netty 发送文件的最佳实践
public void sendFile(ChannelHandlerContext ctx, File file) {
RandomAccessFile raf = new RandomAccessFile(file, "r");
FileChannel fileChannel = raf.getChannel();
// DefaultFileRegion 底层使用 transferTo
DefaultFileRegion region = new DefaultFileRegion(
fileChannel, 0, fileChannel.size()
);
// 零拷贝发送
ctx.writeAndFlush(region).addListener(future -> {
fileChannel.close();
raf.close();
});
}
FileRegion 的工作原理:

层次二:DirectByteBuffer(堆外内存)
// 传统 HeapByteBuffer
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
// 写入 socket 时:堆内存 → 直接内存 → 内核 → 网卡
// DirectByteBuffer
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
// 写入 socket 时:直接内存 → 内核 → 网卡 (少一次拷贝)
为什么需要直接内存?
JVM 堆内存可能被 GC 移动,操作系统无法直接访问。必须先拷贝到堆外,才能进行 DMA 传输。
Netty 的优化:
// Netty 的 PooledByteBufAllocator
ByteBuf buffer = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
// 使用池化的直接内存,避免频繁分配/释放
层次三:CompositeByteBuf(逻辑组合)
场景:HTTP 响应 = Header + Body,传统方式需要合并:
// 传统方式:需要拷贝
ByteBuf header = ...;
ByteBuf body = ...;
ByteBuf combined = Unpooled.buffer(header.readableBytes() + body.readableBytes());
combined.writeBytes(header); // 拷贝!
combined.writeBytes(body); // 拷贝!
Netty 的零拷贝方式:
// CompositeByteBuf:逻辑组合,不拷贝数据
CompositeByteBuf combined = Unpooled.compositeBuffer();
combined.addComponents(true, header, body);
// 底层只是持有多个 ByteBuf 的引用
ctx.writeAndFlush(combined);
底层结构:
CompositeByteBuf
├─ Component[0] → header ByteBuf (引用)
└─ Component[1] → body ByteBuf (引用)
层次四:切片操作(slice/duplicate)
ByteBuf original = ...;
// slice:创建视图,共享底层数组
ByteBuf slice = original.slice(10, 20); // 零拷贝!
// duplicate:创建完整视图
ByteBuf duplicate = original.duplicate(); // 零拷贝!
// 注意:修改 slice 会影响 original
slice.setByte(0, 'X'); // original 的第 10 个字节也变了
5.2 FileRegion 的源码剖析
public class DefaultFileRegion extends AbstractReferenceCounted implements FileRegion {
private final FileChannel file;
private final long position;
private final long count;
@Override
public long transferTo(WritableByteChannel target, long position) throws IOException {
// 核心:调用 FileChannel.transferTo
long transferred = file.transferTo(this.position + position, count, target);
return transferred;
}
}
Netty 写出 FileRegion 的流程:

5.3 实战:高性能文件服务器
public class FileServer {
public static void main(String[] args) {
ServerBootstrap b = new ServerBootstrap();
b.group(new NioEventLoopGroup(1), new NioEventLoopGroup())
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(
new HttpServerCodec(),
new HttpObjectAggregator(65536),
new FileServerHandler()
);
}
});
b.bind(8080);
}
}
class FileServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
String uri = req.uri();
File file = new File("/data" + uri);
if (!file.exists()) {
send404(ctx);
return;
}
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
// 1. 发送 HTTP 头
HttpResponse response = new DefaultHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.OK
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, fileLength);
ctx.write(response);
// 2. 零拷贝发送文件内容
DefaultFileRegion region = new DefaultFileRegion(
raf.getChannel(), 0, fileLength
);
ctx.writeAndFlush(region).addListener(future -> {
raf.close();
});
// 3. 发送结束标记
ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
}
}
性能对比:
-
传统 InputStream/OutputStream:处理 1GB 文件需要 4 次拷贝 + 大量 CPU
-
FileRegion:处理 1GB 文件只需 2 次 DMA,CPU 接近零占用
实测结果:相同硬件下,吞吐量提升 3-5 倍!
六、零拷贝的边界与限制
6.1 不是银弹
何时不适用零拷贝?
-
需要处理数据:如果要加密、压缩,必须经过 CPU 处理
-
// 这种场景无法零拷贝 byte[] data = readFile(); byte[] encrypted = encrypt(data); // 必须在用户空间处理 socket.write(encrypted); -
小文件:零拷贝的设置开销可能大于收益
-
// 对于 < 64KB 的文件,直接读写可能更快 if (file.length() < 65536) { traditionalCopy(); // 更快 } else { zeroCopy(); }
-
非顺序访问:sendfile 只支持顺序传输
6.2 跨平台差异
|
操作系统 |
支持的零拷贝技术 |
备注 |
|
Linux |
sendfile, splice, mmap |
最完善 |
|
Windows |
TransmitFile |
语义略有不同 |
|
macOS |
sendfile (BSD 风格) |
参数与 Linux 不同 |
Netty 的处理:自动检测平台,选择最优实现
if (PlatformDependent.hasUnsafe()) {
// Linux: 使用 epoll + sendfile
} else {
// 回退到 NIO
}
七、总结:零拷贝的本质
零拷贝不是一项技术,而是一系列优化策略:
-
硬件层:DMA 解放 CPU
-
内核层:sendfile 避免用户空间
-
应用层:DirectBuffer、CompositeByteBuf 减少拷贝
-
业务层:选择合适的场景应用
记住这个金字塔:
业务选型
/ \
应用层优化 内核优化
\ /
硬件基础(DMA)
零拷贝的精髓:让数据尽可能少地移动,让 CPU 尽可能少地参与。
在实际开发中,理解原理比记住 API 更重要。当你遇到性能瓶颈时,先问自己:数据被拷贝了几次?CPU 做了多少无用功?答案往往就在其中。

1484

被折叠的 条评论
为什么被折叠?



