关于java:深入分析NIO的零拷贝

33次阅读

共计 8722 个字符,预计需要花费 22 分钟才能阅读完成。

本章还是对于 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 是如何治理堆外内存的了!

正文完
 0