likes
comments
collection
share

ByteBuf:Netty的数据容器

作者站长头像
站长
· 阅读数 21

网络传输的基本单位总是字节,JDK使用ByteBuffer作为Nio网络编程的数据容器,但是这个类使用过于复杂,存在一些缺点,例如:它不支持扩容、读写模式切换需要经常调用flip(),导致开发者经常因为忘记调用而导致无法读取写入的数据。 ​

Netty使用ByteBuf来代替JDK的ByteBuffer,它有以下优点:

  1. 支持扩容。
  2. 分别维护读写索引,无需调用flip()
  3. 支持链式调用。
  4. 支持引用技术、池化,对象和内存可以复用。
  5. CompositeByteBuf实现了透明的零拷贝。

1. 工作模式

ByteBuf分别维护读写索引readerIndex和writerIndex,写数据时writerIndex不断递增,读数据时readerIndex不断递增,readerIndex达到writerIndex代表无数据可读,writerIndex达到capacity代表不可写。 ​

通过这种方式,ByteBuf将缓冲区的数据分成了三段,分别是:

数据段范围
可丢弃字节0~readerIndex
可读字节readerIndex~writerIndex
可写字节writerIndex~capacity

如何回收这部分「可丢弃字节」呢?ByteBuf提供了discardReadBytes()方法,它会移动readerIndex和writerIndex,同时将「可读字节」的数据向前复制。由于会导致内存复制,因此不建议频繁调用此方法。 ​

ByteBuf还提供了clear()方法用来清空缓冲区,它仅仅重置索引,不会有任何的内存复制,因此它速度极快。 ByteBuf:Netty的数据容器

1.1 顺序读写和随机读写

ByteBuf支持顺序读写和随机读写,顺序读写会移动读写索引,随机读写不会。 ​

顺序读写的方法名以read和write开头,随机读写的方法名以get和set开头。 ​

除了可以往ByteBuf写入基本的字节数组外,还可以写入Java八大基本数据类型,Netty自己会完成字节的转换。 例如,往HeapByteBuf写入一个int,源码如下:

static void setInt(byte[] memory, int index, int value) {
    memory[index]     = (byte) (value >>> 24);
    memory[index + 1] = (byte) (value >>> 16);
    memory[index + 2] = (byte) (value >>> 8);
    memory[index + 3] = (byte) value;
}

往ByteBuf写数据的API:

方法说明
writeBytes()写入字节数组
writeByte()写入一个字节
writeShort()写入一个short,2字节
writeInt()写入一个int,4字节
writeLong()写入一个long,8字节
writeFloat()写入一个float,4字节
writeDouble()写入一个double,8字节
writeChar()写入一个char,2字节,高位被忽略
writeBoolean()写入一个boolean,1字节

从ByteBuf中读数据API同上,把write改为read即可。 ​

以上两种是顺序读写,会移动读写索引,写入时如果空间不够,ByteBuf还会自动扩容,下面再说说随机读写。 ​

ByteBuf底层还是数组,一块连续的内存空间,可以根据索引快速定位,支持快速随机读写。随机读写方法以get和set开头,不会移动读写索引,如果index越界不会扩容,只会抛异常。 ​

如下是从HeapByteBuf中随机读取一个int值的源码:

@Override
public int getInt(int index) {
    // 检查index是否合理,有没有超出容量
    checkIndex(index, 4);
    return _getInt(index);
}

@Override
protected int _getInt(int index) {
    return HeapByteBufUtil.getInt(array, index);
}

static int getInt(byte[] memory, int index) {
    return  (memory[index]     & 0xff) << 24 |
        (memory[index + 1] & 0xff) << 16 |
        (memory[index + 2] & 0xff) <<  8 |
        memory[index + 3] & 0xff;
}

2. 缓冲区模式

ByteBuf支持两种内存模式:堆内存、直接内存,这点和ByteBuffer是一样的。

2.1 堆缓冲区

基于堆缓冲区的ByteBuf将数据存储在JVM的堆空间,内部有一个支撑数组byte[],如下是一个堆缓冲区的分配示例:

ByteBuf buf = PooledByteBufAllocator.DEFAULT.heapBuffer(1024);

堆缓冲区的特点是:

  1. 有支撑数组byte[]。
  2. 申请/释放 效率高。
  3. Socket读写需要内存复制。
  4. 适合JVM进程内读写。

堆缓冲区可以直接获取ByteBuf内部的支撑数组,hasArray()返回true。

ByteBuf buf = PooledByteBufAllocator.DEFAULT.heapBuffer(1024);
byte[] bytes = buf.array();
boolean hasArray = buf.hasArray();// true

2.2 直接缓冲区

基于直接缓冲区的ByteBuf将数据存储在堆外,ByteBuffer通过本地调用来向OS申请堆外内存,这带来的好处就是进行IO读写时可以避免一次内存复制。如下是直接缓冲区的分配示例:

ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(1024);

