ByteBuf:Netty的数据容器

网络传输的基本单位总是字节,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 对象,优点是可以带来更好的性能,缺点是需要开发者手动释放资源。

原文始发于微信公众号(程序员小潘):ByteBuf:Netty的数据容器

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/29441.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!