Netty学习之核心组件ByteBuf及API

Netty提供的ByteBuf不同于JDK中NIO的ByteBuffer,ByteBuf是netty中数据传输的容器,是Netty自己实现的,作为NIO ByteBuffer的替代品,提供了更好的API供开发者使用。相较于NIO的ByteBuffer更具有卓越的功能性和灵活性。具体NIO的ByteBuffer如何实现请参考IO模型之NIO代码及其实践详解

一、ByteBuf的API特点

  ByteBuf提供读访问索引(readerIndex)和写访问索引(writerIndex)来控制字节数组。ByteBuf API具有以下优点:

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

二、ByteBuf类原理及使用

  1、ByteBuf工作原理

  ByteBuf维护两个不同的索引: 读索引(readerIndex)和写索引(writerIndex)。如下图: 

        Netty学习之核心组件ByteBuf及API 

  • ByteBuf维护了readerIndex和writerIndex索引。
  • 当readerIndex > writerIndex时,则抛出IndexOutOfBoundsException。
  • ByteBuf容量 = writerIndex。
  • ByteBuf可读容量 = writerIndex - readerIndex。
  • readXXX()和writeXXX()方法将会推进其对应的索引,自动推进。
  • getXXX()和setXXX()方法对writerIndex和readerIndex无影响,不会改变index值。

  readerIndex和WriterIndex将整个ByteBuf分成了三个区域:可丢弃字节、可读字节、可写字节,如下图:

  当尚未读取时,拥有可读字节区域以及可写字节区域。

        Netty学习之核心组件ByteBuf及API

  当已经读过部分区域后,变成了可丢弃字节、可读字节、可写字节三个区域。

            Netty学习之核心组件ByteBuf及API 

  2、ByteBuf的使用模式

  ByteBuf本质是: 一个由不同的索引分别控制读访问和写访问的字节数组。ByteBuf共有三种模式: 堆缓冲区模式(Heap Buffer)、直接缓冲区模式(Direct Buffer)和复合缓冲区模式(Composite Buffer),相较于NIO的ByteBuffer多了一种复合缓冲区模式。

  2.1、堆缓冲区模式(Heap Buffer)

  堆缓冲区模式又称为:支撑数组(backing array)。将数据存放在JVM的堆空间,通过将数据存储在数组中实现。

  • 堆缓冲的优点: 由于数据存储在Jvm堆中可以快速创建和快速释放,并且提供了数组直接快速访问的方法。
  • 堆缓冲的缺点: 每次数据与I/O进行传输时,都需要将数据拷贝到直接缓冲区。

  代码如下:

public static void heapBuffer() {
    // 创建Java堆缓冲区
    ByteBuf heapBuf = Unpooled.buffer(); 
    if (heapBuf.hasArray()) { // 是数组支撑
        byte[] array = heapBuf.array();
        int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
        int length = heapBuf.readableBytes();
        handleArray(array, offset, length);
    }
}

  2.2、直接缓冲区模式(Direct Buffer)

  Direct Buffer属于堆外分配的直接内存,不会占用堆的容量。适用于套接字传输过程,避免了数据从内部缓冲区拷贝到直接缓冲区的过程,性能较好。

  • Direct Buffer的优点: 使用Socket传递数据时性能很好,避免了数据从Jvm堆内存拷贝到直接缓冲区的过程,提高了性能。
  • Direct Buffer的缺点: 相对于堆缓冲区而言,Direct Buffer分配内存空间和释放更为昂贵。

  对于涉及大量I/O的数据读写,建议使用Direct Buffer。而对于用于后端的业务消息编解码模块建议使用Heap Buffer。

  代码如下:

public static void directBuffer() {
    ByteBuf directBuf = Unpooled.directBuffer();
    if (!directBuf.hasArray()) {
        int length = directBuf.readableBytes();
        byte[] array = new byte[length];
        directBuf.getBytes(directBuf.readerIndex(), array);
        handleArray(array, 0, length);
    }
}

  2.3、复合缓冲区模式(Composite Buffer)

  Composite Buffer是Netty特有的缓冲区。本质上类似于提供一个或多个ByteBuf的组合视图,可以根据需要添加和删除不同类型的ByteBuf。

  • 想要理解Composite Buffer,请记住:它是一个组合视图。它提供一种访问方式让使用者*的组合多个ByteBuf,避免了拷贝和分配新的缓冲区。
  • Composite Buffer不支持访问其支撑数组。因此如果要访问,需要先将内容拷贝到堆内存中,再进行访问
  • 下图是将两个ByteBuf:头部+Body组合在一起,没有进行任何复制过程。仅仅创建了一个视图

                      Netty学习之核心组件ByteBuf及API

  代码如下:

public static void byteBufComposite() {
    // 复合缓冲区,只是提供一个视图
    CompositeByteBuf messageBuf = Unpooled.compositeBuffer();
    ByteBuf headerBuf = Unpooled.buffer(); // can be backing or direct
    ByteBuf bodyBuf = Unpooled.directBuffer();   // can be backing or direct
    messageBuf.addComponents(headerBuf, bodyBuf);
    messageBuf.removeComponent(0); // remove the header
    for (ByteBuf buf : messageBuf) {
        System.out.println(buf.toString());
    }
}

  三种ByteBuf使用区别对比:

           Netty学习之核心组件ByteBuf及API

三、ButeBuf的池化与非池化

  内存的申请和销毁都有一定性能开销,内存池化技术可以有效的减少相关开销。Netty在4引入了该技术。Netty的池化分为对象池和内存池,对应的ByteBuf的堆缓冲区和直接缓冲区。

  是否使用池化取决于ByteBufAllocator使用的实例对象(参考分配方式ByteBufAllocator相关说明,本文后部分有说明)

  PooledByteBufAllocator可以通过ctx.alloc获得,如下图:

      Netty学习之核心组件ByteBuf及API

  Netty默认使用池化byteBuf,如果想要声明不池化的可以使用Unpooled工具类。

四、字节级操作

  4.1、随机访问索引

  ByteBuf的索引与普通的Java字节数组一样。第一个字节的索引是0,最后一个字节索引总是capacity()-1。ByteBuf的API分为4大类:Get*、Set*、Read*、Write*。使用有以下两条规则:

  • readXXX()和writeXXX()方法将会推进其对应的索引readerIndex和writerIndex。自动推进
  • getXXX()和setXXX()方法用于访问数据,对writerIndex和readerIndex无影响

  代码如下:

public static void byteBufRelativeAccess() {
    ByteBuf buffer = Unpooled.buffer(); //get reference form somewhere
    for (int i = 0; i < buffer.capacity(); i++) {
        byte b = buffer.getByte(i);// 不改变readerIndex值
        System.out.println((char) b);
    }
}

  4.2、顺序访问索引

  Netty的ByteBuf同时具有读索引和写索引,但JDK的ByteBuffer只有一个索引,所以JDK需要调用flip()方法在读模式和写模式之间切换(NIO方式)。ByteBuf被读索引和写索引划分成3个区域:可丢弃字节区域,可读字节区域和可写字节区域 ,如下图:

        Netty学习之核心组件ByteBuf及API

  4.3、可丢弃字节区域

  可丢弃字节区域是指:[0,readerIndex]之间的区域。可调用discardReadBytes()方法丢弃已经读过的字节。

  •   discardReadBytes()效果: 将可读字节区域(CONTENT)[readerIndex, writerIndex)往前移动readerIndex位,同时修改读索引和写索引。
  •   discardReadBytes()方法会移动可读字节区域内容(CONTENT)。如果频繁调用,会有多次数据复制开销,对性能有一定的影响。

  4.4、可读字节区域

  可读字节区域是指:[readerIndex, writerIndex]之间的区域。任何名称以read和skip开头的操作方法,都会改变readerIndex索引。

  4.5、可写字节区域

  可写字节区域是指:[writerIndex, capacity]之间的区域。任何名称以write开头的操作方法都将改变writerIndex的值。

  4.6、索引管理

  • markReaderIndex()+resetReaderIndex() ----- markReaderIndex()是先备份当前的readerIndex,resetReaderIndex()则是将刚刚备份的readerIndex恢复回来。常用于dump ByteBuf的内容,又不想影响原来ByteBuf的readerIndex的值
  • readerIndex(int) ----- 设置readerIndex为固定的值
  • writerIndex(int) ----- 设置writerIndex为固定的值
  • clear() ----- 效果是: readerIndex=0, writerIndex(0)。不会清除内存
  • 调用clear()比调用discardReadBytes()轻量的多。仅仅重置readerIndex和writerIndex的值,不会拷贝任何内存,开销较小。

  4.7、查找操作(indexOf)

  查找ByteBuf指定的值。类似于,String.indexOf("str")操作

  • 最简单的方法 ----- indexOf()
  • 利用ByteProcessor作为参数来查找某个指定的值。

  代码如下:

