本章还是对于NIO的概念铺底,无关NIO相干的代码,我还是心愿大家闲余工夫取网上找一下无关应用JDK NIO开发服务端、客户端的代码,我会取写这些,然而具体的代码我不会很具体的取介绍,下一章的话可能就要上代码了,具体的布局如下:

讲一下NIO根底API的应用、剖析Netty的核心思想,应用Reactor模式仿写一个多线程版的Nio程序、再而后就是对于Netty的源码剖析了!欢送关注公众号【源码学徒】

回归正题,NIO的高性能除了体现在Epoll模型之外,还有很重要的一点,就是零拷贝!首先大家要先明确一点,所谓的0拷贝,并不是一次拷贝都没有,而是数据由内核空间向用户空间的互相拷贝被勾销了,所以称之为零拷贝!

零碎如何操作底层数据文件

在理解整个IO的读写的过程中,咱们须要晓得咱们的应用程序是如何操作一些内存、磁盘数据的!

咱们在开发中,假如要向硬盘中写入一段文本数据,咱们并不需要操作太多的细节,而是只须要简略的将数据转为字节而后在通知程序,咱们要写入的地位以及名称就能够了,为什么这么简略呢?因为操作系统全副帮咱们开发好了,咱们只须要调用就能够了,然而咱们想一下,如果咱们的操作系统的全副权限,包含内存都能够让用户随便操作那是一个很危险的事件,例如某些病毒能够随便篡改内存中的数据,以达到某些不轨的目标,那就很好受了!所以,咱们的操作系统就必须对这些底层的API进行一些限度和爱护!

然而如何爱护呢?一方面,咱们心愿内部零碎可能调用我的零碎API,另一方面我又不想内部随便拜访我的API怎么办呢? 此时,咱们就要引申进去一个组件叫做kernel,你能够把它了解为一段程序,他在机器启动的时候被加载进来,被用于管理系统底层的一些设施,例如硬盘、内存、网卡等硬件设施!当咱们又了kernel之后,会产生什么呢?

咱们还是以写出文件为例,当咱们调用了一个write api的时候,他会将write的办法名以及参数加载到CPU的寄存器中,同时执行一个指令叫做 int 0x80的指令,int 0x80是 interrupt 128(0x80的10进制)的缩写,咱们个别叫80中断,当调用了这个指令之后,CUP会进行以后的调度,保留以后的执行中的线程的状态,而后在中断向量表中寻找 128代表的回调函数,将之前写到寄存器中的数据(write /参数)当作参数,传递到这个回调函数中,由这个回调函数去寻找对应的零碎函数write进行写出操作!

大家回忆一下,当零碎发动一个调用后不再是用户程序间接调用零碎API的而是切换成内核调用这些API,所以内核是以这种形式来爱护零碎的而且这也就是 用户态切换到内核态

传统的I/O读写

场景:读取一个图片通过socket传输到客户端展现。

  1. 程序发动read申请,调用零碎read api由用户态切换至内核态!
  2. CPU通过DMA引擎将磁盘数据加载到内核缓冲区,触发停止指令,CPU将内核缓冲区的数据拷贝到用户空间!由内核态切换至用户态!
  3. 程序 发动write调用,调用零碎API,由用户态切换只内核态,CPU将用户空间的数据拷贝到Socket缓冲区!再由内核态切换至用户态!
  4. DMA引擎异步将Socket缓冲区拷贝到网卡通过底层协定栈发送至对端!

咱们能够理解一下,这当中产生了4次上下文的切换和4次数据拷贝!咱们大抵剖析一下,那些数据拷贝是多余的:

  • 磁盘文件拷贝到内核缓冲区是必须的不能省略,因为这个数据总归要读取进去的!
  • 内核空间拷贝到用户空间,如果咱们不筹备对数据做批改的话,如同没有必要呀,间接拷贝到Socket缓冲区不就能够了
  • Socket到网卡,如同也有点多余,为什么这么说呢?因为咱们间接从内核空间外面间接怼到网卡外面,两头不就少了很多的拷贝和上下文的切换看吗?

sendfile

咱们通过Centos man page指令查看该函数的定义!

也能够通过该链接下载:sendfile()函数介绍

根本介绍:

sendfile——在文件描述符之间传输数据

形容

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

sendfile()在一个文件描述符和另一个文件描述符之间复制数据。因为这种复制是在内核中实现的,所以sendfile()比read(2)和write(2)的组合更高效,后者须要在用户空间之间来回传输数据。

in_fd应该是关上用于读取的文件描述符,而out_fd应该是关上用于写入的文件描述符。

如果offset不为NULL,则它指向一个保留文件偏移量的变量,sendfile()将从这个变量开始从in_fd读取数据。当sendfile()返回时,这个变量将被设置为最初一个被读取字节前面的字节的偏移量。如果offset不为NULL,则sendfile()不会批改以后值