由于直接缓冲区需要向OS申请内存,所以它的创建和释放的开销很大,不过没关系,Netty实现了ByteBuf的池化,后面会说。由于它的数据是存储在堆外的,因此JVM不能直接获取字节数组,需要手动去读取,这样又会多一次内存复制,因此不建议JVM进程频繁读写直接缓冲区。 ​

直接缓冲区的特点:

  1. 无支撑数组。
  2. 数据存储在堆外。
  3. 申请/释放效率低,需要同步向OS申请内存。
  4. JVM进程内读写需要内存复制。
  5. Socket读写无需内存复制。

2.3 复合缓冲区

Netty还提供了另外一种JDK的ByteBuffer不支持的缓冲区:CompositeByteBuf复合缓冲区。 ​

CompositeByteBuf可以组合多个ByteBuf并提供一个统一的聚合视图,这带来的好处就是你无需将多个小的ByteBuf拷贝到一个大的ByteBuf,CompositeByteBuf会自动组合,内部实现了透明的零拷贝。 ​

如下是CompositeByteBuf的简单使用示例:

public static void main(String[] args) {
    CompositeByteBuf composite = PooledByteBufAllocator.DEFAULT.compositeBuffer();
    composite.addComponents(true, Unpooled.wrappedBuffer("hello".getBytes()));
    composite.addComponent(true, Unpooled.wrappedBuffer(" world".getBytes()));

    byte[] bytes = new byte[composite.readableBytes()];
    composite.readBytes(bytes);
    System.out.println(new String(bytes));// hello world
}

3. 池化技术

ByteBuf:Netty的数据容器 通过ByteBuf的类图可以发现,它实现了ReferenceCounted接口。Netty基于引用计数算法自己管理资源,每个ByteBuf会有一个refCnt属性来计数,调用retain()计数会递增,调用release()计数会递减,递减至0时,Netty会自动释放资源。 ​

池化技术不仅可以管理直接缓冲区,也可以管理堆缓冲区。Netty默认使用池化的ByteBuf分配器PooledByteBufAllocator,Netty基于JeMalloc思想自己管理资源,对于直接内存,它预先申请一大块内存,然后进程内按需分配。 ​

未池化的直接缓冲区,申请和释放的开销非常大,它需要发起一次系统调用,向OS申请/释放内存,因此尽量避免使用未池化的直接缓冲区,笔者做过测试,它的分配比池化的ByteBuf慢10倍都不止。 ​

3.1 未池化

Netty提供了一个工具类Unpooled来分配未池化的ByteBuf,如下:

Unpooled.buffer(1024);
Unpooled.directBuffer(1024);

Unpooled底层还是利用UnpooledByteBufAllocator分配的:

UnpooledByteBufAllocator.DEFAULT.heapBuffer(1024);
UnpooledByteBufAllocator.DEFAULT.directBuffer(1024);

UnpooledByteBufAllocator每次分配ByteBuf都会创建新的ByteBuf实例,内存的申请和释放也全交给JVM。这样的好处是实现简单,但是会给GC带来较大的压力。 ​

3.2 池化

PooledByteBufAllocator是Netty内置的使用池化技术的ByteBuf分配器,如下示例:

PooledByteBufAllocator.DEFAULT.heapBuffer(1024);
PooledByteBufAllocator.DEFAULT.directBuffer(1024);

池化可以从两个角度去看,一个是内存、一个是ByteBuf对象。 ​

对于内存的池化,Netty基于JeMalloc思想管理内存,预先申请一大块内存,然后按需分配。 对于ByteBuf对象本身的池化,Netty通过Recycler来回收ByteBuf对象,只要Stack中有对象可用,就不会创建新的对象,这大大减轻了GC的压力。 ​

PooledByteBufAllocator对内存和ByteBuf对象本身都做了池化处理,因此它的效率是最高的,也是Netty默认的分配器。 ​

自己管理内存带来的好处是:减轻GC的压力,不用频繁申请/释放,内存可以被重用,可以带来更好的性能。缺点是需要开发者主动释放内存,这一点对于Java开发者来说可能不太适应。 ​

关于Netty是如何管理内存的,东西比较多,笔者会单独开一篇文章写。

4. 总结

ByteBuf是Netty的数据容器,它的目的是替代JDK的ByteBuffer,使开发者可以使用性能更好、更方便、更灵活的数据缓冲区。 ​

它最大的特点就是读写索引分别维护了,使用起来更加方便,同时Netty还提供了复合缓冲区CompositeByteBuf,它可以组合多个ByteBuf,内部实现了透明的零拷贝。 ​

为了避免ByteBuf和内存的频繁申请和释放,Netty使用池化技术来复用内存和ByteBuf对象,优点是可以带来更好的性能,缺点是需要开发者手动释放资源。