网络传输的基本单位总是字节,JDK 使用 ByteBuffer 作为 Nio 网络编程的数据容器,但是这个类使用过于复杂,存在一些缺点,例如:它不支持扩容、读写模式切换需要经常调用flip()
,导致开发者经常因为忘记调用而导致无法读取写入的数据。
Netty 使用 ByteBuf 来代替 JDK 的 ByteBuffer,它有以下优点:
-
支持扩容。 -
分别维护读写索引,无需调用 flip()
。 -
支持链式调用。 -
支持引用技术、池化,对象和内存可以复用。 -
CompositeByteBuf 实现了透明的零拷贝。
1. 工作模式
ByteBuf 分别维护读写索引 readerIndex 和 writerIndex,写数据时 writerIndex 不断递增,读数据时 readerIndex 不断递增,readerIndex 达到 writerIndex 代表无数据可读,writerIndex 达到 capacity 代表不可写。
通过这种方式,ByteBuf 将缓冲区的数据分成了三段,分别是:
数据段 | 范围 |
---|---|
可丢弃字节 | 0~readerIndex |
可读字节 | readerIndex~writerIndex |
可写字节 | writerIndex~capacity |
如何回收这部分「可丢弃字节」呢?ByteBuf 提供了discardReadBytes()
方法,它会移动 readerIndex 和 writerIndex,同时将「可读字节」的数据向前复制。由于会导致内存复制,因此不建议频繁调用此方法。
ByteBuf 还提供了clear()
方法用来清空缓冲区,它仅仅重置索引,不会有任何的内存复制,因此它速度极快。
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);
堆缓冲区的特点是:
-
有支撑数组 byte[]。 -
申请/释放 效率高。 -
Socket 读写需要内存复制。 -
适合 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 进程频繁读写直接缓冲区。
直接缓冲区的特点:
-
无支撑数组。 -
数据存储在堆外。 -
申请/释放效率低,需要同步向 OS 申请内存。 -
JVM 进程内读写需要内存复制。 -
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 的类图可以发现,它实现了
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