租用文件偏移in_fd;否则,将调整以后文件偏移量以反映从in_fd读取的字节数。

如果offset为NULL,则从以后文件偏移量开始从in_fd读取数据,并通过调用更新文件偏移量。

count是要在文件描述符之间复制的字节数。

in_fd参数必须对应于反对相似mmap(2)的操作的文件(也就是说,它不能是套接字)。

在2.6.33之前的Linux内核中,out_fd必须援用一个套接字。从Linux 2.6.33开始,它能够是任何文件。如果是一个惯例文件,则sendfile()适当地更改文件偏移量。

简略来说,sendfile函数能够将两个文件描述符外面的数据来回复制,再Linux中万物皆文件!内核空间和Socket也是一个个的对应的文件,sendfile函数能够将两个文件外面的数据来回传输,这也造就了,咱们前面的零拷贝优化!

sendfile - linux2.4之前

  1. 用户程序发动read申请,程序由用户态切换至内核态!
  2. DMA引擎将数据从磁盘拷贝进去到内核空间!
  3. 调用sendfile函数将内核空间的数据间接拷贝到Socket缓冲区!
  4. 上下文从内核态切换至用户态
  5. Socket缓冲区通过DMA引擎,将数据拷贝到网卡,通过底层协定栈发送到对端!

这个优化不堪称不狠,上下文切换次数变为两次,数据拷贝变为两次,这根本合乎了咱们下面的优化要求,然而咱们还是会发现,从内核空间到Socket缓冲区,而后从内核缓冲区到网卡仿佛也有点鸡肋,所以,Linux2.4之后再次进行了优化!

sendfile - linux2.4之后

  1. 用户程序发动read申请,程序由用户态切换至内核态!
  2. DMA引擎将数据从磁盘拷贝进去到内核空间!
  3. 调用sendfile函数将内核空间的数据再内存中的起始地位和偏移量写入Socket缓冲区!而后内核态切换至用户态!
  4. DMA引擎读取Socket缓冲区的内存信息,间接由内核空间拷贝至网卡!

这里的优化是本来将内核空间的数据拷贝至Socket缓冲区的步骤,变成了只记录文件的起始地位和偏移量!而后程序间接返回,由DMA引擎异步的将数据从内核空间拷贝到网卡!

为什么不是间接拷贝,而是多了一步记录文件信息的步骤呢?因为相比于内核空间,网卡的读取速率切实是太慢了,这一步如果由CPU来操作的话,会重大拉低CPU的运行速度,所以要交给DMA来做,然而因为是异步的,DMA引擎又不晓得为这个Socket到底发送多少数据,所以要在Socket上记录文件起始量和数据长度,再由DMA引擎读取这些文件信息,将文件发送只网卡数据!

mmap

咱们通过Centos man page指令查看该函数的定义!

mmap()函数介绍

名字

mmap, munmap -将文件或设施映射到内存中

void *mmap(void *addr, size_t length, int prot, int flags,       int fd, off_t offset);int munmap(void *addr, size_t length);

形容:

mmap()在调用过程的虚拟地址空间中创立一个新的映射。新映射的起始地址在addr中指定。length参数指定映射的长度,如果addr为空,则内核抉择创立映射的地址;这是创立新映射的最可移植的办法。如果addr不为空,则内核将其作为提醒!对于在哪里搁置映射;在Linux上,映射将在左近的页面边界创立。新映射的地址作为调用的后果返回。

mmap()零碎调用使得过程之间通过映射同一个一般文件实现共享内存。一般文件被映射到过程地址空间后,过程能够像拜访一般内存一样对文件进行拜访,不用再调用read(),write()等操作。

什么叫区域共享,这个不能被了解为咱们的应用程序就能够间接到内核空间读取数据了,而是咱们在用户空间外面再开拓一个空间,将内核空间的数据的起始以及偏移量映射到用户空间!简略点说 也就是用户空间的内存,持有对内核空间这一段内存区域的援用!这样用户空间在操作读取到的数据的时候,就能够像间接操作本人空间下的数据一样操作内核空间的数据!

  1. 用户程序发动read申请,而后上下文由用户态切换至内核态!
  2. cpu告诉DMA,由DMA引擎异步将数据读取至内核区域,同时在用户空间建设地址映射!
  3. 上下文由内核态切换至用户态
  4. 发动write申请,上下文由用户态切换至内核态!
  5. CPU告诉DMA引擎将数据拷贝至Socket缓存!程序切换至用户态!
  6. DMA引擎异步将数据拷贝至网卡!

很明确的发现mmap函数在read数据的时候,少了异步由内核空间到用户空间的数据复制,而是间接建设一个映射关系,操作的时候,间接操作映射数据,然而上下文的切换没有变!