public static void byteProcessor() {
    ByteBuf buffer = Unpooled.buffer(); //get reference form somewhere
    // 使用indexOf()方法来查找
    buffer.indexOf(buffer.readerIndex(), buffer.writerIndex(), (byte)8);
    // 使用ByteProcessor查找给定的值
    int index = buffer.forEachByte(ByteProcessor.FIND_CR);
}

  4.8、其余访问操作

  除去get、set、read、write类基本操作,还有一些其余的有用操作,如下图:

        Netty学习之核心组件ByteBuf及API

  下面的两个方法操作字面意思较难理解,给出解释:

  • hasArray() :如果ByteBuf由一个字节数组支撑,则返回true。通俗的讲:ByteBuf是堆缓冲区模式,则代表其内部存储是由字节数组支撑的。
  • array() :如果ByteBuf是由一个字节数组支撑则返回数组,否则抛出UnsupportedOperationException异常。也就是,ByteBuf是堆缓冲区模式。

五、ByteBufHolder的使用

  我们时不时的会遇到这样的情况:即需要另外存储除有效的实际数据各种属性值。HTTP响应就是一个很好的例子;与内容一起的字节的还有状态码,cookies等。

  Netty 提供的 ByteBufHolder 可以对这种常见情况进行处理。 ByteBufHolder 还提供了对于 Netty 的高级功能,如缓冲池,其中保存实际数据的 ByteBuf 可以从池中借用,如果需要还可以自动释放。

  ByteBufHolder 有那么几个方法。到底层的这些支持接入数据和引用计数。如下图所示:

        Netty学习之核心组件ByteBuf及API

  ByteBufHolder是ByteBuf的容器,可以通过子类实现ByteBufHolder接口,根据自身需要添加自己需要的数据字段。可以用于自定义缓冲区类型扩展字段。Netty提供了一个默认的实现DefaultByteBufHolder:

public class CustomByteBufHolder extends DefaultByteBufHolder{
 
    private String protocolName;
 
    public CustomByteBufHolder(String protocolName, ByteBuf data) {
        super(data);
        this.protocolName = protocolName;
    }
 
    @Override
    public CustomByteBufHolder replace(ByteBuf data) {
        return new CustomByteBufHolder(protocolName, data);
    }
 
    @Override
    public CustomByteBufHolder retain() {
        super.retain();
        return this;
    }
 
    @Override
    public CustomByteBufHolder touch() {
        super.touch();
        return this;
    }
 
    @Override
    public CustomByteBufHolder touch(Object hint) {
        super.touch(hint);
        return this;
    }
    ...
}

