netty源码解读二(几种零拷贝的比较与堆外内存回收问题)

零拷贝总览

1)传统IO 需要4次复制(包括两次cpu复制) 4次用户态内核态的切换;
2)mmap/write 需要3次复制(包括一次cpu复制) 4次用户态内核态的切换;
3)sendfile 需要3次复制(包括一次cpu复制) 2次用户态内核态的切换;
4)linux2.4优化后,2次(只有两次DMA复制),2次切换,没有了cpu拷贝,实现了真正的零拷贝;
零拷贝中的零指的是cpu的零拷贝,允许DMA拷贝;
零拷贝可以分为操作系统层面和java层面;上面四点就是操作系统层面,java层面的实现可以理解为传递的是引用或是位置信息,而不是直接复制;

零拷贝产生背景

cpu内部有高速缓存,寄存器,运行速度非常快,内存条速度次之,网卡再次之,硬盘速度最慢。

cpu将数据发送至网卡过程

现在用户控件有100kb数据需要发送到对端,过程是怎样的呢?当没有DMA时,cpu会先从内存条中读取100k数据至其高速缓存,接着cpu会将100k写至网卡,这么一来会使cpu速度会拉低到和网卡一个速度,因为在读写过程中,cpu没办法再去执行其他进程了,所以DMA应运而生。
首先cpu会将100k数据从内存条读到其高速缓存,然后写至socket写缓冲区(内核空间的一块缓冲区),这里有一次cpu复制,接着cpu通知DMA控制器,让其将100k数据由socket写缓冲区移到网卡中,DMA收到命令后,会读取socket写缓冲区的数据至其本地缓存中,接着DMA将数据写至网卡中,直到socket写缓冲区的数据读完,并且数据全部由DMA缓存写至网卡后,DMA会给cpu发送一条DMA中断,告知cpu我做完了。为啥会有中断呢,原因分析如下:
若socket写缓冲区为50k,cpu执行当前进程A,从内存条中读100K数据的一半至其高速缓存,再将这50k数据复制至socket写缓冲区,此时socket写缓冲区已满了,当前进程A会从cpu的运行队列中被移至socket写缓冲器的等待队列中,即进程A变成了阻塞状态,接着cpu通知DMA控制器,让其将50k数据由socket写缓冲区移到网卡中,DMA收到命令后,先会读取socket写缓冲区的50k数据至其缓存中,接着DMA将50k数据写至网卡中,接着DMA会给cpu发送一条DMA中断,cpu收到中断后,会立马中断掉当前正在执行的进程B,将其从用户态切换至内核态,执行中断处理程序,中断程序逻辑是将socket写缓冲区的等待队列中的进程A移至cpu的运行队列中,即进程A变成了运行状态,中断处理程序结束,cpu会继续执行,所以进程A再次获得运行权利,cpu再次执行当前进程A,从内存条中读剩下50k数据至其高速缓存,再将这50k数据复制至socket写缓冲区,即重复以上操作。

cpu从本地硬盘读取数据的过程

cpu首先检查内核文件缓冲区中是否有硬盘中的数据,若有,则直接读,若没有,cpu直接取读硬盘,显然是太慢,系统首先会将当前进程A从cpu运行队列移至文件缓冲区对应的等待队列中,即进程A阻塞,接着cpu此时会通知DMA,让DMA控制器将数据从硬盘移动到内核文件缓冲区中,移动完之后,DMA会向cpu发起中断,告诉cpu我已经将数据移到内核文件缓冲区中,此时当前进程B会从用户态进入内核态,将文件缓冲区中对应的等待队列中的进程A移至cpu运行队列中,即进程A进入可运行状态,最后退出中断程序,当进程A抢到cpu资源后, 会读取文件缓冲区的数据。

虚拟内存与物理内存

两块不同的虚拟内存,可以映射到同一块物理内存,所以可以让内核的缓冲区与用户进程的缓冲区映射到同一块物理内存上,可以减少一次拷贝。

传统io

将数据从本地磁盘发送至网络对端需要进行几次拷贝呢。