mmap所建设的虚拟空间,空间量事实上能够远大于物理内存空间,假如咱们想虚拟内存空间中写入数据的时候,超过物理内存时,操作系统会进行页置换,依据淘汰算法,将须要淘汰的页置换成所需的新页,所以mmap对应的内存是能够被淘汰的(若内存页是"脏"的,则操作系统会先将数据回写磁盘再淘汰)。这样,就算mmap的数据远大于物理内存,操作系统也能很好地解决,不会产生性能上的问题。

sendfile: 只经验两次上线文的切换和两次数据拷贝,然而毛病也不言而喻,你无奈对数据进行批改操作!适宜大文件的数据传输!而且是没有没有批改数据的需要!

mmap: 经验4次上下文的切换、三次数据拷贝,然而用户操作读取来的数据,异样简略!适宜小文件的读写和传输!

nio的堆外内存

堆外内存的实现类是DirectByteBuffer, 咱们查看SocketChannel再向通道写入数据的时候的代码:

这段代码是当你调用SocketChannel.write的时候的源代码,咱们从代码中能够得悉,无论你是否应用的是不是堆外内存,在外部NIO都会将其转换为堆外内存,而后在进行后续操作,那么堆外内存到底有何种魔力呢?

何为堆外内存,要晓得咱们的JAVA代码运行在了JVM容器外面,咱们又叫做Java虚拟机,java开发者为了不便内存治理和内存调配,将JVM的空间与操作系统的空间隔离了起来,市面上所有的VM程序都是这样做的,VM程序的空间结构和操作系统的空间结构是不一样的,所以java程序无奈间接的将数据写出去,必须先将数据拷贝到C的堆内存上也就是常说的堆外内存,而后在进行后续的读写,在NIO中间接应用堆外内存能够省去JVM外部数据向本次内存空间拷贝的步骤,放慢处理速度!

而且NIO中每次写入写出不在是以一个一个的字节写出,而是用了一个Buffer内存块的形式写出,也就是说只须要通知CPU 我这个数据块的数据开始的索引以及数据偏移量就能够间接读取,然而JVM通过垃圾回收的时候,通过会做垃圾拷贝整顿,这个时候会挪动内存,这个时候如果内存地址扭转,就势必会呈现问题,所以咱们要想一个方法,让JVM垃圾回收不影响这个数据块!

总结来说:它能够应用Native 函数库间接调配堆外内存,而后通过一个存储在Java 堆外面的DirectByteBuffer 对象作为这块内存的援用进行操作。这样能在一些场景中显著进步性能,因为防止了在Java 堆和Native 堆中来回复制数据。

可能防止JVM垃圾回收过程中做内存整理,所产生的的问题,当数据产生在JVM外部的时候,JVM的垃圾回收就无奈影响这部分数据了,而且可能变相的加重JVM垃圾回收的压力!因为不必再治理这一部分数据了!

他的内存构造看起来像这样:

为什么DirectByteBuffer就可能间接操作JVM外的内存呢?咱们看下他的源码实现:

DirectByteBuffer(int cap) {         .....疏忽....        try {            //分配内存            base = unsafe.allocateMemory(size);        } catch (OutOfMemoryError x) {            ....疏忽....        }        ....疏忽....        if (pa && (base % ps != 0)) {            //对齐page 计算地址并保留            address = base + ps - (base & (ps - 1));        } else {            //计算地址并保留            address = base;        }        //开释内存的回调        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));        ....疏忽..    }

咱们次要关注:unsafe.allocateMemory(size);

public native long allocateMemory(long var1);

咱们能够看到他调用的是 native办法,这种办法通常由C++实现,是间接操作内存空间的,这个是被jdk进行平安爱护的操作,也就是说你通过Unsafe.getUnsafe()是获取不到的,必须通过反射,具体的实现,自行翻阅浏览器!

如此NIO就能够通过本地办法去操作JVM外的内存,然而大家有没有发现一点问题,咱们当初是可能让操作系统间接读取数据了,而且也可能防止垃圾回收所带来的影响了还能加重垃圾回收的压力,堪称是一举三得,然而大家有没有思考过一个问题,这部分空间不通过垃JVM治理了,他该什么时候开释呢?JVM都治理不了了,那么堆外内存势必会导致OOM的呈现,所以,咱们必须要去手动的开释这个内存,然而手动开释对于编程复杂度难度太大,所以,JVM对堆外内存的治理也做了一部分优化,首先咱们先看一下上述DirectByteBuffer中的cleaner = Cleaner.create(this, new Deallocator(base, size, cap));,这个对象,他次要用于堆外内存空间的开释;

public class Cleaner extends PhantomReference<Object> {....}

虚援用

Cleaner继承了一个PhantomReference,这代表着Cleaner是一个虚援用,无关强脆弱虚援用的应用,请大家自行百度,Netty更新实现之后,我会写一篇文章做独自的介绍,这里就不一一介绍了,这里间接说PhantomReference虚援用:

public class PhantomReference<T> extends Reference<T> {    public T get() {        return null;    }    public PhantomReference(T referent, ReferenceQueue<? super T> q) {        super(referent, q);    }}

虚援用的构造函数中要求必须传递的两个参数,被援用对象、援用队列!

这两个参数的用意是什么呢,看个图

JVM中判断一个对象是否须要回收,个别都是应用可达性剖析算法,什么是可达性剖析呢?就是从所谓的办法区、栈空间中找到被标记为root的节点,而后沿着root节点向下找,被找到的都工作是存活对象,当所有的root节点寻找结束后,残余的节点也就被认为是垃圾对象

根据上图,咱们显著发现栈空间中持有对direct的援用,咱们将该对象传递给弱援用和,弱援用也持有该对象,当初相当于direct援用和ref援用同时援用堆空间中的一块数据,当direct应用结束后,该援用断开:

JVM通过可待性剖析算法,发现除了 ref援用之外,其余的没有人援用他,因为ref是虚援用,所以本次垃圾回收肯定会回收它,回收的时候,做了一件什么事呢?

咱们在创立这个虚援用的时候传入了一个队列,在这个对象被回收的时候,被援用的对象会进入到这个回调!

public class MyPhantomReference {    static ReferenceQueue<Object> queue = new ReferenceQueue<>();    public static void main(String[] args) throws InterruptedException {        byte[] bytes = new byte[10 * 1024];        //将该对象被虚援用援用        PhantomReference<Object> objectPhantomReference = new PhantomReference<Object>(bytes,queue);        //这个肯定返回null  因为切实接口定义中写死的        System.out.println(objectPhantomReference.get());        //此时jvm并没有进行对象的回收,该队列返回为空        System.out.println(queue.poll());        //手动开释该援用,将该援用置为有效援用        bytes = null;        //触发gc        System.gc();        //这里返回的还是null  接口定义中写死的        System.out.println(objectPhantomReference.get());        //垃圾回收后,被回收对象进入到援用队列        System.out.println(queue.poll());    }}

根本理解了虚援用之后,咱们再来看DirectByteBuffer对象,他在构造函数创立的时候援用看一个虚援用Cleaner!当这个DirectByteBuffer应用结束后,DirectByteBuffer被JVM回收,触发Cleaner虚援用!JVM垃圾线程会将这个对象绑定到Reference对象中的pending属性中,程序启动后援用类Reference类会创立一条守护线程:

static {        ThreadGroup tg = Thread.currentThread().getThreadGroup();        for (ThreadGroup tgn = tg;             tgn != null;             tg = tgn, tgn = tg.getParent());        Thread handler = new ReferenceHandler(tg, "Reference Handler");        //设置优先级为零碎最高优先级        handler.setPriority(Thread.MAX_PRIORITY);        handler.setDaemon(true);        handler.start();        //.......................    }

咱们看一下该线程的定义:

static boolean tryHandlePending(boolean waitForNotify) {        Reference<Object> r;        Cleaner c;        try {            synchronized (lock) {                if (pending != null) {                   //......疏忽                    c = r instanceof Cleaner ? (Cleaner) r : null;                    pending = r.discovered;                    r.discovered = null;                } else {                    //队列中没有数据结阻塞  RefQueue入队逻辑中有NF操作,感兴趣能够本人去看下                    if (waitForNotify) {                        lock.wait();                    }                    // retry if waited                    return waitForNotify;                }            }        } catch (OutOfMemoryError x) {            //产生OOM之后就让出线程的使用权,看能不能外部消化这个OOM            Thread.yield();            return true;        } catch (InterruptedException x) {            // 线程中断的话就间接返回            return true;        }        // 这里是要害,如果虚援用是一个 cleaner对象,就间接进行清空操作,不在入队        if (c != null) {            //TODO 重点关注            c.clean();            return true;        }        //如果不是 cleaner对象,就将该援用入队        ReferenceQueue<? super Object> q = r.queue;        if (q != ReferenceQueue.NULL) q.enqueue(r);        return true;    }

那咱们此时就应该重点关注c.clean();办法了!

this.thunk.run();

重点关注这个,thunk是一个什么对象? 咱们须要从新回到 DirectByteBuffer创立的时候,看看他传递的是什么。

 cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

咱们能够看到,传入的是一个 Deallocator对象,那么他所调用的run办法,咱们看下逻辑:

public void run() {    if (address == 0) {        // Paranoia        return;    }    //开释内存    unsafe.freeMemory(address);    address = 0;    Bits.unreserveMemory(size, capacity);}

重点关注unsafe.freeMemory(address);这个就是开释内存的!

至此,咱们晓得了JVM是如何治理堆外内存的了!