六、ByteBuf分配

  创建和管理ByteBuf实例的多种方式:按需分配(ByteBufAllocator)、Unpooled缓冲区和ByteBufUtil类。

  1、按序分配: ByteBufAllocator接口

  Netty通过接口ByteBufAllocator实现了(ByteBuf的)池化。Netty提供池化和非池化的ButeBufAllocator,是否使用池是由应用程序决定的: 

  • ctx.channel().alloc().buffer() ----- 本质就是: ByteBufAllocator.DEFAULT
  • ByteBufAllocator.DEFAULT.buffer() ----- 返回一个基于堆或者直接内存存储的Bytebuf。默认是堆内存
  • ByteBufAllocator.DEFAULT ----- 有两种类型: UnpooledByteBufAllocator.DEFAULT(非池化)和PooledByteBufAllocator.DEFAULT(池化)。对于Java程序,默认使用PooledByteBufAllocator(池化)。对于安卓,默认使用UnpooledByteBufAllocator(非池化)
  • 可以通过BootStrap中的Config为每个Channel提供独立的ByteBufAllocator实例

  ByteBufAllocator提供的操作如下图:

        Netty学习之核心组件ByteBuf及API

  注意:

  • 上图中的buffer()方法,返回一个基于堆或者直接内存存储的Bytebuf ----- 缺省是堆内存。源码: AbstractByteBufAllocator() { this(false); }
  • ByteBufAllocator.DEFAULT ----- 可能是池化,也可能是非池化。默认是池化(PooledByteBufAllocator.DEFAULT)
  • 通过一些方法接受整型参数允许用户指定 ByteBuf 的初始和最大容量值。

  得到一个 ByteBufAllocator 的引用很简单。你可以得到从 Channel (在理论上,每 Channel 可具有不同的 ByteBufAllocator ),或通过绑定到的 ChannelHandler 的 ChannelHandlerContext 得到它,如代码:

Channel channel = ...;
ByteBufAllocator allocator = channel.alloc(); //1、Channel

ChannelHandlerContext ctx = ...;
ByteBufAllocator allocator2 = ctx.alloc(); //2、 ChannelHandlerContext

  第一种是从 channel 获得 ByteBufAllocator,第二种是从 ChannelHandlerContext 获得 ByteBufAllocator。

  Netty 提供了两种 ByteBufAllocator 的实现,一种是 PooledByteBufAllocator,用ByteBuf 实例池改进性能以及内存使用降到最低,此实现使用一个“jemalloc”内存分配。其他的实现不池化 ByteBuf 情况下,每次返回一个新的实例。Netty 默认使用 PooledByteBufAllocator,我们可以通过 ChannelConfig 或通过引导设置一个不同的实现来改变。

  2、Unpooled缓冲区:非池化

  Unpooled提供静态的辅助方法来创建未池化的ByteBuf。其包含方法如下:

        Netty学习之核心组件ByteBuf及API

  注意:

  • 上图的buffer()方法,返回一个未池化的基于堆内存存储的ByteBuf
  • wrappedBuffer() :创建一个视图,返回一个包装了给定数据的ByteBuf。非常实用

  创建ByteBuf代码:

 public void createByteBuf(ChannelHandlerContext ctx) {
    // 1. 通过Channel创建ByteBuf,实际上也是使用ByteBufAllocator,因为ctx.channel().alloc()返回的就是一个ByteBufAllocator对象
    ByteBuf buf1 = ctx.channel().alloc().buffer();
    // 2. 通过ByteBufAllocator.DEFAULT创建
    ByteBuf buf2 =  ByteBufAllocator.DEFAULT.buffer();
    // 3. 通过Unpooled创建
    ByteBuf buf3 = Unpooled.buffer();
}

  3、ByteBufUtil类

  ByteBufUtil类提供了用于操作ByteBuf的静态的辅助方法: hexdump()和equals

  • hexdump() :以十六进制的表示形式打印ByteBuf的内容,可以用于调试程序时打印 ByteBuf 的内容。非十六进制字符串相比字节而言对用户更友好。 而且十六进制版本可以很容易地转换回实际字节表示。
  • boolean equals(ByteBuf, ByteBuf) :判断两个ByteBuf实例的相等性,在 实现自己 ByteBuf 的子类时经常用到

