前文我们介绍了 Java I/O 的底层原理,想必大家都知道类似 Netty、KafKa 等大数据量高吞吐框架都会提到一个概念 Zero Copy(零拷贝),这是什么技术呢,今天我们来学习下。
一、为什么需要 Zero Copy技术?
要想了解 zero-copy 我们需要知道该技术的应用场景,网络传输中一个基本的场景是:通过网络传输一个文件,按照一般的思路,用Java语言来描述发送端的逻辑,大致如下。
Socket socket = new Socket(HOST, PORT);
InputStream inputStream = new FileInputStream(FILE_PATH);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
while (inputStream.read(buffer) >= 0) {
outputStream.write(buffer);
}
看起来是很简单的,但是如果我们深入到操作系统的层面,就会发现实际的微观操作要更复杂。在这个场景中,至少出现 4 次数据拷贝和 3 次的内核态和用户态的切换。具体来说有以下步骤:
1、JVM 发出 read() 系统调用,触发上下文切换,从用户态切换到内核态。第一次 copy 是通过 DMA 引擎直接从硬盘文件系统读取文件内容存储在内核缓存空间。
2、将数据从内核缓冲区拷贝到用户空间缓冲区,read() 系统调用返回,并从内核态切换回用户态。
3、JVM发出 write() 系统调用,触发上下文切换,从用户态切换到内核态,将数据从用户缓冲区拷贝到内核中与目的地 Socket 关联的缓冲区。
4、数据最终经由 Socket 通过 DMA 传送到硬件(如网卡)缓冲区,write() 系统调用返回,并从内核态切换回用户态。
我们都知道,上下文切换是 CPU 密集型的工作,数据拷贝是 I/O 密集型的工作(至于为啥有内核缓冲与进程缓冲区,可以看这篇文章《10分钟看懂 Java IO 底层原理》)。如果一次简单的传输就要像上面这样复杂的话,效率是相当低下的。Zreo Copy (零拷贝)机制的终极目标,就是消除冗余的上下文切换和数据拷贝,提高效率。
二、Zero Copy 原理
通过上面的分析可以看出,第 2、3 次拷贝(也就是从内核空间到用户空间的来回复制)是没有意义的,数据应该可以直接从内核缓冲区直接送入 Socket 缓冲区。Zero Copy这个技术就是来解决这个问题,不过零拷贝需要由操作系统直接支持,不同操作系统有不同的实现方法。
关于零拷贝提供了两种解决方式:mmap + write 方式、sendfile 方式
1、虚拟内存
所有现代操作系统都使用虚拟内存,使用虚拟地址取代物理地址,这样做的好处就是:
1)多个虚拟内存可以指向同一个物理地址
2)虚拟内存空间可以远远大于物理内存空间
利用第一条特性可以优化一下上面的设计思路,就是把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样就不需要来回复制了:
2、mmap+write方式
使用 mmap+write 方式替换原来的传统 IO 方式,就是利用了虚拟内存的特性。mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系;这样就可以省掉原来内核 Read Buffer copy 数据到用户缓冲区,但是还是需要内核 Read Buffer 将数据 copy 到内核 Socket Buffer,如下图:
整体流程的核心区别就是,把数据读取到内核缓冲区后,应用程序进行写入操作时,直接是把内核 Read Buffer 的数据复制到 Socket Buffer 以便进行写入,这次内核之间的复制也是需要 CPU 参与的。
这个流程就少了一个CPU Copy,提升了 IO 的速度。不过发现上下文的切换还是 4 次,没有减少,因为还是要应用程序发起 write 操作。那能不能减少上下文切换呢?
3、sendfile方式
为了简化用户接口,同时减少 CPU 的拷贝次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。通过 sendfile 传送文件只需要一次系统调用,当调用 sendfile 时:1)首先(通过 DMA )将数据从磁盘读取到内核 Read Buffer 中;
2)然后将内核 Read Buffer 的数据拷贝到 Socket buffer 中;
3)最后将 Socket buffer 中的数据 copy 到网卡设备中发送;
到这里就只有 3 次 Copy,其中只有 1 次 CPU Copy;3 次上下文切换。那能不能把CPU Copy减少到没有呢?
Linux2.4内核进行了优化,提供了gather操作,这个操作可以把最后一次CPU Copy去除,什么原理呢?就是在内核空间 Read Buffer 和 Socket Buffer 不做数据复制,*而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不需要复制(其实本质就是和虚拟内存的解决方法思路一样,就是内存地址的记录)
三、Java Zero Copy
Java 的 Zero Copy 是由 Java NIO 来提供的,NIO 三大核心要素 :Buffer(缓冲区)、Channel(通道)和 Selector(选择器),Buffer 和Channel 组合实现了Java 的 Zero Copy,主要是由 MappedByteBuffer、DirectByteBuffer 以及 FileChannel来完成的。
MappedByteBuffer
MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子类 FileChannelImpl实现,下面是和内存映射相关的核心代码:
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
throw new IOException("Map failed", y);
}
}
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
} else {
return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
}
}
map() 方法通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。
1)文件映射需要在 Java 堆中创建一个 MappedByteBuffer 的实例。如果第一次文件映射导致 OOM,则手动触发垃圾回收,休眠 100ms 后再尝试映射,如果失败则抛出异常。
2)通过 Util 的 newMappedByteBuffer方法或者 newMappedByteBufferR方法反射创建一个 DirectByteBuffer实例,其中 DirectByteBuffer 是 MappedByteBuffer 的子类。
map() 方法返回的是内存映射区域的起始地址,通过(起始地址 + 偏移量)就可以获取指定内存的数据。这样一定程度上替代 read() 或 write() 方法,底层直接采用 Unsafe 类的 getByte() 和 putByte() 方法对数据进行读写。
DirectByteBuffer
DirectByteBuffer 继承于 MappedByteBuffer ,DirectByteBuffer 的对象引用位于 Java 内存模型的堆里面,JVM 可以对 DirectByteBuffer 的对象进行内存分配和回收管理,一般使用 DirectByteBuffer 的静态方法 allocateDirect() 创建 DirectByteBuffer 实例并分配内存。DirectByteBuffer 内部的字节缓冲区位在于堆外的(用户态)直接内存,它是通过 Unsafe 的本地方法 allocateMemory() 进行内存分配,底层调用的是操作系统的 malloc() 函数。
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
除此之外,初始化 DirectByteBuffer 时还会创建一个 Deallocator 线程,并通过 Cleaner 的 freeMemory() 方法来对直接内存进行回收操作,freeMemory() 底层调用的是操作系统的 free() 函数。
FileChannel
FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。
transferTo() 和 transferFrom() 方法的底层实现是由 FileChannelImpl 提供的,底层原理是基于 sendfile 实现数据传输的。以 transferTo() 的源码实现为例。FileChannelImpl 首先执行 transferToDirectly() 方法,以 sendfile 的零拷贝方式尝试数据拷贝。如果系统内核不支持 sendfile,进一步执行 transferToTrustedChannel() 方法,以 mmap 的零拷贝方式进行内存映射,这种情况下目的通道必须是 FileChannelImpl 或者 SelChImpl 类型。如果以上两步都失败了,则执行 transferToArbitraryChannel() 方法,基于传统的 I/O 方式完成读写,具体步骤是初始化一个临时的 DirectBuffer,将源通道 FileChannel 的数据读取到 DirectBuffer,再写入目的通道 WritableByteChannel 里面。
public long transferTo(long position, long count, WritableByteChannel target) throws IOException {
// 计算文件的大小
long sz = size();
// 校验起始位置
if (position > sz)
return 0;
int icount = (int)Math.min(count, Integer.MAX_VALUE);
// 校验偏移量
if ((sz - position) < icount)
icount = (int)(sz - position);
long n;
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
return transferToArbitraryChannel(position, icount, target);
}
小结
本文开篇详述了为什么需要 Zero Copy以及其底层原理。从源码着手分析了 Java NIO 对零拷贝的实现,主要包括基于内存映射(mmap)方式的 MappedByteBuffer 以及基于 sendfile 方式的 FileChannel。PS:这个坑已经越挖越大了,在这里又引入了虚拟内存、mmap 以及 DMA (Direct Memory Access),甚至 Java 的 NIO 等概念,下期,再具体介绍下 Zero Copy 在 Netty 以及 KafKa 的应用吧
挖坑序列文章