Netty ByteBuf与NIO ByteBuffer

Netty中引入了ByteBuf,相较于NIO中的ByteBuffer有了一下改进,使得网络编程更加简便。

ByteBuffer的缺点

       在NIO的Buffer中,我们已经知道Buffer有七种,最常用的是ByteBuffer,而ByteBuffer也有一些局限性:

  1. ByteBuffer的长度是固定的,一旦分配完成就不能进行扩容和收缩,当需要操作的对象大于Buffer的容量时,会发生异常;

  2. 在进行读写状态切换时,需要调用flip()或rewind()方法改变指针position的位置,稍有不慎,就不能完成任务;

基于ByteBuffer的这些局限性,Netty的ByteBuf改进了这些问题。

ByteBuf的工作原理

      由于ByteBuf是改进了ByteBuffer的缺点, 这一部分内容主要从两个方面介绍:

扩容

ByteBuffer

   在对ByteBuffer进行put()操作时,若缓冲区可写入空间不足,会发生BufferOverFlowException异常,因此,ByteBuffer中每次在进行put()操作时,都会对可用空间进行校验,空间不足时,会创建一个新的ByteBuffer,将旧ByteBuffer复制到新的中去,最后释放旧的ByteBuffer,代码如下:


   
   
   
   
  1. if( this.buffer.remaining() < needSize) {
  2.              int toBeExtSize = needSize < 128 ? needSize : 128;
  3.             ByteBuffer tmpBuffer = ByteBuffer.allocate( this.buffer.capacity() + toBeExtSize);
  4.              this.buffer.flip();
  5.             tmpBuffer.put( this.buffer);
  6.              this.buffer = tmpBuffer;
  7.         }

ByteBuf   

问题在于,每次进行put()操作都要进行可写空间校验,会导致代码冗余,不小心操作就可能出现别的问题。基于此,ByteBuf对ByteBuffer进行了封装,利用write操作进行可用空间的校验,当可用空间不足时,ByteBuf会进行自动扩容,对于使用者而言,不需要关心底层实现,操作更简便。


   
   
   
   
  1. AbstractByteBuf
  2.      @Override
  3.      public ByteBuf writeByte(int value) {
  4.         ensureWritable( 1);
  5.         setByte(writerIndex++, value);
  6.          return this;
  7.     }
  8.      @Override
  9.      public ByteBuf ensureWritable(int minWritableBytes) {
  10.          if (minWritableBytes < 0) {
  11.              throw new IllegalArgumentException(String.format(
  12.                      "minWritableBytes: %d (expected: >= 0)", minWritableBytes));
  13.         }
  14.          if (minWritableBytes <= writableBytes()) {
  15.              return this;
  16.         }
  17.          if (minWritableBytes > maxCapacity - writerIndex) {
  18.              throw new IndexOutOfBoundsException(String.format(
  19.                      "writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s",
  20.                     writerIndex, minWritableBytes, maxCapacity, this));
  21.         }
  22.         //扩容
  23.          int newCapacity = calculateNewCapacity(writerIndex + minWritableBytes);
  24.         capacity(newCapacity);
  25.          return this;
  26.     }

   可以看到具体的容量计算在calcculateNewCapacity()方法中:


   
   
   
   
  1. private int calculateNewCapacity(int minNewCapacity) {
  2.      int maxCapacity = this.maxCapacity;
  3.      int threshold = 4194304; //4mb阀值
  4.      if(minNewCapacity == 4194304) { //如果新容量为阀值,直接返回
  5.          return 4194304;
  6.     } else {
  7.          int newCapacity;
  8.          if(minNewCapacity > 4194304) { //如果传入的新容量大于阀值,进行计算
  9.             newCapacity = minNewCapacity / 4194304 * 4194304;
  10.              if(newCapacity > maxCapacity - 4194304) { //如果大于最大容量,新容量为最大容量
  11.                 newCapacity = maxCapacity;
  12.             } else { //否则新容量 + 阀值 4mb,按照阀值扩容
  13.                 newCapacity += 4194304;
  14.             }
  15.              return newCapacity;
  16.         } else { //如果小于阀值,则以64为计数倍增,知道倍增的结果>=需要的容量值
  17.              for(newCapacity = 64; newCapacity < minNewCapacity; newCapacity <<= 1) {
  18.                 ;
  19.             }
  20.              return Math.min(newCapacity, maxCapacity);
  21.         }
  22.     }
  23. }

