Netty学习笔记(9)——Netty组件ByteBuf

编程

1. ByteBuf作用

    1. 当进行数据传输时,都会使用到一个缓冲区,在jdk提供的NIO中最常用的就是ByteBuffer,但在使用的时候,我们很容易会感到ByteBuffer有以下缺点:

  • ByteBuffer实际上就是一个Byte数组,所以在一开始进行创建时,就必须要指定其大小,而且不能进行动态扩容以及缩容,这就引起了很多问题,经常会导致数组下标越界异常。
  • ByteBuffer中有三个标示位,用于标示读写时的位置,所以在使用ByteBuffer进行读写时必须经常调用flip()方法来切换读写模式,否则就会导致程序异常。
  • ByteBuffer的API功能有限。

    2. 为了弥补ByteBuffer的缺点,Netty中提供了ByteBuf类来取代NIO的ByteBuffer。其优点如下:

  • 通过内置的复合缓冲区类型实现了透明的零拷贝;
  • 容量可以按需增长(类似于 JDK 的 StringBuilder);
  • 在读和写这两种模式之间切换不需要调用 ByteBuffer 的 flip()方法;
  • 读和写使用了不同的索引;
  • 支持方法的链式调用;
  • 支持引用计数;
  • 支持池化;
  • 它可以被用户自定义的缓冲区类型扩展;

    ByteBuf并不是和ByteBuffer一样,是一个具体实现类,而是一个抽象类,我们可以通过继承该抽象类自己去实现所需要的缓冲区,当然,Netty中也提供了非常丰富的具体实现类,基本满足我们的使用需求。

2. 原理分析

    1. 首先,ByteBuf肯定还是和ByteBuffer一样,底层都是一个Byte类型的数组,基本功能是与ByteBuffer一致的,也就是ByteBuffer有的API,ByteBuf里也有基本相同功能的API。但除此之外,为了弥补NIO中ByteBuffer的缺点,ByteBuf在ByteBuffer的基础上进行了一些扩展,扩展方式有两种

  • 通过直接继承或者是直接赋值代码的方式,将基本功能的代码移植过来,然后再添加一些新的功能代码。
  • 通过外观模式,也就是通过聚合的方式,对ByteBuffer进行包装,可以减少需要编写的代码量。基本上都是使用这种方式,外观模式的应用在这里有着体现,具体的ByteBuf实现类比如io.netty.buffer.ReadOnlyByteBuf等都是使用聚合的方式进行扩展ByteBuffer。

    2. 为什么ByteBuf不需要进行读写状态切换:

    其实原理很简单,在ByteBuffer中只用了一个position变量来记录标示当前操作的数组下标位置,所以读写需要切换状态;而在ByteBuf中则另外添加两个变量来记录读或写进行操作的当前数组下标,对应变量分别是readerIndex和writerIndex,而且必须满足readerIndex   <=   writerIndex。也就是说,读操作只会改变readerIndex变量,而写操作只会改变writerIndex,同时要满足满足readerIndex   <=   writerIndex,这样就能使得不需要进行读写状态切换。

3. API介绍

    1. 读操作:ByteBuffer中通过相关的get方法来实现数据读取,上面说了,ByteBuf中有ByteBuffer的基本功能,所以也有ByteBuffer的get方法,但是ByteBuf还是扩展了相关的read方法,get方法的功能主要是通过下标index随机读取,而read方法实现的是顺序读取。

    但是,尽管read方法和get方法的功能都是基本相同的,在底层原理上还是不同的,因为ByteBuf的get方法是直接调用ByteBuffer的get方法来实现的,所以其不会改变ByteBuf中的readerIndex变量,只会改变ByteBuffer中的position,必须要注意这一点。不能get方法和read方法混用,会出现问题。部分读操作API如下

    2. 写操作:ByteBuf中的写操作是write方法,与ByteBuffer中的put方法类似,但是ByteBuf中并没有put方法,而是换成了set方法,set方法的功能主要是通过下标index随机写入数据,而write方法实现的是顺序写入数据。同样的,虽然write和set方法都可以想缓冲区插入数据,但是只有write方法可以操作改变writerIndex变量,而set方法则不可以。部分API如下

    3. 可丢弃字节(discardReadBytes()方法),上面提到过,ByteBuf相较于ByteBuffer有一个改进就是可以动态扩容,其底层原理无非就是对于Byte数组进行扩容或缩容,但是这个操作是非常耗时的。为了提高性能,ByteBuf中还通过readerIndex索引变量实现了可丢弃字节的功能,也就是重复利用已读取过的数组空间。原理图如下

字节丢弃后

