NIO零拷贝
waht is 零拷贝?
零拷贝(Zero-Copy)是指计算机在执行操作时,CPU不需要先将数据从某处内存复制到一个特定区域,从而节省CPU时钟周期和内存带宽。 —维基百科
概念
DMA
直接内存访问(Direct Memory Access,DMA)是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA是一种快速的数据传送方式。很多硬件的系统会使用DMA,包含硬盘控制器、绘图显卡、网卡和声卡。
内核态/用户态
系统的资源是有限的,如果不加以管理,必然造成资源过多消耗和访问冲突。为了控制关键资源的访问,Linux把程序划分为不同的执行等级,即特权的概念。x86架构的CPU提供了0到3四个特权级,数字越小,特权越高,Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。
用户态的进程可以执行的操作和访问的资源都会受到限制;内核态的进程则可以执行任何操作并且在资源的使用上没有限制。用户程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,就需要通过系统调用把系统从用户态切换到内核态。比如C语言的内存分配函数malloc(),是通过sbrk()系统调用来分配内存,从malloc到sbrk()的调用就涉及从用户态到内核态的切换,类似函数printf()调用的是wirte()系统调用。
Linux IO模型
传统IO
传统IO也叫缓存IO,是利用缓冲(Buffer)机制实现的。
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(inFile));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(outFile))){
byte[] buf = new byte[1024];
while ((bis.read(buf)) != -1) {
bos.write(buf);
}
传统IO的数据拷贝流程如下图:
- JVM发起read()系统调用,操作系统切换到内核模式,将数据读入socket buffer。然后,OS内核请求硬件设备获取数据,DMA将数据复制到内核空间的kernel buffer中。
- CPU将内核中的数据复制到用户空间user buffer中,切换回用户模式。read()调用返回。
- JVM处理代码逻辑然后发起write()系统调用。
- 操作系统切换到内核模式,将数据从user buffer复制到输出sockek buffer中,通过网络发送出去。
传统IO会经历4次的内核态和用户态的相互切换和两次的拷贝过程。
其中4次的内核态和用户态的相互切换如下图所示:
传统IO的优点
- 在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全
- 可以减少读盘的次数,从而提高性能
传统IO的缺点
在缓存IO机制中,DMA方式可以将数据直接从磁盘读到内核缓存中,或者将数据从缓存直接写回到磁盘上,但不能直接在应用程序地址空间和磁盘之间进行数据传输。因此,数据在传输过程中需要在应用程序地址空间和内核缓存空间之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。
NIO的零拷贝-transferTo
由于在上述过程中,应用程序并不修改传输的数据,所以数据在Kernel和用户缓存间的来回拷贝以及系统上下文的多次切换,是可否可以进行优化,去掉第2、3两次数据拷贝,是否存在一种“管道”把Read Buffer和Write Buffer直接接在一起?
NIO中的FileChannel.transferTo()方法给我们提供了这种实现:
/**
* Transfers bytes from this channel's file to the given writable byte
* channel.
*
* <p> An attempt is made to read up to <tt>count</tt> bytes starting at
* the given <tt>position</tt> in this channel's file and write them to the
* target channel.
*/
public abstract long transferTo(long position, long count,
WritableByteChannel target)
throws IOException;
具体实现代码如下:
FileChannel inputChannel = new FileInputStream(inFile).getChannel();
FileChannel outChannel = new FileOutputStream(outFile).getChannel();
//Transfers bytes from this channel's file to the given writable byte channel
inputChannel.transferTo(0, fileChannelInput.size(), outChannel);
数据流程图如下:
通过FileChannel的transferTo()方法,实现了把数据中一个可读的文件管道直接传输到另一个可写管道,消除了Kernel和用户缓存间的数据拷贝和系统上下文切换。在Linux底层,方法被传递到sendfile()系统调用,实现把数据从一个文件描述符传输到了另一个文件描述符。
在Linux 2.4以及更高版本的内核中,Socket缓冲区描述符支持gather操作。内核只向Socket传递数据的FD(File Descriptor),而不实际拷贝数据,这种方式不但减少上下文切换,同时消除了需要CPU参与的数据拷贝过程。
此模式下,用户侧还是调用FileChannel.transferTo()方法,但是Kernel内部实现发生了变化:
- transferTo方法调用触发DMA引擎将文件上下文信息拷贝到内核缓冲区。
- 数据不会被拷贝到Socket缓冲区,只有数据的描述符被拷贝到Socket缓冲区。
- DMA引擎直接根据FD把数据从内核缓冲区拷贝到NIC缓存,减少了最后一次需要消耗CPU的拷贝操作。
使用场景
- JVM无对该文件数据进行操作的需求
- 文件较大,读写较慢,追求速度
- JVM内存不足,不能加载太大数据
- 内存带宽不够,即存在其他程序或线程存在大量的IO操作,导致带宽本来就小
NIO的零拷贝-mmap
它的处于传统IO与零拷贝之间,为何这么说呢?
- 传统IO把磁盘的文件经过内核空间,读到JVM空间,然后进行各种操作,最后再写到磁盘或是发送到网络,效率较慢但支持数据文件操作。
- 零拷贝-transerTo直接在内核空间完成文件读取并转到磁盘(或发送到网络)。由于缺少了读取文件数据到JVM这一环,因此程序无法操作该文件数据。
而直接内存(mmap技术)则介于两者之间,效率一般且可操作文件数据。直接内存将文件直接映射到内核空间的内存,返回一个操作地址(address),它解决了文件数据需要拷贝到JVM才能进行操作的窘境。而是直接在内核空间直接进行操作,省去了内核空间拷贝到用户空间这一步操作。
JDK1.4的NIO中引入了直接内存映射的模式,能直接在Native堆中分配内存,以避免JVM堆和Native堆之间数据拷贝带来的性能损耗。
Java进程内存基本结构如下:
通过使用堆外内存,可以带来以下好处: - 改善堆过大时垃圾回收效率,减少停顿。Full GC时会扫描堆内存,回收效率和堆大小成正比。通过把内存放到Native堆,可提升GC效率。Native的内存,由OS负责管理和回收。
- 减少内存在Native堆和JVM堆拷贝过程,避免拷贝损耗,降低内存使用。
- 可突破JVM内存大小限制。
直接内存
ByteBuffer用于创建内存缓存,其类的继承关系如下:
HeapByteBuffer用于创建JVM堆内缓存区,DirectByteBuffer用于创建Native缓存区。通过调用ByteBuffer的静态方法allocate和allocateDirect方法分别创建两种缓存区。
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
MappedByteBuffer是NIO提供的文件内存映射的实现方案,可以把整个文件或文件段映射到Native堆内存。MappedByteBuffer是抽象类,DirectByteBuffer才是提供实际功能的其直接子类,他同时实现了DirectBuffer接口,此接口提供了cleaner用于GC管理。
通过FileChannel的map方法,可以把文件映射为内存对象:
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
MapMode提供了3种内存映射模式:
- READ_ONLY:read-only mapping
- READ_WRITE:read/write mapping
- PRIVATE:private (copy-on-write) mapping,此模式下,对内存映射的修改,不会写入文件,而是创建修改后缓冲区的私有副本。
使用参数-XX:MaxDirectMemorySize指定DirectByteBuffer的大小。
参考代码如下:
RandomAccessFile fileSrc =
new RandomAccessFile("/Users/wuds/WdsProjects/awdb-java/1.txt", "rw");
RandomAccessFile fileDest =
new RandomAccessFile("/Users/wuds/WdsProjects/awdb-java/dest.txt", "rw");
FileChannel channel = fileSrc.getChannel();
MappedByteBuffer byteBuffer =
channel.map(FileChannel.MapMode.READ_ONLY, 0, fileSrc.length());
FileChannel channelDest = fileDest.getChannel();
channelDest.write(byteBuffer);
byteBuffer.flip();
Charset charset = Charset.forName("UTF-8");
CharBuffer charBuffer = charset.decode(byteBuffer);
System.out.println(charBuffer);
参考
https://juejin.cn/post/6844903745340309517
https://segmentfault.com/a/1190000021448694
https://corejava.vip/java/zero-copy/