注意:

1. 当申请的新空间大于阀值时,采用每次步进4MB的方式进行扩张内存,而不是倍增,因为这会造成内存膨胀和浪费 

2. 而但申请的新空间小于阀值时,则以64为基数进行倍增而不是步进,因为当内存比较小的时候,倍增是可以接受的(64 -> 128 和 10Mb -> 20Mb相比)

位置指针

ByteBuffer

     ByteBuffer只有一个位置指针,当我们需要将读写状态切换时,需要手动调用flip()方法或rewind()方法改变position的值,使用不慎就会造成问题。


   
   
   
   
  1. //创建缓冲区
  2. ByteBuffer buffer = ByteBuffer.allocate( 1024);
  3. //存入数据
  4. buffer.put(( byte) 'A');
  5. buffer.put(( byte) 'B');
  6. //翻转
  7. buffer.flip();
  8. //读取二次数据
  9. System.out.println(( char)buffer.get());
  10. System.out.println(( char)buffer.get());

ByteBuf

ByteBuf通过两个位置指针来协助缓冲区的读写操作,读操作使用readerIndex,写操作使用writerIndex。

readerIndex和writerIndex的取值一开始都是0,随着数据的写入writerIndex会增加,读取数据会使readerIndex增加,但是它不会超过writerIndex。在读取之后,0~readerIndex的就被视为discard的,调用discardReadBytes方法,可以释放这部分空间,它的作用类似ByteBuffer的compact方法。readerIndex和writerIndex之间的数据是可读取的,等价于ByteBuffer position和limit之间的数据。writerIndex和capacity之间的空间是可写的,等价于ByteBuffer limit和capacity之间的可用空间。

由于写操作不修改readerIndex指针,读操作不修改writerIndex指针,因此读写之间不再需要调整位置指针,这极大地简化了缓冲区的读写操作,避免了由于遗漏或者不熟悉flip()操作导致的功能异常。

初始分配的ByteBuf如图:

写入N个字节之后的ByteBuf如图:

读取M(<N)个字节之后的ByteBuf如图:

调用discardReadBytes操作之后的ByteBuf如图:

调用clear操作之后的ByteBuf如图:

ByteBuf源码思路

由于NIO的Channel读写的参数都是ByteBuffer,因此,Netty的ByteBuf接口必须提供API方便的将ByteBuf转换成ByteBuffer,或者将ByteBuffer包装成ByteBuf。考虑到性能,应该尽量避免缓冲区的复制,内部实现的时候可以考虑聚合一个ByteBuffer的私有指针用来代表ByteBuffer。

从内存分配的角度看,ByteBuf可以分为两类。

(1)堆内存(HeapByteBuf)字节缓冲区:特点是内存的分配和回收速度快,可以被JVM自动回收;缺点就是如果进行Socket的I/O读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核Channel中,性能会有一定程度的下降。

(2)直接内存(DirectByteBuf)字节缓冲区:非堆内存,它在堆外进行内存分配,相比于堆内存,它的分配和回收速度会慢一些,但是将它写入或者从Socket Channel中读取时,由于少了一次内存复制,速度比堆内存快。

正是因为各有利弊,所以Netty提供了多种ByteBuf供开发者使用,经验表明,ByteBuf的最佳实践是在I/O通信线程的读写缓冲区使用DirectByteBuf,后端业务消息的编解码模块使用HeapByteBuf,这样组合可以达到性能最优。

