Netty网络编程——NIO与零拷贝

1.什么是DMA

2.什么是用户态和内核态

3.普通BIO的拷贝流程分析

4.mmap系统函数

5.sendFile系统函数(零拷贝)

6.java堆外内存如何回收

1.什么是DMA

DMA(Direct Memory Access直接存储器访问),我们先从一张图来了解一下DMA是一个什么装置。

Netty网络编程——NIO与零拷贝_第1张图片

假设在什么没有DMA的情况下,如果CPU想从内存里读取数据并发送到网卡中,在读的过程中,我们可以知道:
1.1)CPU的速度最快
1.2)当CPU在内存中读取数据的时候,读取的速度瓶颈在于内存的读写速度
1.3)当CPU完成读取,将数据写入网卡的时候,写入的速度瓶颈在于网卡的速度
1.4)CPU在读写的时候,是无法做其它事情的。

这个时候我们就可以得出结论:

1.5)cpu的速度取决于这一系列操作环上最慢的那一个。
1.6)cpu利用率极低,大部分时间都在等待IO

此时如果有了DMA,那么我们的读写就会变得和如图一样:

Netty网络编程——NIO与零拷贝_第2张图片

CPU只需要把读写任务委托给DMA,协助CPU搬运数据,这些操作都由DMA自动执行,而不需要依赖于CPU的大量中断负载,此时cpu就可以去做其它的事情了。

2.什么是用户态和内核态
其实最后我们的服务器程序,都是要在linux上运行的,Linux根据命令的重要程度,也分为不同的权限。Linux操作系统就将权限分成了2个等级,分别就是用户态和内核态

用户态:
用户态的进程能够访问的资源就有极大的限制,有些指令操作无关痛痒,随意执行都没事,大部分都是属于用户态的。

内核态:
运行在内核态的进程可以“为所欲为”,可以直接调用操作系统内核函数。

比如:我们调用malloc函数申请动态内存,此时cpu就要从用户态切换到内核态,调用操作系统底层函数来申请空间。

Netty网络编程——NIO与零拷贝_第3张图片

3.普通BIO的拷贝流程分析

我们来看一下普通IO的拷贝流程

我们来看这一段代码:
Netty网络编程——NIO与零拷贝_第4张图片

我们先从服务器上读取了一个文件,然后通过连接和流传输到请求客户端上,我们可以看到大致的请求流程是这样的:

Netty网络编程——NIO与零拷贝_第5张图片

当程序或者操作者对CPU发出指令,这些指令和数据暂存在内存里,在CPU空闲时传送给CPU,CPU处理后把结果输出到输出设备上

3.1)用户态程序接到请求,要从磁盘上读取文件,切换到内核态,这里是第1次用户态内核态切换
3.2)当要读取的文件通过DMA复制到内核缓冲区的时候,我们还要把这些数据传送给CPU,CPU之后再把这些数据送到输出设备上,这里是第1次cpu拷贝
3.3)当内核态程序数据读取完毕,切换回用户态,这里是第2次内核态用户态切换
3.4)当程序创建一个缓冲区,并将数据写入socket缓冲区,这里是第3次用户态内核态切换
3.5)此时cpu要把数据拷贝到socket缓冲区,这里是第2次cpu拷贝
3.6)完成所有操作之后,应用程序从内核态切换回用户态,继续执行后续操作(程序到此为止)。这里是第4次用户态内核态切换

此时我们可以看出,传统的IO拷贝流程,经历了4次用户态和内核态的切换,进行了2次cpu复制,性能耗费巨大,我们有没有更节省资源的做法呢?

4.mmap系统函数

linux的底层内核函数mmap函数对底层进行了一个优化:

Netty网络编程——NIO与零拷贝_第6张图片

4.1)用户态程序接到请求,要从磁盘上读取文件,切换到内核态,这里是第1次用户态内核态切换
4.2)当要读取的文件通过DMA复制到内核缓冲区完成,此时内核缓冲区,用户数据缓冲区共享一块物理内存空间,这里就无需cpu拷贝到用户空间中
4.3)此时读取文件完毕,用户切换回用户态,这是第2次用户态内核态切换
4.4)申请一块缓冲区,需要调用内核函数,这是第3次用户态内核态切换
4.5)内核态通过cpu复制,将共享空间的数据内容拷贝到socket缓冲区中,这是第1次cpu拷贝
4.6)完成所有操作之后,应用程序从内核态切换回用户态,继续执行后续操作(程序到此为止)。这里是第4次用户态内核态切换

我们可以看出,mmap函数少了一次cpu复制,对于空间的利用率提高了,不过还是需要4次用户态和内核态的切换

5.sendFile系统函数(零拷贝)

零拷贝:指的是没有cpu拷贝,数据还是需要通过DMA拷贝到内存中,再发送出去的

Netty网络编程——NIO与零拷贝_第7张图片
4.1)用户态程序接到请求,要从磁盘上读取文件,切换到内核态,这里是第1次用户态内核态切换。
4.2)当数据通过DMA复制进入内核缓冲区并且完成,我们还是通过cpu复制把数据复制到socket缓冲区,不过这里的cpu复制只复制很少量的内容,可以几乎忽略不计。