七、派生缓冲区

  “派生的缓冲区”是代表一个专门的展示 ByteBuf 内容的“视图”。这种视图是由duplicate() 、slice()、slice(int, int)、Unpooled.unmodifiableBuffer(...)、Unpooled.wrappedBuffer(...)、 order(ByteOrder)、readSlice(int) 方法创建的。所有这些都返回一个新的 ByteBuf 实例包括它自己的 reader, writer 和标记索引。然而,内部数据存储共享就像在一个 NIO 的 ByteBuffer。这使得派生的缓冲区创建、修改其内容,以及修改其“源”实例更廉价。

  注意:

  • 上面的7中方法,都会返回一个新的ByteBuf实例,具有自己的读索引和写索引。但是,其内部存储是与原对象是共享的。这就是视图的概念
  • 请注意:如果你修改了这个新的ByteBuf实例的具体内容,那么对应的源实例也会被修改,因为其内部存储是共享的。
  • 如果需要拷贝现有缓冲区的真实副本,请使用copy()或copy(int, int)方法。
  • 使用派生缓冲区,避免了复制内存的开销,有效提高程序的性能

  针对派生和复制区别,如下面代码所展示:

Charset utf8 = Charset.forName("UTF-8");
ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8); //1

ByteBuf sliced = buf.slice(0, 14);          //2
System.out.println(sliced.toString(utf8));  //3

buf.setByte(0, (byte) 'J');                 //4
assert buf.getByte(0) == sliced.getByte(0);
  1. 创建一个 ByteBuf 保存特定字节串。
  2. 创建从索引 0 开始,并在 14 结束的 ByteBuf 的新 slice。
  3. 打印 Netty in Action
  4. 更新索引 0 的字节。
  5. 断言成功,因为数据是共享的,并以一个地方所做的修改将在其他地方可见。
Charset utf8 = Charset.forName("UTF-8");
ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8);     //1

ByteBuf copy = buf.copy(0, 14);               //2
System.out.println(copy.toString(utf8));      //3

buf.setByte(0, (byte) 'J');                   //4
assert buf.getByte(0) != copy.getByte(0);
  1. 创建一个 ByteBuf 保存特定字节串。
  2. 创建从索引0开始和 14 结束 的 ByteBuf 的段的拷贝。
  3. 打印 Netty in Action
  4. 更新索引 0 的字节。
  5. .断言成功,因为数据不是共享的,并以一个地方所做的修改将不影响其他。

  因此使用派生缓冲区可以尽可能避免复制内存,要想数据独立,请使用copy。

八、引用计数

  引用计数是一种通过在某个对象所持有的资源不再被其他对象引用时释放该对象所持有的资源来优化内存使用和性能的技术。Netty 在第4 版中为ByteBuf引入了引用计数技术,ByteBuf初始化引用数量为1,通过release 可以-1,为0时对象被回收。

        Netty学习之核心组件ByteBuf及API

  堆中对象即使不回收在gc执行时也会被回收,但是直接内存的对象如果不释放,可能会引起内存的溢出。

  如果试图访问已经回收的对象会抛出

      Netty学习之核心组件ByteBuf及API

  资源释放的问题通常来讲是由最后一个访问该对象的事件处理器负责。执行writeAndFlush会自动释放资源,同时如下图中的SimpleChannelInboundHandler
的实现也是为了给我们实现了对象回收。省去一些通用代码。

      Netty学习之核心组件ByteBuf及API

              Netty学习之核心组件ByteBuf及API

上一篇:java NIO理论总结


下一篇:Netty ByteBuf 传输载体