从内存回收角度看,ByteBuf也分为两类:基于对象池的ByteBuf和普通ByteBuf。

       两者的主要区别就是基于对象池的ByteBuf可以重用ByteBuf对象,它自己维护了一个内存池,可以循环利用创建的ByteBuf,提升内存的使用效率,降低由于高负载导致的频繁GC。测试表明使用内存池后的Netty在高负载、大并发的冲击下内存和GC更加平稳。尽管推荐使用基于内存池的ByteBuf,但是内存池的管理和维护更加复杂,使用起来也需要更加谨慎,因此,Netty提供了灵活的策略供使用者来做选择。

内存池原理分析 

       Arena本身是指一块区域,在内存管理中,Memory Arena是指内存中的一大块连续的区域,PoolArena就是Netty的内存池实现类。

      为了集中管理内存的分配和释放,同时提高分配和释放内存时候的性能,很多框架和应用都会通过预先申请一大块内存,然后通过提供相应的分配和释放接口来使用内存。这样一来,对内存的管理就被集中到几个类或者函数中,由于不再频繁使用系统调用来申请和释放内存,应用或者系统的性能也会大大提高。在这种设计思路下,预先申请的那一大块内存就被称为Memory Arena。

     不同的框架,Memory Arena的实现不同,Netty的PoolArena是由多个Chunk组成的大块内存区域,而每个Chunk则由一个或者多个Page组成,因此,对内存的组织和管理也就主要集中在如何管理和组织Chunk和Page了。

PoolChunk 

       Chunk主要用来组织和管理多个Page的内存分配和释放,在Netty中,Chunk中的Page被构建成一棵二叉树。假设一个Chunk由16个Page组成,那么这些Page将会被按照下图所示的形式组织起来。 

      Page的大小是4个字节,Chunk的大小是64个字节(4×16)。整棵树有5层,第1层(也就是叶子节点所在的层)用来分配所有Page的内存,第4层用来分配2个Page的内存,依次类推。

      每个节点都记录了自己在整个Memory Arena中的偏移地址,当一个节点代表的内存区域被分配出去之后,这个节点就会被标记为已分配,自这个节点以下的所有节点在后面的内存分配请求中都会被忽略。举例来说,当我们请求一个16字节的存储区域时,上面这个树中的第3层中的4个节点中的一个就会被标记为已分配,这就表示整个Memroy Arena中有16个字节被分配出去了,新的分配请求只能从剩下的3个节点及其子树中寻找合适的节点。

     对树的遍历采用深度优先的算法,但是在选择哪个子节点继续遍历时则是随机的,并不像通常的深度优先算法中那样总是访问左边的子节点。

PoolSubpage 

       对于小于一个Page的内存,Netty在Page中完成分配。每个Page会被切分成大小相等的多个存储块,存储块的大小由第一次申请的内存块大小决定。假如一个Page是8个字节,如果第一次申请的块大小是4个字节,那么这个Page就包含2个存储块;如果第一次申请的是8个字节,那么这个Page就被分成1个存储块。 

       一个Page只能用于分配与第一次申请时大小相同的内存,比如,一个4字节的Page,如果第一次分配了1字节的内存,那么后面这个Page只能继续分配1字节的内存,如果有一个申请2字节内存的请求,就需要在一个新的Page中进行分配。

      Page中存储区域的使用状态通过一个long数组来维护,数组中每个long的每一位表示一个块存储区域的占用情况:0表示未占用,1表示以占用。对于一个4字节的Page来说,如果这个Page用来分配1个字节的存储区域,那么long数组中就只有一个long类型的元素,这个数值的低4位用来指示各个存储区域的占用情况。对于一个128字节的Page来说,如果这个Page也是用来分配1个字节的存储区域,那么long数组中就会包含2个元素,总共128位,每一位代表一个区域的占用情况。