但是,discardReadBytes方法原理说白了就是内存复制,只不过比单纯的数组扩容的性能要好得多,因为数组扩容需要先进行开辟内存空间,然后再复制原数组中的每一个元素,而discardReadBytes方法只需要复制部分元素即可,但是频繁调用discardReadBytes方法仍然很耗费性能,所以如果不是必须的话,不建议执行。

    4. 可读可写判断(isReadable方法和isWritable方法):通过readerIndex  和  writerIndex,ByteBuf还提供了一个可读可写的判断操作,从Byte数组下标0处到readerIndex 之前都是读取过的数据,而readerIndex到writerIndex之间就是可以进行读取的数据空间,writerIndex到array.length-1之间就是可以写入的数据空间,所以很容易就可以实现一个可读可写的判断条件。

    5. 读写索引管理:读写索引管理就是指调整改变readerIndex  和  writerIndex的值,除了读写操作以及discardReadBytes外,ByteBuf还提供了几个专门管理读写索引的方法

  • markReaderIndex()和resetReaderIndex():标记当前读索引的位置,然后调用resetReaderIndex重置readerIndex 的值到该位置,这两个方法都是配套使用的
  • markWriterIndex()和resetWriterIndex():标记当前写索引的位置,然后调用resetWriterIndex重置writerIndex的值到该位置,这两个方法都是配套使用的
  • clear():清空缓冲区,这个方法虽说是清空缓冲区,但实际上只是重置读写索引的值为0,并不会将数组中的元素值为null,所以该方法性能很好。

    6. 查找操作:有时候需要在缓冲区里查找某个byte数据,比如查找换行符字节等,ByteBuf提供了一系列的方法来实现

  • int indexOf(int fromIndex, int toIndex, byte value):从起始索引fromIndex开始遍历,查询首次出现value的位置索引,终点索引是toIndex,没有找到则返回-1
  • int bytesBefore(byte value):从readerIndex 到writerIndex之间查询首个出现value的索引下标,没有找到则返回-1
  • int bytesBefore(int length, byte value):从readerIndex 到readerIndex +length之间查询首个出现value的索引下标,没有找到则返回-1,但要注意如果readerIndex +length大于writerIndex就会抛出数组下标越界异常。
  • int bytesBefore(int index, int length, byte value):从index到index+length之间查询首个出现value的索引下标,没有找到则返回-1,如果index+length大于缓冲区数组长度,就会抛出数组下标越界异常。
  • int forEachByte(ByteBufProcessor processor):从readerIndex 到writerIndex之间遍历满足ByteBufProcessor 查询条件的索引下标,没有找到则返回-1
  • int forEachByte(int index, int length, ByteBufProcessor processor):从index到index+length之间查询首个满足ByteBufProcessor 查询条件的索引下标,没有找到则返回-1,如果index+length大于缓冲区数组长度,就会抛出数组下标越界异常。
  • int forEachByteDesc(ByteBufProcessor processor):这个就是forEachByte的逆序查找版本
  • int forEachByteDesc(int index, int length, ByteBufProcessor processor):forEachByte的逆序查找版本

    7. 派生缓冲区:有多种方式

  • ByteBuf copy():将当前的ByteBuf 复制一份,也就是说创建一个新的ByteBuf 对象,并且将其中的Byte数组内容一并被复制,而且读写索引变量不变,但是两个ByteBuf 之间互不影响,数据内容和读写索引都是独立的(不共享缓冲区内容)。
  • ByteBuf copy(int index, int length):同上,但是是从指定的index下标位置卡死是复制length个字节,同样复制后的读写索引和内容都是独立的(不共享缓冲区内容)。
  • ByteBuf slice():返回当前ByteBuf 对象的可读子缓冲区,也就是将readerIndex 到writerIndex之间的字节数据组裁剪复制一份出来,并且子缓冲区的读写索引独立维护,但是共享缓冲区的数据内容,也就是说,相当于两个ByteBuf 对象中的Byte数组对象是同一个,但是两个ByteBuf对象维护了各自的读写索引。
  • ByteBuf slice(int index, int length):返回当前ByteBuf 对象的可读子缓冲区,范围从index到index+length,读写索引独立维护,但是共享数据内容。
  • ByteBuf duplicate():完整复制当前的ByteBuf对象,共享整个缓冲区的数据内容,但是各自维护各自的读写索引。

    8. 转换为NIO中的ByteBuffer:

  • ByteBuffer nioBuffer():将当前ByteBuf 对象可读的缓冲区转换为ByteBuffer对象并返回,共享缓冲区内容,但各自维护的读写索引独立,而且ByteBuffer对象无法感知到ByteBuf对象中对于缓冲区的扩容操作。
  • ByteBuffer nioBuffer(int index, int length):将当前ByteBuf 对象从index处到index+length的的缓冲区转换为ByteBuffer对象并返回,共享缓冲区内容,但各自维护的读写索引独立,而且ByteBuffer对象无法感知到ByteBuf对象中对于缓冲区的扩容操作。

