FileChannel 高速拷贝文件的秘密

最近在做性能优化,发现 Java NIO 中的 FileChannel 类的 transfer 相关方法,能显著提升文件拷贝速度(自测减少耗时 30-70% 左右)。

官方的说法是,利用了文件系统的缓存:

This method is potentially much more efficient than a simple loop that reads from the source channel and writes to this channel.
Many operating systems can transfer bytes directly from the source channel into the filesystem cache without actually copying them.

民间则有人提到,利用了 DMA (Direct Memory Access):

The key advantage here is that the JVM uses the OS’s access to DMA (Direct Memory Access), if present.
What happens is the data goes straight to/from disc, to the bus, and then to the destination, by passing any circuit through RAM or the CPU.

《计算机组成原理》当年学到的几乎已恢复出厂设置,通过 回 (chá) 忆 (yuè) 还是发现 DMA 和 NIO 确实有不少类似的地方:

  • DMA 不会一直占用 CPU 时间去执行 IO,而且是执行完通过中断去通知;而 NIO 也是异步非阻塞的;
  • DMA 和 NIO 都有 Channel 的概念;

下面还是先通过跟踪源代码来看看。

首先 FileInputStream / FileOutputStream 内部均持有一个 FileChannel 对象,实现类为 FileChannelImpl

其 transfer 相关方法内部调用了 writeImpl() 方法,核心部分又调用了 Libcore.oswrite(fd, buffer, position) 方法 :

private int writeImpl(ByteBuffer buffer, long position) throws IOException {
    ...
    bytesWritten = Libcore.os.pwrite(fd, buffer, position);
    ...
}

而跟踪传统 FileOutputStream 的 write 相关方法,则发现调用的是 Libcore.oswrite(fd, bytes, byteOffset, byteCount) 方法:

public static void write(FileDescriptor fd, byte[] bytes, int byteOffset, int byteCount) throws IOException {
    ...
    int bytesWritten = Libcore.os.write(fd, bytes, byteOffset, byteCount);
    ...
}

可以看到,主要是第二个参数不同:一个是 ByteBuffer,一个是 byte[]

再到 native 层的 libcore_io_Posix.cpp中看看两个相关方法:

static jint Posix_writev(JNIEnv* env, jobject, jobject javaFd, jobjectArray buffers, jintArray offsets, jintArray byteCounts) {
    ...
    return IO_FAILURE_RETRY(env, ssize_t, writev, javaFd, ioVec.get(), ioVec.size());
}

static jint Posix_writeBytes(JNIEnv* env, jobject, jobject javaFd, jbyteArray javaBytes, jint byteOffset, jint byteCount) {
    ...
    return IO_FAILURE_RETRY(env, ssize_t, write, javaFd, bytes.get() + byteOffset, byteCount);
}

核心的就是 IO_FAILURE_RETRY 这个宏定义,而且两个方法唯一的区别就是第三个参数不同:writevwrite;

再看这个宏定义的内容:

#define IO_FAILURE_RETRY(jni_env, return_type, syscall_name, java_fd, ...) ({ \
    ... \
    _rc = syscall_name(_fd, __VA_ARGS__); \
    ... \
})

原来第三个参数就是系统调用的名称。然后通过 writev.Swrite.S 看看两个系统调用的汇编实现:

ENTRY(writev)
    ...
    ldr     r7, =__NR_writev
    ...
END(writev)
ENTRY(write)
    ...
    ldr     r7, =__NR_write
    ...
END(write)

可以看到,差别也是系统调用号不同:__NR_WRITEV__NR_WRITE;

值得一提的是,Linux 对应的也有 writev()write() 两个函数,前者也是用于写 buffer。

代码跟到这里已经到底了,但总感觉没这么简单,而且丝毫没看出来和前文提到的 DMA 有任何瓜葛。

于是 Google 搜索 writev DMA,在《Linux 中的零拷贝技术》这篇文章中看到,mmap() 方法可减少拷贝次数,而且底层正是应用了 DMA:

在 Linux 中,减少拷贝次数的一种方法是调用 mmap() 来代替调用 read;
首先,应用程序调用了 mmap() 之后,数据会先通过 DMA 拷贝到操作系统内核的缓冲区中去。
接着,应用程序跟操作系统共享这个缓冲区,这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。

《The GNU C Library: Memory-mapped I/O》 的相关介绍:

On modern operating systems, it is possible to mmap a file to a region of memory.
When this is done, the file can be accessed just like an array in the program.
This is more efficient than read or write, as only the regions of the file that a program actually accesses are loaded.

mmap 和 DMA 相关的技术细节这里不再深究,有兴趣的可参考 《Linux Device Drivers, 2nd Edition: Chapter 13: mmap and DMA》

让我们再回到 FileChannelImpl 类的 transferFrom() 方法:

public long transferFrom(ReadableByteChannel src, long position, long count) throws IOException {
    ...
    ByteBuffer buffer = fileSrc.map(MapMode.READ_ONLY, filePosition, count);
    ...
}

发现调用了 map() 方法,可能和 mmap 有关联;再进入 map() 方法:

public final MappedByteBuffer map(MapMode mapMode, long position, long size) throws IOException {
    ...
    MemoryBlock block = MemoryBlock.mmap(fd, alignment, size + offset, mapMode);
    ...
}

果然调用了 MemoryBlockmmap() 方法,内部又调用了 Libcore.osmmap() 方法:

public static MemoryBlock mmap(FileDescriptor fd, long offset, long size, MapMode mapMode) throws IOException {
    ...
    long address = Libcore.os.mmap(0L, size, prot, flags, fd, offset);
    ...
}

最后进入 native 层的 libcore_io_Posix.cpp,正是调用了 Linux 层的 mmap()

static jlong Posix_mmap(JNIEnv* env, jobject, jlong address, jlong byteCount, jint prot, jint flags, jobject javaFd, jlong offset) {
    ...
    void* ptr = mmap(suggestedPtr, byteCount, prot, flags, fd, offset);
    ...
}

最后看看版本支持。

Java 层从 API level 1 就支持 NIO 了, 而 native 层从 4.0 开始就能找到 libcore_os_Posix.cpp 中对 mmap 的调用;那 4.0 以前呢?

你问我资瓷不资瓷,当然是资瓷嘀,只不过相关代码的结构和调用关系有较大差异:

dma-buf-sharing.txt 这篇文档详细介绍了 kernel 对 DMA buffer 相关接口的定义;可以看到,Android 也是支持的,而 mmap() 最终的内核调用接口为 dma_buf_mmap()

总结:

  • 写入前,底层调用 mmap(),利用 OS 层的内存映射和硬件层的 DMA 技术,直接将文件映射到内存空间,减少拷贝次数,便于快速访问;
  • 写入时,底层为 writev 系统调用,能利用 buffer;