面试题:NIO/Netty 中的零拷贝体现在哪里?

2022年6月4日10:05:56

前言

关于 NIO 里的零拷贝,很多博客提及的都是关于磁盘到网络的拷贝,他们写得很清楚了。总结起来就是,关于磁盘到网络(或磁盘到磁盘)的拷贝,与底层系统有关,Java 做的是封装。这种零拷贝是不能给我们 Java 程序操作数据的。因为 Java 程序在这里面起到的作用仅仅是发一个“系统调用”(以及封装),其它工作都是内核完成的。

现在的 Java 程序员,更多关注地是 Java 程序(内存)到网络之间的拷贝。因为关于磁盘的读写往往是通过数据库来做的,而不是通过 FileChannel 来读文件。本文想讲明白的,就是内存到网络的零拷贝。

相关知识

内核
内核是操作系统的软件,它封装了最底层的细节,提供接口,保证安全。Java 程序要调用内核的接口,就涉及 2 次模式切换。调用:从用户模式到内核模式;返回:从内核模式到用户模式。这是耗性能的。内核模式(也叫内核态)拥有比用户模式更大的权限。

系统调用
关于 Java 里的 IO 这一块,相关代码大量调用了 JNI(Java Native Interface),JNI 是由 c/c++ 写的。而这些底层语言关于 IO 这一块,调用的是“系统调用”,“系统调用”是系统内核提供的接口。

虚拟内存
对于 Linux 系统,每个进程分配的内存是虚拟内存,虚拟内存以为单位分配,并且有页表能找到物理内存的位置。虚拟内存让进程以为自己有连续的内存空间。

DirectByteBuffer 与 HeapByteBuffer 的关系

我们创建一个 DirectByteBuffer:
类 ByteBuffer

publicstatic ByteBufferallocateDirect(int capacity){returnnewDirectByteBuffer(capacity);}

底层是通过 c++ 的 malloc 方法分配内存。这个内存是堆外内存,也就是直接内存

SocketChannelImpl 的源码得在 OpenJDK 中看,它里面有 write 和 read 方法,我们只看 write,因为它们是类似的。

publicintwrite(ByteBuffer buf)throws IOException{if(buf== null)thrownewNullPointerException();synchronized(writeLock){ensureWriteOpen();int n=0;try{begin();synchronized(stateLock){if(!isOpen())return0;
                writerThread= NativeThread.current();}for(;;){//这里
                n= IOUtil.write(fd, buf,-1, nd);if((n== IOStatus.INTERRUPTED)&&isOpen())continue;return IOStatus.normalize(n);}}finally{writerCleanup();end(n>0||(n== IOStatus.UNAVAILABLE));synchronized(stateLock){if((n<=0)&&(!isOutputOpen))thrownewAsynchronousCloseException();}assert IOStatus.check(n);}}}

类 IOUtil

staticintwrite(FileDescriptor fd, ByteBuffer src,long position,
                     NativeDispatcher nd)throws IOException{//如果是DirectBufferif(srcinstanceofDirectBuffer)returnwriteFromNativeBuffer(fd, src, position, nd);//不是DirectBuffer,就是一种堆内Buffer,Java里没有HeapBuffer这个接口// Substitute a native bufferint pos= src.position();int lim= src.limit();assert(pos<= lim);int rem=(pos<= lim? lim- pos:0);//还是要创建一个临时的DirectBuffer
    ByteBuffer bb= Util.getTemporaryDirectBuffer(rem);try{
        bb.put(src);
        bb.flip();// Do not update src until we see how many bytes were written
        src.position(pos);//还是要调用这个方法int n=writeFromNativeBuffer(fd, bb, position, nd);if(n>0){// now update src
            src.position(pos+ n);}return n;}finally{
        Util.offerFirstTemporaryDirectBuffer(bb);}}
  1. 如果src为DirectBuffer,那么就直接调用writeFromNativeBuffer
  2. 否则src为一个HeapBuffer(Java中没有这个接口),先通过getTemporaryDirectBuffer创建一个临时的DirectBuffer,然后将HeapBuffer中的数据拷贝到这个临时的DirectBuffer,最后再调用writeFromNativeBuffer发送数据

writeFromNative本质是JVM发起了系统调用,将直接内存地址给内核操作。内核由于权限最高,所以可以通过我们发起JNI调用时传递的直接内存地址来帮我们直接操作堆外内存,也就减少了我们正常方式中需要将数据从用户态内存(堆内内存和堆外内存)拷贝到内核态内存。

为什么不能让内核系统直接操作堆内内存?因为 JVM 不让。

总结一下上面的内容:
在 NIO 里,通过 Buffer 的方式,Java 程序与外设(网卡、磁盘)交流,必须通过堆外内存。

如果不用 DirectBuffer 的内存复制过程:堆内内存 => 堆外内存 == 内核内存=> 外设(磁盘或者网卡缓存,它们与内核之间的数据读写不由 CPU 完成)
其中,堆外内存 == 内核内存 是因为:用户态的逻辑地址和内核态的逻辑地址使用的是同一个物理空间,内核态直接操作了用户态内存。

面试题:NIO 的零拷贝体现在哪里?

从上面的内容就可以知道 NIO 的零拷贝是怎么回事了:

  1. 使用 DirectBuffer 不仅省去了数据在堆内内存与堆外内存之间的拷贝
  2. 而且用户态的逻辑地址和内核态的逻辑地址使用的是同一个物理空间,内核态直接操作了用户态内存,省去了数据在用户态与内核态之间的拷贝。CPU不需要为数据在内存之间的拷贝消耗资源

面试题:Netty 的零拷贝体现在哪里?

Netty 是基于 NIO 的,所以上面的两点要先答出来。除了这两点外,Netty 还有自己的一点:

  • Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。

关于文件传输

其实答完上面几点就已经能让面试官刮目相看了。但文章看开头也说了,本文讲述的是内存到网络的零拷贝,还有关于磁盘到网络/磁盘到磁盘的零拷贝在文章开头大致讲述了一下。在这里简单总结一下怎么讲给面试官:

  • 关于磁盘到网络/磁盘到磁盘的零拷贝,NIO/Netty 是通过 transferTo 完成的,transferTo 发出系统调用,零拷贝由系统内核完成。(也就是说零拷贝能到那种程度,取决于你的操作系统)

更多详情请看文章末尾的参考文章

关于 TCP 缓冲区的思考

堆内内存 => 堆外内存 == 内核内存=> 网卡 的过程中,TCP 缓冲区在哪儿?

TCP 缓冲区在内核中,这是可以肯定的。但问题是现在内核操作的内存其实是 Java 申请的堆外内存,之后就要传输数据到网卡了,也没有再复制到 TCP 缓冲区这一步,那么 TCP 缓冲区到底在哪里呢?

其实,TCP 缓冲区保存的也是内存的地址。这样来看,似乎就没什么问题了。堆外内存,内核内存,TCP 缓冲区用了同一块物理内存。

如果有误,欢迎指正。

参考文章

浅谈NIO与零拷贝

Linux 虚拟内存、Java直接内存和内存映射

  • 作者:君莫笑(๑˙ー˙๑)
  • 原文链接:https://blog.csdn.net/weixin_44367006/article/details/106578474
    更新时间:2022年6月4日10:05:56 ,共 3479 字。