4. ByteBuf的具体实现子类

    ByteBuf的子类非常复杂,而且功能种类繁多,只列举几个示例。

    1. 缓冲区内存分配模式分类:在NIO中说过缓冲区的内存分配方式有两种,一种是通过JVM的堆内存进行分配,比如ByteBuffer中的Byte数组,另一种则是直接通过物理内存空间(或者说系统内核空间)分配的,这种方式分配的特点就是其数据读写并不能以JVM中的数组形式进行,同样的Netty中也提供了ByteBuf的两种实现(直接内存和堆内存这里有一个零拷贝的相关知识点,后面详细讲解,其实通过这里基本也能知道什么是零拷贝)

  • 堆内存分配:内存分配在JVM的堆中进行,由于是在JVM的堆中所以其内存分配和回收的速度都比较快,但缺点是相较于直接内存,需要额外的进行一次IO操作将数据在系统内核空间和用户空间之积进行复制移动,所以性能较差。(比如io.netty.buffer.PooledHeapByteBuf),以读操作为例
            ByteBuf heapByteBuf = Unpooled.wrappedBuffer(new byte[1024]);

    if(heapByteBuf.hasArray()) {//判断是否为堆内存分配的缓冲区,是则进行下面的操作

    byte[] array = heapByteBuf.array();//获取当前缓冲区的字节数组引用

    int offset = heapByteBuf.arrayOffset();//获取数组偏移量

    int length = heapByteBuf.readableBytes();//获取数组中的可读缓冲区字节数量

    //do something

    }

     

  • 直接内存分配:直接在系统内核空间进行内存分配,这种方式避免了需要额外的进行一次IO操作将数据在系统内核空间和用户空间之积进行复制移动,所以性能较高,但是内存的分配和回收清理比较慢。(比如io.netty.buffer.PooledDirectByteBuf),以读操作为例
            ByteBuf directByteBuf = Unpooled.directBuffer(1024);

    if(!directByteBuf.hasArray()) {//判断是否为直接内存分配的缓冲区,是则进行下面的操作

    int length = directByteBuf.readableBytes();//获取可读缓冲区的字节数

    byte[] array = new byte[length];

    directByteBuf.getBytes(directByteBuf.readerIndex(), array);//将直接内存缓冲区中的数据读取到array中

    //do something

     

因为这两种方式各有利弊,最佳应用应该是在IO通信线程中使用直接内存分配,而在数据消息的编解码等模块使用堆内存分配。

    2. 内存回收模式分类:

  • 类似于线程池的缓冲区ByteBuf对象池:和线程池的原理类似,作用也是一样的,ByteBuf缓存池中会存放多个ByteBuf对象,这些ByteBuf对象可以重复利用,减少了高高负载情况下的频繁的进行内存分配和回收。
  • 普通ByteBuf:需要进行频繁的内存分配和回收,内存使用效率低。

使用ByteBuf对象池确实要比直接使用ByteBuf对象在性能上要好得多,但ByteBuf对象池的管理和维护非常复杂,代码中使用时必须要更加谨慎。

    3. Netty中的Scatter/Gather机制实现:Netty中提供了一种复合缓冲区来实现NIO中的Scatter/Gather机制,复合缓冲区中存放并管理多个ByteBuf,可以在这个复合缓冲区增删ByteBuf对象。Netty中通过io.netty.buffer.CompositeByteBuf来实现了这个机制,它提供了一个将多个缓冲区合并表示为单个缓冲区的虚拟表示。

CompositeByteBuf中的ByteBuf对象可能同时包含直接内存分配和非直接内存分配两种。 如果CompositeByteBuf只有一个ByteBuf实例,那么对 CompositeByteBuf 上的 hasArray()方法的调用将返回该组件上的hasArray()方法的值;否则它将返回 false。

 CompositeByteBuf 总因为可能有直接内存分配的ByteBuf,所以其可能不支持访问其支撑数组,因此访问 CompositeByteBuf 中的数据类似于(访问)直接缓冲区的模式。

 

 

总结:Bytebuf的具体实现子类非常多,也非常复杂,但核心内容差不多就是以上这些东西,发现其实ByteBuf与ByteBuffer其实差不多,只是对ByteBuffer进行了一些扩充,比ByteBuffer拥有功能更多更复杂的子类,但他们的核心原理都是一致的。

以上是 Netty学习笔记(9)——Netty组件ByteBuf 的全部内容, 来源链接: utcz.com/z/510657.html

回到顶部