?【Java深度系列】“技术盲区”让我们来探索一下Netty(Java) (I)底层的“零拷贝”技术
Netty的零拷贝
Netty中的零拷贝不同于传统的零拷贝。
传统的零拷贝是指数据传输的过程,不需要CPU来拷贝数据。它是用户空间和内核之间主要数据的拷贝。
传统意义的零拷贝
零拷贝描述的是中央处理器不执行将数据从一个内存区域拷贝到另一个内存区域的任务的计算机操作。
发送数据时,传统的方式是:
File.read(字节)
Socket.send(字节)
该方法需要四个数据副本和四个上下文切换:
数据从磁盘读取到内核的读取缓冲区。
数据从内核缓冲区复制到用户缓冲区。
数据从用户缓冲区复制到内核的套接字缓冲区。
数据从内核的套接字缓冲区复制到网卡接口的缓冲区。
显然,上面的第二步和第三步是不必要的,通过java的FileChannel.transferTo方法可以避免上面的两个冗余副本(当然这需要底层操作系统的支持)。
调用TransferTo,数据由DMA引擎从文件复制到内核读缓冲区。
然后,DMA将数据从内核读取缓冲区复制到网卡接口缓冲区。
以上两个操作不需要CPU参与,所以实现了零拷贝。
Netty中的零拷贝
Netty中也使用了FileChannel.transferTo方法,所以Netty的零拷贝也包括上面提到的操作系统级别的零拷贝。此外,在ByteBuf的实现中,Netty还提供了一些零拷贝的实现。
关于ByteBuffer,Netty提供了两个接口:
字节数
字节堆栈
Netty为ByteBuf提供了几种实现:
堆字节流:直接在堆内存中分配。
Direct ByteBuf:直接在内存区域分配内存,而不是堆内存。
复合缓冲区
Direct Buffers(直接内存)
直接在内存区域分配空间,而不是在堆内存中。
如果使用传统的堆内存分配,当我们需要通过socket发送数据时,需要先从堆内存复制到直接内存,再从直接内存复制到网络接口层。
Netty提供的直接Buffer直接将数据分配到内存空间,避免了数据拷贝,实现了零拷贝。
堆外内存
如果在JVM内部执行I/O操作,则必须先将数据复制到堆外的内存中,然后才能执行系统调用。
VM语言,那么为什么操作系统不能直接使用JVM堆内存进行I/O读写呢?
主要有两点原因:
操作系统不知道JVM的堆内存,JVM的内存布局与操作系统分配的内存布局不同。操作系统不会根据JVM的行为读写数据。
同一个对象的内存地址可能会随着JVM GC的执行而随时改变。比如JVM GC过程中会通过压缩来减少内存碎片,这就涉及到对象移动的问题。
Netty在I/O操作时使用堆外内存,可以避免将数据从JVM堆内存复制到堆外内存。
JDK告诉我们,NIO操作不适合直接在堆上操作。由于堆由GC直接管理,GC在IO写入过程中可能会组织内存空间,导致一次IO写入的内存地址不完整。
JNI(Java Native Inteface)调用IO操作的C类库时,规定写入时地址不能失效,导致无法直接在堆上执行IO操作。它也是在IO操作期间禁止GC的一个选项。如果IO耗时过长,可能会导致堆空间溢出。
Composite Buffers
传统字节缓冲,如果你需要放两个字节
eBuffer中的数据组合到一起,我们需要首先创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf,就可以避免这样的操作,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
FileChannel.transferTo的使用
Netty中使用了FileChannel的transferTo方法,该方法依赖于操作系统实现零拷贝。
总结
Netty的零拷贝体现在三个方面:
-
Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。
- 如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
-
Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。
-
Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
关于堆外内存的回收
堆外内存的回收其实依赖于我们的GC机制
-
首先,我们要知道在java层面和我们在堆外分配的这块内存关联的只有与之关联的DirectByteBuffer对象了,它记录了这块内存的基地址以及大小,那么既然和GC也有关,那就是GC能通过操作DirectByteBuffer对象来间接操作对应的堆外内存了。
-
DirectByteBuffer对象在创建的时候关联了一个PhantomReference,说到PhantomReference其实主要是用来跟踪对象何时被回收的,它不能影响GC决策。
-
GC过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在GC完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,而DirectByteBuffer关联的PhantomReference是PhantomReference的一个子类,在最终的处理里会通过Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块。
为什么要主动调用System.gc
System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收,DirectByteBuffer对象以及他们关联的堆外内存.
DirectByteBuffer对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为冰山对象。
做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题.
如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为heap明明剩余的内存还很多。
资源学习
https://www.jianshu.com/p/61a7916b37fd
极限就是为了超越而存在的
内容来源网络,如有侵权,联系删除,本文地址:https://www.230890.com/zhan/157191.html