零拷贝技术是指计算机执行操作时,CPU不需要先将数据从一个内存区域拷贝到另一个内存区域,从而减少上下文切换和CPU拷贝时间。
整个数据传输操作是在直接存储器存取控制器的控制下进行的。除了在数据传输的开始和结束时做一点处理,CPU还可以在传输过程中继续做其他工作。
这样,大部分时间,CPU计算和I/O操作都是并行操作,大大提高了整个计算机系统的效率。
DMA磁盘控制器接管数据读写请求后,CPU从繁重的I/O操作中解脱出来,数据读取操作流程如下:
用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令。DMA 磁盘控制器对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程。数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区。DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。传统输入输出模式
为了更好地理解零拷贝解决的问题,我们首先了解传统I/O模式存在的问题。
在Linux系统中,传统的访问模式是通过两个系统调用实现的:write和read。通过read函数将文件读入缓冲区,然后通过write方法将缓冲区中的数据输出到网络端口。
伪代码如下:
read; write;下图分别对应传统I/O操作的数据读写流程,整个过程涉及2个CPU副本,2个DMA副本,共4个副本,4个上下文切换。
以下简要阐述相关概念:
上下文切换:当用户程序向内核发起系统调用时,CPU 将用户进程从用户态切换到内核态;当系统调用返回时,CPU 将用户进程从内核态切换回用户态。CPU 拷贝:由 CPU 直接处理数据的传送,数据拷贝时会一直占用 CPU 的资源。DMA 拷贝:由 CPU 向DMA磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。传统读取操作
当应用程序执行读取系统调用来读取一个数据块时,如果该数据块已经存在于用户进程的页面内存中,它将直接从内存中读取数据。
如果数据不存在,首先将数据从磁盘加载到内核空之间的读缓冲区,然后从读缓冲区复制到用户进程的页面内存。
read;基于传统的I/O读取方式,读取的系统调用会触发两个上下文切换,一个是DMA拷贝,一个是CPU拷贝。
启动数据读取的过程如下:
用户进程通过 read 函数向内核发起系统调用,上下文从用户态切换为内核态。CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区。CPU 将读缓冲区中的数据拷贝到用户空间的用户缓冲区。上下文从内核态切换回用户态,read 调用执行返回。传统写操作
当应用程序准备数据并执行写系统调用发送网络数据时,首先将数据从用户空之间的页面缓冲区复制到内核空之间的套接字缓冲区,然后将写缓存中的数据复制到网卡设备,完成数据传输。
write;基于传统的I/O写入方式,write系统调用会触发两个上下文切换,一个CPU拷贝,一个DMA拷贝。
用户程序发送网络数据的过程如下:
用户进程通过 write 函数向内核发起系统调用,上下文从用户态切换为内核态。CPU 将用户缓冲区中的数据拷贝到内核空间的网络缓冲区。CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。上下文从内核态切换回用户态,write 系统调用执行返回。零拷贝模式
在Linux中实现零拷贝技术主要有三种方式:
用户态直接 I/O:应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。这样在用户空和内核空之间仍然有上下文切换,硬件上的数据直接复制到用户空而不经过内核空。因此,在直接I/O中,内核空之间的缓冲区和用户空之间的缓冲区之间没有数据复制..
减少数据拷贝次数:在数据传输过程中,避免数据在用户空间缓冲区和系统内核空间缓冲区之间的 CPU 拷贝,以及数据在系统内核空间内的 CPU 拷贝,这也是当前主流零拷贝技术的实现思路。写时复制技术:写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么将其拷贝到自己的进程地址空间中,如果只是数据读取操作则不需要进行拷贝操作。用户模式直接输入输出
用户模式直接输入/输出使运行在用户空间中的应用程序进程或库函数能够直接访问硬件设备。
数据直接跨内核传输,在数据传输过程中,内核不参与除必要的虚拟存储配置之外的任何其他工作。这种方式可以直接绕过内核,大大提高性能。
用户模式直接I/O只能应用于不需要内核缓冲处理的应用。这些应用通常在进程地址空之间有自己的数据缓存机制,称为自缓存应用,比如数据库管理系统就是代表。
其次,这种零拷贝机制会直接操作磁盘I/O,由于CPU和磁盘I/O的时间间隔,会浪费大量资源。解决办法是配合异步I/O。
mmap+写
零拷贝模式是用mmap+写代替原来的读+写模式,减少一次CPU拷贝操作。
Mmap是Linux提供的内存映射文件方法,将进程的address 空中的虚拟地址映射到磁盘文件地址。mmap+write的伪代码如下:
tmp_buf = mmap; write;使用mmap的目的是将内核中读取缓冲区的地址与用户之间的用户缓冲区映射空。
从而实现内核缓冲区和应用程序内存的共享,省略了从内核读缓冲区向用户缓冲区复制数据的过程。
然而,内核读缓冲区仍然需要将数据复制到内核写缓冲区。一般流程如下图所示:
基于mmap+写系统调用的零拷贝模式,整个拷贝过程会有四个上下文切换,一个CPU拷贝和两个DMA拷贝。
用户程序读写数据的过程如下:
用户进程通过 mmap 函数向内核发起系统调用,上下文从用户态切换为内核态。将用户进程的内核空间的读缓冲区与用户空间的缓存区进行内存地址映射。CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区。上下文从内核态切换回用户态,mmap 系统调用执行返回。用户进程通过 write 函数向内核发起系统调用,上下文从用户态切换为内核态。CPU 将读缓冲区中的数据拷贝到网络缓冲区。CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。上下文从内核态切换回用户态,write 系统调用执行返回。mmap的主要目的是提高I/O性能,特别是对于大文件。对于小文件,内存映射文件会导致片段之间的浪费空。
因为内存映射总是与页面边界对齐,最小单位是4 KB,一个5 KB的文件会占用8 KB的内存,浪费3 KB的内存。
虽然mmap的副本减少了一个副本,提高了效率,但也存在一些隐藏的问题。
当mmap为文件时,如果文件被另一个进程截获,写系统调用会被访问非法地址的SIGBUS信号终止,SIGBUS会默认杀死该进程并生成coredump,因此服务器可能被终止。
发送文件
Linux内核2.1版引入了Sendfile系统调用,简化了通过网络在两个通道之间传输数据的过程。
Sendfile系统调用的引入,不仅减少了CPU复制的次数,也减少了上下文切换的次数。它的伪代码如下:
sendfile;有了Sendfile系统调用,数据可以在内核空之间直接I/O传输,从而消除了用户空和内核空之间来回复制数据的需要。
与mmap内存映射不同,Sendfile调用中的I/O数据对用户空是完全不可见的。换句话说,这是一个完整的数据传输过程。
基于Sendfile系统调用的零拷贝模式,整个拷贝过程会有两个上下文切换,一个CPU拷贝和两个DMA拷贝。
用户程序读写数据的过程如下:
用户进程通过 sendfile 函数向内核发起系统调用,上下文从用户态切换为内核态。CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区。CPU 将读缓冲区中的数据拷贝到的网络缓冲区。CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。上下文从内核态切换回用户态,Sendfile 系统调用执行返回。与mmap内存映射相比,Sendfile少了两个上下文切换,但仍然有一个CPU复制操作。
Sendfile的问题是用户程序不能修改数据,只是简单的完成一次数据传输过程。
发送文件+直接存储器存取收集副本
Linux内核2.4版修改了Sendfile系统调用,引入了针对DMA复制的聚集操作。
它将内核空间的读缓冲区中对应的数据描述信息记录到对应的套接字缓冲区中,并根据内存地址和地址偏移量将数据从读缓冲区批量复制到网卡设备。
这样只保存了内核空之间的一次CPU复制操作,Sendfile的伪代码如下:
sendfile;在硬件支持下,Sendfile复制模式不再将数据从内核缓冲区复制到套接字缓冲区,只复制缓冲区的文件描述符和数据长度。
这样,DMA引擎可以直接使用聚集操作将页面缓存中的数据打包发送到网络,本质上类似于虚拟内存映射的思想。
基于Sendfile+DMA聚集拷贝系统调用的零拷贝模式,整个拷贝过程会有2个上下文切换,0个CPU拷贝,2个DMA拷贝。
用户程序读写数据的过程如下:
用户进程通过 sendfile 函数向内核发起系统调用,上下文从用户态切换为内核态。CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区。CPU 把读缓冲区的文件描述符和数据长度拷贝到网络缓冲区。基于已拷贝的文件描述符和数据长度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区拷贝到网卡进行数据传输。上下文从内核态切换回用户态,Sendfile 系统调用执行返回。Sendfile+DMA聚集副本也存在用户程序无法修改数据的问题,需要硬件的支持。仅适用于将数据从文件复制到套接字的传输过程。
接合
Sendfile只适合从文件复制数据到socket,需要硬件支持,这也限制了它的应用范围。
Linux在2.6.17版引入了Splice系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。
拼接伪码如下:
splice;拼接系统调用可以在内核空之间的读取缓冲区和套接字缓冲区之间建立流水线,从而避免了它们之间的CPU复制操作。
基于拼接系统调用的零拷贝模式,整个拷贝过程中会有两个上下文切换,零CPU拷贝和两个DMA拷贝。
用户程序读写数据的过程如下:
用户进程通过 splice 函数向内核发起系统调用,上下文从用户态切换为内核态。CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区。CPU 在内核空间的读缓冲区和网络缓冲区之间建立管道。CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。上下文从内核态切换回用户态,Splice 系统调用执行返回。拼接复制还存在用户程序无法修改数据的问题。另外,它使用Linux的流水线缓冲机制,可以用来传输任意两个文件描述符中的数据,但是它的两个文件描述符参数中必须有一个是流水线设备。
写时复制
在某些情况下,内核缓冲区可能由多个进程共享。如果某个进程想写这个共享区,因为写不提供任何锁操作,会损坏共享区的数据。Linux使用写入时复制的引入来保护数据。
写时复制是指当多个进程共享同一数据时,如果其中一个进程需要修改该数据,则需要复制到自己的进程地址空。
这样做并不影响其他进程对这段数据的操作,每个进程只在需要修改的时候才会拷贝,所以叫写时拷贝。
这种方法可以在一定程度上降低系统开销。如果一个进程永远不会改变被访问的数据,那么它就永远不需要被复制。
缓冲区共享
缓冲区共享完全重写了传统的I/O操作,因为传统的I/O接口是基于数据复制的,所以为了避免复制,必须移除并重写原来的windows sockets。
因此,这种方法是一种全面的零拷贝技术。目前比较成熟的方案是在Solaris上实现的fbuf。
fbuf的思想是每个进程维护一个缓冲池,它可以映射到用户空间和内核空间。内核和用户共享这个缓冲池,从而避免了一系列的复制操作。
缓冲区共享的难点在于共享缓冲池的管理需要应用、网络软件、设备驱动之间的紧密配合,如何重写API还处于实验阶段,不成熟。
Linux零拷贝的比较
无论是传统的I/O拷贝模式还是零拷贝模式,第二次DMA拷贝都是必不可少的,因为第二次DMA是由硬件完成的。
下面从CPU复制次数、DMA复制次数、系统调用等方面总结了上述I/O复制方法的区别:
Java NIO零拷贝的实现
Java NIO中的一个通道相当于操作系统内核空间中的一个缓冲区。
缓冲区对应于操作系统的用户空间中的用户缓冲区空:
通道是全双工的,它既可能是读缓冲区,也可能是网络缓冲区。缓冲区分为堆内存和堆外内存,这是通过 malloc 分配出来的用户态内存。DirectBuffer在使用后需要由应用程序手动恢复,而HeapBuffer的数据可能会在GC期间自动恢复。
因此,在使用HeapBuffer读写数据时,为了避免由于GC导致的缓冲区数据丢失,NIO会先将HeapBuffer内部的数据复制到一个临时DirectBuffer中的本机内存中。
这个副本涉及到sun.misc.Unsafe.copyMemory的调用,背后的实现原理和memcpy类似。
最后将临时生成的DirectBuffer中数据的内存地址传递给I/O调用函数,从而避免访问Java对象来处理I/O读写。
MappedByteBuffer
MappedByteBuffer是NIO基于内存映射提供的实现,继承了ByteBuffer。
FileChannel定义了一个map方法,可以将文件的大小区域从位置映射到内存图像文件。
抽象方法映射方法在文件通道中定义如下:
public abstract MappedByteBuffer mapthrows IOException;模式:它定义了内存映射对内存镜像文件的访问模式,包括三种模式:只读、读写和私有。
位置:文件映射的起始地址,对应MappedByteBuffer的第一个地址。
Size:文件映射的字节长度,Position后的字节数,对应MappedByteBuffer的大小。
与字节缓冲相比,映射字节缓冲增加了三个重要的方法:
fore:对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。load:将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。isLoaded:如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。以下是使用MappedByteBuffer读写文件的示例:
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并提供读权限、写权限和数据清除空权限,通过fileChannel映射到一个可写内存缓冲区mappedByteBuffer,将目标数据写入mappedByteBuffer,通过force方法将缓冲区的更改内容强制写入本地文件。
@Test public void writeToFileByMappedByteBuffer {Path path = Paths.get.getResource.getPath);byte bytes = CONTENT.getBytes);try ) {MappedByteBuffer mappedByteBuffer = fileChannel.map;if {mappedByteBuffer.put;mappedByteBuffer.force;}} catch {e.printStackTrace;} }读取文件数据:打开文件通道文件通道并提供只读权限,通过文件通道映射到一个可读的内存缓冲区mappedByteBuffer,读取mappedByteBuffer中的字节数组得到文件数据。
@Test public void readFromFileByMappedByteBuffer {Path path = Paths.get.getResource.getPath);int length = CONTENT.getBytes).length;try ) {MappedByteBuffer mappedByteBuffer = fileChannel.map;if {byte bytes = new byte;mappedByteBuffer.get;String content = new String;assertEquals;}} catch {e.printStackTrace;} }下面描述map方法的底层实现原理。map方法是java.nio.channels.FileChannel的抽象方法,由子类sun.nio.ch.FileChannelImpl.java实现。
以下是与内存映射相关的核心代码:
public MappedByteBuffer map throws IOException {int pagePosition = ;long mapPosition = position - pagePosition;long mapSize = size + pagePosition;try {addr = map0;} catch {System.gc;try {Thread.sleep;} catch {Thread.currentThread.interrupt;}try {addr = map0;} catch {throw new IOException;}}int isize = size;Unmapper um = new Unmapper;if || ) {return Util.newMappedByteBufferR;} else {return Util.newMappedByteBuffer;} }map方法通过本地方法map0为文件分配一个虚拟内存作为其内存映射区域,然后返回该内存映射区域的起始地址:
文件映射需要在 Java 堆中创建一个 MappedByteBuffer 的实例。如果第一次文件映射导致 OOM,则手动触发垃圾回收,休眠 100ms 后再尝试映射,如果失败则抛出异常。通过 Util 的 newMappedByteBuffer方法或者 newMappedByteBufferR方法反射创建一个 DirectByteBuffer 实例,其中 DirectByteBuffer 是 MappedByteBuffer 的子类。map方法返回内存映射区的起始地址,指定内存的数据可以通过得到。
这在一定程度上替代了read或write方法,底层直接使用sun.misc.Unsafe类的getByte和putByte方法读写数据。
private native long map0 throws IOException;以上是原生方法的定义)map0,它通过JNI调用底层c的实现。
原生函数是在JDK源码包下的源文件native/sun/nio/ch/file channel mpl . c中实现的。
JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_map0 {void *mapAddress = 0;jobject fdo = ->GetObjectField;jint fd = fdval;int protections = 0;int flags = 0;if {protections = PROT_READ;flags = MAP_SHARED;} else if {protections = PROT_WRITE | PROT_READ;flags = MAP_SHARED;} else if {protections = PROT_WRITE | PROT_READ;flags = MAP_PRIVATE;}mapAddress = mmap64; /* Offset into file */if {if {JNU_ThrowOutOfMemoryError;return IOS_THROWN;}return handle;}return mapAddress); }可以看到map0函数最终通过mmap64函数调用内存映射到底层Linux内核。mmap64函数的原型如下:
#include <sys/mman.h> void *mmap64;mmap64函数的每个参数的含义及其可选值详述如下:
Addr:用户进程空之间的内存映射区中文件的起始地址,是一个建议参数,通常可以设置为0或NULL。这时内核决定真正的起始地址。
当flags为MAP_FIXED时,Addr是必需的参数,即需要提供一个现有的地址。
Len:文件需要内存映射的字节长度。
Prot:控制用户进程对内存映射区的访问权限:
PROT_READ:读权限。PROT_WRITE:写权限。PROT_EXEC:执行权限。PROT_NONE:无权限。标志:控制内存映射区域的修改是否由多个进程共享:
MAP_PRIVATE:对内存映射区数据的修改不会反映到真正的文件,数据修改发生时采用写时复制机制。MAP_SHARED:对内存映射区的修改会同步到真正的文件,修改对共享此内存映射区的进程是可见的。MAP_FIXED:不建议使用,这种模式下 addr 参数指定的必须提供一个存在的 addr 参数。Fd:文件描述符。每次映射操作都会导致文件的引用计数增加1,每次取消映射操作或结束该过程都会导致引用计数减少1。
偏移量:文件偏移量。要映射的文件的位置,以及从文件起始地址向后的偏移量。
下面总结了MappedByteBuffer的特点和缺点:
MappedByteBuffer 使用是堆外的虚拟内存,因此分配的内存大小不受 JVM 的 -Xmx 参数限制,但是也是有大小限制的。如果当文件超出 Integer.MAX_VALUE 字节限制时,可以通过 position 参数重新 map 文件后面的内容。MappedByteBuffer 在处理大文件时性能的确很高,但也存在内存占用、文件关闭不确定等问题,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。MappedByteBuffer 提供了文件映射内存的 mmap 方法,也提供了释放映射内存的 unmap 方法。然而 unmap 是 FileChannelImpl 中的私有方法,无法直接显示调用。所以用户程序需要通过调用Java反映的sun.misc.Cleaner类的clean方法,手动释放映射占用的内存区域。
public static void clean throws Exception {AccessController.doPrivileged -> {try {Method getCleanerMethod = buffer.getClass.getMethod;getCleanerMethod.setAccessible;Cleaner cleaner = getCleanerMethod.invoke;cleaner.clean;} catch {e.printStackTrace;}}); }DirectByteBuffer
DirectByteBuffer的对象引用位于Java内存模型的堆中,JVM可以管理DirectByteBuffer对象的内存分配和恢复。
一般使用DirectByteBuffer的静态方法allocateDirect创建DirectByteBuffer的一个实例,分配内存。
public static ByteBuffer allocateDirect {return new DirectByteBuffer; }DirectByteBuffer内部的字节缓冲区位于堆外的直接内存中,通过Unsafe的local方法allocateMemory分配内存,底层调用操作系统的malloc函数。
DirectByteBuffer {super;boolean pa = VM.isDirectMemoryPageAligned;int ps = Bits.pageSize;long size = Math.maxcap + );Bits.reserveMemory;long base = 0;try {base = unsafe.allocateMemory;} catch {Bits.unreserveMemory;throw x;}unsafe.setMemory 0);if ) {address = base + ps - );} else {address = base;}cleaner = Cleaner.create);att = null; }此外,在初始化DirectByteBuffer时,将创建一个Declasor线程,直接内存将由清理器的freeMemory方法回收。freeMemory的底层调用操作系统的free函数。
private static class Deallocator implements Runnable {private static Unsafe unsafe = Unsafe.getUnsafe;private long address;private long size;private int capacity;private Deallocator {assert ;this.address = address;this.size = size;this.capacity = capacity;}public void run {if {return;}unsafe.freeMemory;address = 0;Bits.unreserveMemory;} }因为DirectByteBuffer是用来分配系统的本地内存的,不受JVM控制,所以直接内存的恢复和堆内存的恢复是不一样的。如果直接内存使用不当,很容易导致内存不足错误。
说到这里,DirectByteBuffer和零拷贝有什么关系?如前所述,当MappedByteBuffer执行内存映射时,它的map方法将通过Util.newMappedByteBuffer创建一个缓冲区实例。
初始化代码如下:
static MappedByteBuffer newMappedByteBuffer {MappedByteBuffer dbb;if initDBBConstructor;try {dbb = directByteBufferConstructor.newInstance, new Long, fd, unmapper });} catch {throw new InternalError;}return dbb; } private static void initDBBRConstructor {AccessController.doPrivileged {public Void run {try {Class<?> cl = Class.forName;Constructor<?> ctor = cl.getDeclaredConstructor;ctor.setAccessible;directByteBufferRConstructor = ctor;} catch {throw new InternalError;}return null;}}); }DirectByteBuffer是MappedByteBuffer的具体实现类。
实际上,Util.newMappedByteBuffer方法通过反射机制获取DirectByteBuffer的构造函数,然后创建DirectByteBuffer的一个实例,对应于一个单独用于内存映射的构造方法:
protected DirectByteBuffer {super;address = addr;cleaner = Cleaner.create;att = null; }所以DirectByteBuffer除了允许分配操作系统的直接内存外,它本身还具有文件内存映射的功能,这里就不解释了。
我们需要注意的是DirectByteBuffer基于MappedByteBuffer为内存镜像文件提供随机读get和写write操作。
内存镜像文件的随机读取操作:
public byte get {return )))); } public byte get {return )))); }内存镜像文件的随机写操作:
public ByteBuffer put {unsafe.putByte), ));return this; } public ByteBuffer put {unsafe.putByte), ));return this; }内存镜像文件的随机读写由ix方法定位,该方法从内存映射空与给定偏移量I之间的内存头地址计算指针地址,然后通过不安全类的get和put方法读取或写入指针所指向的数据。
private long ix {return address + i << 0); }文件频道
FileChannel是读取、写入、映射和操作文件的通道,在并发环境下是线程安全的。
基于文件输入流、文件输出流或随机访问文件的GetChannel方法可以创建和打开文件通道。
FileChannel定义了transferFrom和transferTo两种抽象方法,通过建立通道之间的连接来实现数据传输。
TransferTo:通过FileChannel将文件中的源数据写入WritableByteChannel的目标通道。
public abstract long transferTothrows IOException;TransferFrom:将数据从源通道ReadableByteChannel读取到当前文件通道的文件中。
public abstract long transferFromthrows IOException;以下是文件通道使用传输到和传输自方法进行数据传输的示例:
private static final String CONTENT = "Zero copy implemented by FileChannel"; private static final String SOURCE_FILE = "/source.txt"; private static final String TARGET_FILE = "/target.txt"; private static final String CHARSET = "UTF-8";首先在类加载的根路径下创建两个文件,source.txt和target.txt,将初始化数据写入源文件source.txt..
@Before public void setup {Path source = Paths.get);byte bytes = CONTENT.getBytes);try ) {fromChannel.write);} catch {e.printStackTrace;} }对于transferTo方法,目标通道toChannel可以是任何单向字节写通道WritableByteChannel而对于transferFrom方法,源通道fromChannel可以是任何单向字节读通道ReadableByteChannel。
其中有FileChannel、SocketChannel、DatagramChannel等。,实现可写字节通道和可读字节通道接口,是支持读写的双向通道。
为了便于测试,下面给出了一个基于文件通道的通道间数据传输的例子。
使用transferTo将数据从通道复制到通道:
@Test public void transferTo throws Exception {try , "rw").getChannel;FileChannel toChannel = new RandomAccessFile, "rw").getChannel) {long position = 0L;long offset = fromChannel.size;fromChannel.transferTo;} }使用transferFrom将数据从通道复制到通道:
@Test public void transferFrom throws Exception {try , "rw").getChannel;FileChannel toChannel = new RandomAccessFile, "rw").getChannel) {long position = 0L;long offset = fromChannel.size;toChannel.transferFrom;} }下面介绍transferTo和transferFrom方法的底层实现原理,这两个方法也是java.nio.channels.FileChannel的抽象方法,由子类sun.nio.ch.FileChannelImpl.java实现。
TransferTo和transferFrom都是基于Sendfile实现数据传输的,其中FileChannelImpl.java定义了三个常量来表示当前操作系统的内核是否支持Sendfile以及Sendfile的相关特性。
private static volatile boolean transferSupported = true; private static volatile boolean pipeSupported = true; private static volatile boolean fileSupported = true;TransferSupported:用于标记当前系统内核是否支持sendfile调用;默认值为真。
PipeSupported:用于标记当前系统内核是否支持基于管道的文件描述符的sendfile调用。默认值为真。
FileSupported:用于标记当前系统内核是否支持基于file sendfile调用的文件描述符。默认值为真。
以transferTo的源代码实现为例。FileChannelImpl首先执行transferToDirectly方法,并尝试以发送文件零拷贝的方式复制数据。
如果系统内核不支持Sendfile,则进一步执行transferToTrustedChannel方法,以mmap零拷贝的方式映射内存。在这种情况下,目标通道必须是文件通道协议类型或选择通道类型。
如果以上两个步骤失败,执行transferToArbitraryChannel方法,在传统I/O模式的基础上完成读写。具体步骤是初始化一个临时的DirectBuffer,将源通道FileChannel的数据读入DirectBuffer,然后写入目的通道WritableByteChannel。
public long transferTothrows IOException {// 计算文件的大小long sz = size;// 校验起始位置if return 0;int icount = Math.min;// 校验偏移量if < icount)icount = ;long n;if ) >= 0)return n;if ) >= 0)return n;return transferToArbitraryChannel; }接下来重点介绍了transferToDirectly的实现,这是transferTo通过Sendfile实现零拷贝的本质。
可以看到,transferToDirectlyInternal方法首先获取目标通道WritableByteChannel的文件描述符targetFD,获取同步锁,然后执行transferToDirectlyInternal方法。
private long transferToDirectlythrows IOException {// 省略从target获取targetFD的过程if ) {synchronized {long pos = position;try {return transferToDirectlyInternal;} finally {position;}}} else {return transferToDirectlyInternal;} }最后,transferToDirectlyInternal调用本地方法transferTo0尝试在Sendfile中传输数据。
如果系统内核根本不支持发送文件,例如Windows操作系统,它将返回UNSUPPORTED并将transferSupported标记为false。
如果系统内核不支持发送文件的某些功能,例如,较低版本的Linux内核不支持直接内存分配收集复制操作,它将返回UNSUPPORTED_CASE并将pipeSupported或fileSupported标记为false。
private long transferToDirectlyInternal throws IOException {assert !nd.transferToDirectlyNeedsPositionLock ||Thread.holdsLock;long n = -1;int ti = -1;try {begin;ti = threads.add;if )return -1;do {n = transferTo0;} while && isOpen);if {if pipeSupported = false;if fileSupported = false;return IOStatus.UNSUPPORTED_CASE;}if {transferSupported = false;return IOStatus.UNSUPPORTED;}return IOStatus.normalize;} finally {threads.remove;end ;} }原生方法)transferTo0通过JNI调用底层c的函数。
本机函数也位于JDK源文件包下的本机/sun/nio/ch/file channel mpl . c源文件中。
JNI函数基于条件编译预编译不同的系统。下面是JDK为transferto提供的基于Linux系统内核的调用包。
#if defined || defined #include <sys/sendfile.h> #elif defined #include <sys/socket.h> #elif defined #include <sys/types.h> #include <sys/socket.h> #include <sys/uio.h> #define lseek64 lseek #define mmap64 mmap #endif JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_transferTo0 {jint srcFD = fdval;jint dstFD = fdval; #if definedoff64_t offset = position;jlong n = sendfile64count);return n; #elif definedresult = sendfilev64;return result; #elif definedresult = sendfile;return result; #endif }对于Linux、Solaris和Apple系统,transferTo0函数的底层将执行系统调用sendfile64,完成零拷贝操作。sendfile64函数的原型如下:
#include <sys/sendfile.h> ssize_t sendfile64;下面简单介绍一下sendfile64函数各参数的含义:
out_fd:待写入的文件描述符。in_fd:待读取的文件描述符。offset:指定 in_fd 对应文件流的读取位置,如果为空,则默认从起始位置开始。count:指定在文件描述符 in_fd 和 out_fd 之间传输的字节数。Linux 2.6.3之前,out_fd必须是套接字,Linux 2.6.3之后,out_fd可以是任何文件。
也就是说,sendfile64函数不仅可以传输网络文件,还可以对本地文件实现零拷贝操作。
其他零拷贝实现
网络零拷贝
Netty的零拷贝在操作系统层面与上述零拷贝不同。我们所说的Netty的零拷贝完全基于用户模式,更倾向于数据操作优化的概念。
具体体现在以下几个方面:
Netty 通过 DefaultFileRegion 类对 java.nio.channels.FileChannel 的 tranferTo 方法进行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道。ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 对象, 进而避免了拷贝操作。ByteBuf 支持 Slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。第一种属于操作系统级的零拷贝操作,后三种只能算是用户级的数据操作优化。
洛克MQ与卡夫卡的比较
RocketMQ选择mmap+write作为零拷贝方式,适合业务级消息等小文件的数据持久化和传输。
卡夫卡采用的是Sendfile,这是一种零拷贝的方法,适用于大文件的数据持久化和高吞吐量的传输,比如系统日志消息。
但是值得注意的是,卡夫卡的索引文件采用mmap+write模式,其数据文件采用Sendfile模式。
摘要
本文开头详细描述了Linux操作系统中物理内存和虚拟内存、内核空和用户空的概念以及Linux的内部层次结构。
在此基础上,进一步分析比较了传统I/O模式和零拷贝模式的区别,然后介绍了Linux内核提供的几种零拷贝实现。
包括内存映射mmap,Sendfile,Sendfile+DMA聚集复制和拼接,并从系统调用和复制时间比较它们。
然后从源代码上分析了Java NIO中零拷贝的实现,包括基于mmap的MappedByteBuffer和基于Sendfile的FileChannel。
最后,在本文的最后,简要描述了Netty中的零拷贝机制以及RocketMQ和卡夫卡在零拷贝实现上的区别。
简介:五年在R&D和建筑方面的经验。曾在思爱普中国R&D中心和上海冰建科技信息技术有限公司后端R&D担任建筑师助理,现为成都思爱普工程有限公司高级顾问和R&D员工..熟悉大数据、高并发、负载均衡、缓存、数据库、消息中间件、搜索引擎、容器、自动化。个人学习能力强,技术热情高,热爱开源和写技术博客,善于沟通和分享。
1.《零拷贝 支撑百万并发的“零拷贝”技术,你了解吗?》援引自互联网,旨在传递更多网络信息知识,仅代表作者本人观点,与本网站无关,侵删请联系页脚下方联系方式。
2.《零拷贝 支撑百万并发的“零拷贝”技术,你了解吗?》仅供读者参考,本网站未对该内容进行证实,对其原创性、真实性、完整性、及时性不作任何保证。
3.文章转载时请保留本站内容来源地址,https://www.lu-xu.com/guonei/1755620.html