ByteBuf:Netty的数据容器
网络传输的基本单位总是字节,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对象,优点是可以带来更好的性能,缺点是需要开发者手动释放资源。
转载自:https://juejin.cn/post/7288526152216412160