首先进程会发起read操作,调用系统read函数,当前进程A会从用户态切换至内核态,先从内核缓冲区中查,若有则直接将数据从内核空间拷贝至用户空间,若没有,则当前进程A会进入到内核缓冲区对应的等待队列中,进程A进入阻塞状态,接着cpu通知DMA,让其将数据从磁盘拷贝至内核缓冲区去,DMA执行完拷贝后,会向cpu发起中断,当前进程B就会由用户态切换至内核态,执行中断程序,中断程序中会将进程A从内核缓冲区的等待队列移至cpu运行队列中,当进程A再次获得cpu执行权时,会将内核缓冲区中数据拷贝至用户态缓冲区中,此时进程A也从内核态切换至用户态了。
接着进程会发起write操作,调用系统write函数,当前进程A会从用户态切换至内核态,先查若socket写缓冲区已写满,则进程A会从运行队列移至socket写缓冲区的等待队列中即阻塞,否则将数据拷贝至本地socket写缓冲区中,此时cpu会通知DMA设备将socket写缓冲区中的数据拷贝至网卡,拷贝结束后,DMA会向cpu发起中断,当前进程B会由用户态切换至内核态,执行中断程序,中断程序中会将进程A由socket写缓冲区等待队列中移至cpu运行队列,中断程序结束,进程A切换回用户态,当再次取得cpu执行权时,会将拷贝数据由cpu至socket写缓冲区。
所以综上在执行read时,会有2次复制:cpu复制和DMA复制;至少2次用户态和内核态的切换,仅仅指进程A的切换。进程B切换有2次。当然进程B可能没有,可能只存在进程A。
执行write时,会有2次复制:cpu复制和DMA复制;至少2次用户态和内核态的切换,仅仅指进程A的切换。进程B切换有2次。当然进程B可能没有,可能只存在进程A。
所以传统io一共是4次复制,至少4次用户态和内核态之间的切换。

几种零拷贝的实现比较

mmap + write实现零拷贝

mmap函数会将磁盘上的文件映射到物理内存中一份,然后对文件的读写直接操作内存就可以了,内核会把操作的数据刷到磁盘中,另外用户数据缓冲区和内核缓冲区也同时映射到了该块物理内存,所以只要通过DMA复制,将数据复制到内核中即可,而不需要再通过cpu复制将数据从内核复制到用户,因为可以直接映射;
首先调用mmap函数,读取磁盘上的数据,当前进程A会发生用户态到内核态的切换,若内核缓冲区中有数据,则直接返回,若没有,则DMA会将数据由磁盘拷贝一份至内核缓冲区,此时用户缓冲区可直接操作与内核缓冲区映射到的同一块物理内存,减少了一次拷贝过程,当然进程A会从内核态切换回用户态。
当往磁盘上写数据时,会调用write函数,进程A发生用户态到内核态的切换,接着进程A会直接拷贝一份内核缓冲区的数据至socket写缓冲区,进程A切换回用户态,DMA会将数据由socket写缓冲区拷贝一份至磁盘。
以上相对于传统io少了一次数据拷贝,因为共享物理内存的原因。传统io是读数据会将数据拷贝至用户空间,写会将数据再拷贝至内核态,而mmap + write操作直接将数据从一个内核缓冲区拷贝至另一个socket缓冲区。

sendfile实现零拷贝

调用sendfile函数时会传递几个参数,一个是文件fd,另一个是本地的socket写缓冲区的fd。
调用sendfile,系统会先读取数据,再发送数据,之前两个函数合为一个函数了,首先进程A由用户态切换至内核态,若内核缓冲区无数据,DMA会从磁盘拷贝一份数据至内核缓冲区,接着会根据传入的socket写缓冲区的fd,将数据拷贝一份至socket写缓冲区,此时系统调用结束,进程A会切换回用户态,DMA还是将socket写缓冲区的数据拷贝一份至网卡。
相对于mmap+write,sendfile减少了两次用户态和内核态之间的切换。

Linux2.4 内核做了优化

取而代之的是只包含关于数据的位置和长度的信息的描述符由内核缓冲区传到了socket 写缓冲区中,从而消除了最后一次CPU拷贝。经过上述过程,数据只经过了2次DMA拷贝就从磁盘传送出去了。这个才是真正的Zero-Copy(这里的零拷贝是针对kernel来讲的,数据在kernel模式下是Zero-Copy)。

Netty中零拷贝的实现

Netty中的零拷贝其实就是传递引用,去掉了拷贝,直接操作同一块内存区间,实现了逻辑上的整体;
https://segmentfault.com/a/1190000007560884
https://blog.csdn.net/weixin_43113679/article/details/99624572
https://segmentfault.com/a/1190000017128263?utm_source=tag-newest

堆外内存

bio在网络传输时,面向流传输,从jvm堆(用户态)到内核socket写缓冲区,会先将数据写入jvm堆中的小缓冲区,满了后,再flush进socket写缓冲区,当socket写缓冲区满了,则阻塞当前进程,DMA将数据拷贝至网卡。
nio在网络传输时,面向块传输,从jvm堆到内核socket写缓冲区,引入了buffer概念,可以看成是byte数组,有起点位置信息,数据长度信息,将这些信息传递给内核socket写缓冲区,内核写缓冲区会主动去jvm堆中根据这些信息,将数据拷贝过来。若堆此时发生了full gc,导致位置信息发生了改变,socket写缓冲区就无法拷贝到正确的数据。而nio此时一定会引入堆外内存,即将相同大小的buffer拷贝至堆外一份,当位置信息发送给socket写缓冲区后,后者会去堆外找数据再拷贝过来。所以当使用nio传输时,一开始就使用堆外内存,就省去了将数据从堆内拷贝到堆外的一个拷贝,并且解决了由于堆full gc而导致的位置信息改变数据无法找到的问题。