4.3)此时数据通过DMA复制发送给目的地。
4.4)程序切换回用户态,这是第2次用户态内核态切换

我们发现,sendFile系统函数,只需要两次用户态到内核态的切换,而且一次cpu复制都不需要,大大节约了资源。

6.java堆外内存如何回收

介绍了零拷贝技术,其实Netty底层是使用堆外内存来实现零拷贝技术的,api:ByteBuffer.allocateDirect(),这条命令直接在堆外内存开辟了一块空间,我们都知道GC是收集堆内存垃圾的,那堆外内存又是如何收集的呢

堆外内存的优势:
堆外内存的优势在于IO上,java在使用socket发送数据的时候,如果使用堆外内存,就可以直接使用堆外内存往socket上发送数据,就节省了先把堆外数据拷贝到堆内数据的开销。

我们先来看看ByteBuffer.allocateDirect()的源码:
Netty网络编程——NIO与零拷贝_第8张图片
我们可以看出,java使用unsafe类来分配了一块堆外内存

那么堆外内存是如何回收的呢?我们来看这样一行代码:

Netty网络编程——NIO与零拷贝_第9张图片

cleaner就是用来回收堆外内存的,但是它是如何工作的呢?我们仔细研究一下cleaner这个类,它是一个链表结构:

Netty网络编程——NIO与零拷贝_第10张图片

通过create(Object,Runnable)方法创建cleaner对象,调用自身的add方法,将其加入链表中。
Netty网络编程——NIO与零拷贝_第11张图片

clean有个重要的clean方法, 它首先将对象从自身链表中删除:
Netty网络编程——NIO与零拷贝_第12张图片
然后执行this.thunk的run方法,thunk就是由创建的时候传入的Runnable函数:
Netty网络编程——NIO与零拷贝_第13张图片
可以看出,run方法是一个释放堆外内存的函数。

逻辑我们已经梳理完,但是JVM如何释放其占用的堆外内存呢如何跟Cleaner关联起来

首先,Cleaner继承了PhantomReference(虚引用),关于强软弱虚引用,在前面的博客已经赘述过:深入理解JVM(八)——强软弱虚引用

简单地再介绍一下虚引用,当GC某个对象的时候,如果此对象上有虚引用,会将其加入PhantomReference加入到ReferenceQueue队列。

Cleaner继承PhantomReference,而PhantomReference又继承Reference,Reference初始化的时候,会运行一个静态代码块:
Netty网络编程——NIO与零拷贝_第14张图片

我们可以看出,ReferenceHandler作为一个优先级比较高的守护线程被启动了。

在看他的处理逻辑之前,我们先了解一下对象的四种状态;

  • Active:激活。创建ref对象时就是激活状态
  • Pending:等待入引用队列。所对应的引用被GC,就要入队。
  • Enqueued:入队状态。

    • 如果指定了refQueue消费pending移动到enqueued状态。refQueue.poll时进入失效状态
    • 如果没有指定refQueue,直接到失效状态。
  • Inactive:失效

接下来我们可以看业务逻辑了:
Netty网络编程——NIO与零拷贝_第15张图片

这是一个死循环,我们再往里点:

    static boolean tryHandlePending(boolean waitForNotify) {
        Reference r;
        Cleaner c;
        try {
            //可能有多线程对一个引用队列操作,所以要加锁
            synchronized (lock) {
                  //如果当前对象是 等待入引用队列 的状态
                if (pending != null) {
                    r = pending;
                    // 'instanceof' might throw OutOfMemoryError sometimes
                    // so do this before un-linking 'r' from the 'pending' chain...
                    //转化为clean对象
                    c = r instanceof Cleaner ? (Cleaner) r : null;
                    // unlink 'r' from 'pending' chain
                    //解除引用
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                     //如果没有,等待唤醒
                    // The waiting on the lock may cause an OutOfMemoryError
                    // because it may try to allocate exception objects.
                    if (waitForNotify) {
                        lock.wait();
                    }
                    // retry if waited
                    return waitForNotify;
                }
            }
        } catch (OutOfMemoryError x) {
            // Give other threads CPU time so they hopefully drop some live references
            // and GC reclaims some space.
            // Also prevent CPU intensive spinning in case 'r instanceof Cleaner' above
            // persistently throws OOME for some time...
            Thread.yield();
            // retry
            return true;
        } catch (InterruptedException x) {
            // retry
            return true;
        }

        // Fast path for cleaners
        //清除内存
        if (c != null) {
            c.clean();
            return true;
        }

        ReferenceQueue q = r.queue;
        if (q != ReferenceQueue.NULL) q.enqueue(r);
        return true;
    } 
 

我们可以得出:
1)当对象状态是Pending的时候,就会进入if,将这个对象转化为clean对象,并将这个引用置空
2)进行clean的垃圾收集
3)这个线程一直在后台启动,如果有引用,就会唤醒该线程。

你可能感兴趣的:(niommap)