在正式开始我们的原理解析之旅之前,我们先来回忆一下零拷贝的实现方案
-
直接内存访问(DMA)
DMA 是一种硬件特性,允许外设(如网络适配器、磁盘控制器等)直接访问系统内存,而无需通过 CPU 的介入。在数据传输时,DMA 可以直接将数据从内存传输到外设,或者从外设传输数据到内存,避免了数据在用户态和内核态之间的多次拷贝。
如上图所示,内核将数据读取的大部分数据读取操作都交个了 DMA 控制器,而空出来的资源就可以去处理其他的任务了。
- sendfile
一些操作系统(例如 Linux、MacOS)提供了特殊的系统调用,如 sendfile,在网络传输文件时实现零拷贝。通过 sendfile,应用程序可以直接将文件数据从文件系统传输到网络套接字或者目标文件,而无需经过用户缓冲区和内核缓冲区。
如果不用sendfile,如果将A文件写入B文件。
需要先将A文件的数据拷贝到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区; 然后内核再将用户缓冲区的数据拷贝到内核缓冲区,之后才能写入到B文件;
而用了sendfile,用户缓冲区和内核缓冲区的拷贝都不用了,节省了一大部分的开销。
- 共享内存
使用共享内存技术,应用程序和内核可以共享同一块内存区域,避免在用户态和内核态之间进行数据拷贝。应用程序可以直接将数据写入共享内存,然后内核可以直接从共享内存中读取数据进行传输,或者反之。
通过共享一块儿内存区域,实现数据的共享。就像程序中的引用对象一样,实际上就是一个指针、一个地址。
- 内存映射文件(Memory-mapped Files)
内存映射文件直接将磁盘文件映射到应用程序的地址空间,使得应用程序可以直接在内存中读取和写入文件数据,这样一来,对映射内容的修改就是直接的反应到实际的文件中。
当文件数据需要传输时,内核可以直接从内存映射区域读取数据进行传输,避免了数据在用户态和内核态之间的额外拷贝。
虽然看上去感觉和共享内存没什么差别,但是两者的实现方式完全不同,一个是共享地址,一个是映射文件内容。
正文
接下来我将使用java对零拷贝方案进行实现
Java 实现零拷贝的方式
Java 标准的 IO 库是没有零拷贝方式的实现的,标准IO就相当于上面所说的传统模式。只是在 Java 推出的 NIO 中,才包含了一套新的 I/O 类,如 ByteBuffer 和 Channel,它们可以在一定程度上实现零拷贝。
ByteBuffer:可以直接操作字节数据,避免了数据在用户态和内核态之间的复制。
Channel:支持直接将数据从文件通道或网络通道传输到另一个通道,实现文件和网络的零拷贝传输。
借助这两种对象,结合 NIO 中的API,我们就能在 Java 中实现零拷贝了。
首先我们先用传统 IO 写一个方法,用来和后面的 NIO 作对比,这个程序的目的很简单,就是将一个100M左右的PDF文件从一个目录拷贝到另一个目录。
java
public static void ioCopy() {
try {
File sourceFile = new File(SOURCE_FILE_PATH);
File targetFile = new File(TARGET_FILE_PATH);
try (FileInputStream fis = new FileInputStream(sourceFile);
FileOutputStream fos = new FileOutputStream(targetFile)) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
System.out.println("传输 " + formatFileSize(sourceFile.length()) + " 字节到目标文件");
} catch (IOException e) {
e.printStackTrace();
}
}
传输 109.92 M 字节到目标文件
耗时: 1.290 秒
FileChannel.transferTo() 和 transferFrom()
FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。
这两个方法首选用 sendfile 方式,只要当前操作系统支持,就用 sendfile,例如Linux或MacOS。如果系统不支持,例如windows,则采用内存映射文件的方式实现。
transferTo
java
public static void nioTransferTo() {
try {
File sourceFile = new File(SOURCE_FILE_PATH);
File targetFile = new File(TARGET_FILE_PATH);
try (FileChannel sourceChannel = new RandomAccessFile(sourceFile, "r").getChannel();
FileChannel targetChannel = new RandomAccessFile(targetFile, "rw").getChannel()) {
long transferredBytes = sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);
System.out.println("传输 " + formatFileSize(transferredBytes) + " 字节到目标文件");
}
} catch (IOException e) {
e.printStackTrace();
}
}
传输 109.92 M 字节到目标文件
耗时: 0.536 秒
transferFrom()
JAVA
public static void nioTransferFrom() {
try {
File sourceFile = new File(SOURCE_FILE_PATH);
File targetFile = new File(TARGET_FILE_PATH);
try (FileChannel sourceChannel = new RandomAccessFile(sourceFile, "r").getChannel();
FileChannel targetChannel = new RandomAccessFile(targetFile, "rw").getChannel()) {
long transferredBytes = targetChannel.transferFrom(sourceChannel, 0, sourceChannel.size());
System.out.println("传输 " + formatFileSize(transferredBytes) + " 字节到目标文件");
}
} catch (IOException e) {
e.printStackTrace();
}
}
传输 109.92 M 字节到目标文件
耗时: 0.603 秒
MappedByteBuffer
MappedByteBuffer 是 NIO 基于内存映射(mmap) 这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。抽象方法 map() 方法在 FileChannel 中的定义如下:
java
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
参数:
- mode -- FileChannel.MapMode 类中定义的常量 READ_ONLY、READ_WRITE 或 PRIVATE 之一,具体取决于文件是以只读、读写还是私有方式(写入时复制)进行映射。
- position -- 文件中映射区域要开始的位置; must be non-negative
- size (必须为非负大小) -- 要映射的区域的大小;必须为非负值且不大于 Integer.MAX_VALUE
MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三个重要的方法:
-
fore() :对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。
-
load() :将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。
-
isLoaded() :如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。
使用示例:
java
public class Test {
private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";
private final static String FILE_NAME = "/mmap.txt";
private final static String CHARSET = "UTF-8";
/*
写文件数据:打开文件通道 fileChannel 并提供读权限、写权限和数据清空权限,
通过 fileChannel 映射到一个可写的内存缓冲区 mappedByteBuffer,
将目标数据写入 mappedByteBuffer,
通过 `force()` 方法把缓冲区更改的内容强制写入本地文件。
*/
public void writeToFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
if (mappedByteBuffer != null) {
mappedByteBuffer.put(bytes);
mappedByteBuffer.force();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/*
读文件数据:打开文件通道 fileChannel 并提供只读权限,
通过 fileChannel 映射到一个只可读的内存缓冲区 mappedByteBuffer,
读取 mappedByteBuffer 中的字节数组即可得到文件数据。
*/
public void readFromFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
int length = CONTENT.getBytes(Charset.forName(CHARSET)).length;
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
if (mappedByteBuffer != null) {
byte[] bytes = new byte[length];
mappedByteBuffer.get(bytes);
String content = new String(bytes, StandardCharsets.UTF_8);
assertEquals(content, "Zero copy implemented by MappedByteBuffer");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
MappedByteBuffer的map()方法实现原理
map()
方法是 java.nio.channels.FileChannel
的抽象方法,由子类 sun.nio.ch.FileChannelImpl.java
实现,下面是和内存映射相关的核心代码:
注意:跳过了加锁、判断等逻辑,只保留了map处理的核心代码
java
pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
mapSize = size + pagePosition;
try {
// If map0 did not throw an exception, the address is valid
// 通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted
// memory so force gc and re-attempt map
/*
文件映射需要在 Java 堆中创建一个 MappedByteBuffer 的实例。
如果第一次文件映射导致 OOM,则手动触发垃圾回收,
休眠 100ms 后再尝试映射,如果失败则抛出异常。
*/
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
/*
通过 Util 的 newMappedByteBuffer (可读可写)方法
或者 newMappedByteBufferR(仅读)
方法方法反射创建一个 DirectByteBuffer 实例,
其中 DirectByteBuffer 是 MappedByteBuffer 的子类。
*/
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}