内存回收策略

       无论是Chunk还是Page,都通过状态位来标识内存是否可用,不同之处是Chunk通过在二叉树上对节点进行标识实现,Page是通过维护块的使用状态标识来实现。

     对于使用者来说,不需要关心内存池的实现细节,也不需要与这些类库打交道,只需要按照API说明正常使用即可。

辅助类功能介绍

ByteBufHolder 

      ByteBufHolder是ByteBuf的容器,在Netty中,它非常有用,例如HTTP协议的请求消息和应答消息都可以携带消息体,这个消息体在NIO ByteBuffer中就是个ByteBuffer对象,在Netty中就是ByteBuf对象。由于不同的协议消息体可以包含不同的协议字段和功能,因此,需要对ByteBuf进行包装和抽象,不同的子类可以有不同的实现。为了满足这些定制化的需求,Netty抽象出了ByteBufHolder对象,它包含了一个ByteBuf,另外还提供了一些其他实用的方法,使用者继承ByteBufHolder接口后可以按需封装自己的实现。

ByteBufAllocator 

       ByteBufAllocator是字节缓冲区分配器,按照Netty的缓冲区实现不同,共有两种不同的分配器:基于内存池的字节缓冲区分配器和普通的字节缓冲区分配器。

CompositeByteBuf

      CompositeByteBuf允许将多个ByteBuf的实例组装到一起,形成一个统一的视图,有点类似于数据库将多个表的字段组装到一起统一用视图展示。

CompositeByteBuf在一些场景下非常有用,例如某个协议POJO对象包含两部分:消息头和消息体,它们都是ByteBuf对象。当需要对消息进行编码的时候需要进行整合,如果使用JDK的默认能力,有以下两种方式:

 (1)将某个ByteBuffer复制到另一个ByteBuffer中,或者创建一个新的ByteBuffer,将两者复制到新建的ByteBuffer中;

(2)通过List或数组等容器,将消息头和消息体放到容器中进行统一维护和处理。

      上面的做法非常别扭,实际上我们遇到的问题跟数据库中视图解决的问题一致——缓冲区有多个,但是需要统一展示和处理,必须有存放它们的统一容器。为了解决这个问题,Netty提供了CompositeByteBuf。

        它定义了一个Component类型的集合,实际上Component就是ByteBuf的包装实现类,它聚合了ByteBuf对象,维护了在集合中的位置偏移量信息等。

关于使用复合缓冲区实现零拷贝会在下篇博文介绍。

ByteBufUtil 

      ByteBufUtil是一个非常有用的工具类,它提供了一系列静态方法用于操作ByteBuf对象。其中最有用的方法就是对字符串的编码和解码,具体如下。

(1)encodeString(ByteBufAllocator alloc, CharBuffer src, Charset charset):对需要编码的字符串src按照指定的字符集charset进行编码,利用指定的ByteBufAllocator生成一个新的ByteBuf;

(2)decodeString(ByteBuffer src, Charset charset):使用指定的ByteBuffer和charset进行对ByteBuffer进行解码,获取解码后的字符串。

还有一个非常有用的方法就是hexDump,它能够将参数ByteBuf的内容以十六进制字符串的方式打印出来,用于输出日志或者打印码流,方便问题定位,提升系统的可维护性。hexDump包含了一系列的方法,参数不同,输出的结果也不同。

Unpooled (非池化)缓存

当未引用 ByteBufAllocator 时,上面的方法无法访问到 ByteBuf。对于这个用例 Netty 提供一个实用工具类称为 Unpooled,,它提供了静态辅助方法来创建非池化的 ByteBuf 实例。表列出了最重要的方法

在 非联网项目,该 Unpooled 类也使得它更容易使用的 ByteBuf API,获得一个高性能的可扩展缓冲 API,而不需要 Netty 的其他部分的。

你可能感兴趣的:(#,Netty,java)