源码

找到rt.jar包下nio文件中的SocketChannelImpl#write方法,入参是ByteBuffer对象,写操作最终调用的是IOUtil.write方法,首先会判断ByteBuffer是否属于DirectBuffer,即堆外内存,若是则调用writeFromNativeBuffer方法,即从堆外拷贝。若是堆内内存,则调用Util.getTemporaryDirectBuffer方法,拿到一个临时的堆外空间,大小和当前ByteBuffer一样,并且将数据拷贝一份至申请的堆外内存,最后调用writeFromNativeBuffer方法。

堆外内存的释放问题

基本原理

ReferenceQueue dummyQueue = new ReferenceQueue();
PhantomReference phantomReference = new PhantomReference(“指向堆中某个实例”, dummyQueue);当堆中某个实例失去了root引用,并且被垃圾回收后,关联它的虚引用PhantomReference实例会进入到dummyQueue队列,通过dummyQueue.poll方法可以找到PhantomReference实例,这样做的目的是,当在dummyQueue中找到了PhantomReference实例时,说明PhantomReference关联的堆中某个实例已被垃圾回收了,此时可以做一些其他事情。比如若某个实例被回收了,这个实例管理的堆外内存此时也可以执行写的代码进行回收了。堆外内存不能直接被jvm回收,所以需要通过手动回收。
ReferenceQueue的官方注释为:引用队列,在检测到适当的可达性更改后,垃圾收集器会将已注册的引用对象附加到该队列。

DirectByteBuffer类的构造方法

调用DirectByteBuffer的有参构造方法主要完成3件事(读源码一定要总结源码中做了哪些事情,这样一目了然,如果跟着源码每一行翻译,就会难以理解):
1)分配堆外内存;
2)创建Cleaner对象,其中调用其父类构造方法,将DirectByteBuffer和ReferenceQueue与虚引用关联起来;
3)创建Deallocator堆外内存回收线程,并封装进Cleaner;
DirectByteBuffer类是管理堆外内存的类,其构造方法中有几点很重要:
分配堆外内存base = unsafe.allocateMemory(size);Cleaner cleaner = Cleaner.create(this, new Deallocator(base, size, cap));Cleaner 类是jdk提供的,其继承了虚引用,this表示当前DirectByteBuffer类实例,Deallocator是一个线程,负责回收堆外内存,create方法中会执行如下核心代码:
new Cleaner(var1, var2) 入参分别对应create方法的两个参数
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2; 负责堆外内存回收
}
dummyQueue是下边讲的ReferenceQueue,这一行的意思是将虚引用关联到DirectByteBuffer类,并且同时传入ReferenceQueue,当DirectByteBuffer类失去root引用被回收时,虚引用会被加到ReferenceQueue中。
此时Cleaner类作为虚引用对DirectByteBuffer进行了监控;
回收线程最终调用native方法,freeMemory方法,释放堆外内存;

Reference类加载时启动守护线程ReferenceHandler

软,弱,虚引用都都继承了Reference抽象类。在Reference类加载进来后,会执行其static静态代码块,会启动ReferenceHandler线程,核心代码如下:
Thread handler = new ReferenceHandler(tg, “Reference Handler”);
handler.setPriority(Thread.MAX_PRIORITY);
handler.setDaemon(true);
handler.start();

其run方法一直循环
public void run() {
while (true) {
tryHandlePending(true);
}
}

tryHandlePending中主要逻辑如下:
首先会加synchronized锁,若pending队列为空,则会执行wait方法挂起当前守护线程,当垃圾回收线程往pending队列中放PhantomReference实例时会执行notify方法唤醒守护线程。
若pending队列为不为空,它会从pending队列中取出Reference引用,先判断是否为Cleaner类型,若是Cleaner类型,则会执行clean方法,再退出tryHandlePending方法;若不是Cleaner类型,则会将Reference引用转至ReferenceQueue队列。clean方法中会执行Cleanr#thunk.run方法,即Deallocator#run方法,执行清理堆外内存的逻辑。

Reference的四种状态

PhantomReference是虚引用,其构造方法的第二个参数必传,其关联的堆中某个实例被垃圾回收前,虚引用是active状态,关联的实例被回收后,虚引用会被垃圾回收线程放到pending队列,变为了pending状态,并且会判断是否传了ReferenceQueue,虚引用一定有,则会接着被PhantomReference的守护线程ReferenceHandler从pending队列中将其取出再加进到ReferenceQueue队列中,变为enqueued状态,执行完dummyQueue.poll方法后,最终会变为inactive状态,对于软引用和弱引用,由于ReferenceQueue可传可不传,当不传时,直接会变成inactive状态。
netty源码解读二(几种零拷贝的比较与堆外内存回收问题)_第1张图片

你可能感兴趣的:(netty,java,netty,零拷贝相关)