零拷贝是网络编程的关键,很多性能优化都离不开零拷贝,很多优秀的开源框架底层都用的零拷贝,如Netty、RocketMQ、Spark等
在深入零拷贝机制之前,先来了解下传统BIO通信底层发生了什么,为什么会这么“消耗资源”。Linux服务器是现在绝大多数系统的首选,它的优点就不再赘述,下面的分析都基于Linux环境来进行。作为一台服务器,最常见的功能就是
获取客户端发送过来的请求,然后再去查数据库DB获取到想要的数据,再将数据以一定的业务逻辑处理后传回给客户端,这一过程主要会调用Linux内核的以下两个函数:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
一个客户端向服务器发送一个查询商品信息的请求,数据在服务器流转流程大致如下:
由上面的流程分析可以发现,这个过程发生了4次拷贝(两次DMA拷贝,两次CPU拷贝),4次CPU状态切换。这个操作对于应用服务器来说很频繁,因此带来的开销也是非常大的。
DMA,即绕开CPU进行数据读写。在计算机中,相比CPU来说,外部设备访问速度是非常缓慢的,因而,"memory到memory“ 或者 “memory到device”或者“divice到memory”之间搬运数据是非常浪费CPU时间的!造成CPU无法及时处理实时事件…怎么办?因此工程师设计出来一种专门协助CPU搬运数据的硬件“DMA控制器”,协助CPU完成数据搬运。
Java攻城狮都知道,JVM里堆主要是用来存放对象的,栈是用来存放变量以及对象引用地址。本节以JVM内存的角度来分析数据的流向。
在第一节中的流程,操作系统会自动在内核中分配一块内存空间,这块内存空间的创建和销毁完全由内核操作系统来控制。程序对磁盘数据进行读或者向网络发送数据都由操作系统分配的这块内存来进行的,所以读数据时会进行DMA拷贝将数据从磁盘读取到内核的这块内存空间中,然后再经过CPU拷贝将数据拷贝到堆中。而向网络发送数据时,通过CPU拷贝,数据会从堆拷贝到内核的这块内存空间中,然后再通过DMA将数据拷贝到网卡中,进行数据的发送。由此可以发现,传统的IO不仅会有多次拷贝、内核状态的切换,还会受到堆内存OOM、GC时STW的影响,聪明的前辈们早已想好了对策,即将数据存放在内核分配的一个内存空间中,这样就省去了内核态和用户态之间两次无意义的拷贝了。就这样,零拷贝的概念诞生了。
通过上面的分析可以看出,内核态到用户态数据的来回拷贝是没有意义的,数据应该可以直接从内核缓冲区直接送入socket缓冲区。零拷贝机制就实现了这一点。
操作系统层面减少数据拷贝次数主要是指用户空间和内核空间的数据拷贝,因为只有他们的拷贝是大量消耗CPU时间片的,而DMA控制器拷贝数据CPU参与的工作较少,只是辅助作用,所以减少CPU拷贝意义更大。
现实中对零拷贝的概念有“广义”和“狭义”之分,广义上是指只要减少了数据拷贝的次数,就称之为零拷贝;狭义上是指真正的零拷贝,就是避免了内核缓冲区和用户空间内存之间的两次CPU拷贝,
零拷贝实现方式:
既然是内存映射,首先来了解解下虚拟内存和物理内存的映射关系,虚拟内存是操作系统为了方便操作而对物理内存做的抽象,他们之间是靠页表(Page Table)进行关联的,关系如下:
每个进程都有自己的PageTable,进程的虚拟内存地址通过PageTable对应于物理内存,内存分配具有惰性,它的过程一般是这样的:进程创建后新建与进程对应的PageTable,当进程需要内存时会通过PageTable寻找物理内存,如果没有找到对应的页帧就会发生缺页中断,从而创建PageTable与物理内存的对应关系。虚拟内存不仅可以对物理内存进行扩展,还可以更方便地灵活分配,并对编程提供更友好的操作。
内存映射(mmap)是指用户空间和内核空间的虚拟内存地址同时映射到同一块物理内存,用户态进程可以直接操作物理内存,避免用户空间和内核空间之间的数据拷贝。
整个流程是这样的:
小结:
sendfile是在Linux2.1引入的,它只需要2次上下文切换和1次内核CPU拷贝、2次DMA拷贝,函数定义如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
out_fd为文件描述符,in_fd为网络缓冲区描述符,offset偏移量(默认NULL),count文件大小。
sendfile零拷贝的执行流程是这样的:
小结:
那么有没有什么办法彻底减少CPU拷贝次数,让数据不在内存缓冲区和网络缓冲区之间进行拷贝呢?答案就是sendfile + DMA gatter
Linux2.4对sendfile进行了优化,为DMA控制器引入了gather功能,就是在不拷贝数据到网络缓冲区,而是将待发送数据的内存地址和偏移量等描述信息存在网络缓冲区,DMA根据描述信息从内核的读缓冲区截取数据并发送。它的流程是如下:
小结:
但那时的sendfile有个致命的缺陷,如果你查看Sendfild手册,你会发现如下描述
in_fd不仅仅不能是socket,而且在2.6.33之前Sendfile的out_fd必须是socket,因此sendfile几乎成了专为网络传输而设计的,限制了其使用范围比较狭窄。2.6.33之后out_fd才可以是任何file,于是乎出现了splice。
鉴于Sendfile的缺点,在Linux2.6.17中引入了Splice,它在读缓冲区和网络操作缓冲区之间建立管道避免CPU拷贝:先将文件读入到内核缓冲区,然后再与内核网络缓冲区建立管道。它的函数定义:
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
它的执行流程如下
小结:
在Java中,目前主要通过了NIO实现了mmap和sendfile。
JDK NIO提供的MappedByteBuffer底层就是调用mmap来实现的,FileChannel.map用来建立内存映射关系:把用户空间和内存空间的虚拟内存地址映射到同一块物理内存。mmap对大文件比较合适,对小文件则容易造成内存碎片,反而不是最佳使用场景。
NIO提供的FileChannel.transferTo方法可以直接将一个channel传递给另一个channel,结合上一篇推文看,channel像极了内核缓冲区。
MappedByteBuffer是个抽象类,其实例化后的对象是DirectByteBuffer。
这里FileChannel的实现类是FileChannelImpl,查看FileChannelImpl源码:
讲解DirectByteBuffer之前,先来回顾下JVM内存的一些知识。JVM运行时数据区里包含了方法区、堆区、栈、寄存器等,而其中堆区主要存放对象的区域,并且堆区存在GC,可以在JVM内存不足时进行GC,回收垃圾对象。但是DirectByteBuffer是堆外内存,不受GC控制。NIO通过DirectByteBuffer来实现的mmap零拷贝,那么有一个问题是,DirectByteBuffer的内存该如何回收呢?
再次之前,来介绍下Java的引用类型:强引用、弱引用、软引用、虚引用
JVM运行时数据区里的栈,栈中存在一个栈帧的区域(栈帧的入栈和出栈对应着Java类方法的调用和调用结束),栈帧中存放着的引用地址指向了堆中的对象。
强引用是我们日常开发见到最多的了,就是简单的 A a = new A(),a引用指向了A对象。
软引用的特点就是,当引用指向的对象置为null时,gc时软引用不一定被回收。
软引用的特点就是,当引用指向的对象置为null时,gc时弱引用一定会被回收。
虚引用的特点就是,当虚引用指向一个对象时,会把原本该对象的引用存到自己的一个引用队列里,当对象置为null,被GC调后,仍然可以通过引用队列获取到虚引用对象。
那么,到此讲了Java引用的这么多概念,到底和Java零拷贝有啥关系?答案就是:
DirectByteBuffer堆外内存的回收,就用到了虚引用。
下面看下DirectByteBuffer对象的构造方法
接着看下Deallocator类
这样,我们已经找到了释放堆外内存DirectByteBuffer内存的核心代码了,问题是此处的Deallocator是怎么调用,怎么运行起来的呢? 下面就要引入虚引用这个概念了。
Cleanner#clean方法真正的入口在Reference里
那么,NIO使用DirectByteBuffer实现零拷贝的流程是怎样的呢?
如上图,通过DirectByteBuffer对象创建了一块堆外的内存,通过这块堆外的内存来实现mmap方式的零拷贝,当零拷贝结束,堆外内存不再需要被用到时,就会通过Cleaner对象进行内存回收,整个回收机制如下:
至此,DirectByteBuffer创建的堆外内存就被真正的释放掉了。