这是我参与11月更文挑战的第23天,活动详情查看:2021最后一次更文挑战
创建
ByteBuf 可以通过ByteBufAllocator
选择allocator并调用对应的 buffer( )方法来创建的,默认使用直接内存作为ByteBuf,容量为256个字节,可以指定初始容量的大小。
1 | java复制代码public class ByteBufStudy { |
结果可视化:
- 当ByteBuf的容量无法容纳所有数据时,ByteBuf会进行扩容操作
- 如果在handler中创建ByteBuf,建议使用
ChannelHandlerContext ctx.alloc().buffer()
来创建
直接内存与堆内存
通过下面这种方法创建的ByteBuf,使用的是基于直接内存的ByteBuf
1 | java复制代码ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16); |
此外,还可以使用下面的代码来创建池化基于堆的 ByteBuf
1 | java复制代码ByteBuf buffer = ByteBufAllocator.DEFAULT.heapBuffer(16); |
也可以使用下面的代码来创建池化基于直接内存的 ByteBuf
1 | java复制代码ByteBuf buffer = ByteBufAllocator.DEFAULT.directBuffer(16); |
- 直接内存创建和销毁的代价昂贵,但读写性能高(少一次内存复制),适合配合池化功能一起用
- 直接内存对 GC 压力小,因为这部分内存不受 JVM 垃圾回收的管理,但也要注意及时主动释放
池化与非池化
类似于线程池的思想,预先把昂贵的资源创建好,省去创建的时间和步骤,用完后需要归还。池化的最大意义在于可以重用 ByteBuf,他的优点有:
- 没有池化,则每次都得创建新的 ByteBuf 实例,这个操作对直接内存代价昂贵,就算是堆内存,也会增加 GC 压力
- 有了池化,则可以重用池中 ByteBuf 实例,并且采用了与 jemalloc 类似的内存分配算法提升分配效率
- 高并发时,池化功能更节约内存,减少内存溢出的可能
1 | java复制代码public class ByteBufStudy { |
1 | arduino复制代码// 使用池化的直接内存 |
组成
ByteBuf主要有以下几个组成部分
- 最大容量与当前容量
+ 在构造ByteBuf时,可传入两个参数,分别代表初始容量和最大容量,若未传入第二个参数(最大容量),最大容量默认为Integer.MAX\_VALUE
+ 当ByteBuf容量无法容纳所有数据时,会进行扩容操作,若**超出最大容量**,会抛出`java.lang.IndexOutOfBoundsException`异常
- 对于读写操作与ByteBuffer只用position进行控制不同的是, ByteBuf分别由读指针和写指针两个指针控制
+ 进行读写操作时,无需进行模式的切换
- 读指针前的部分被称为废弃部分,是已经读过的内容
- 读指针与写指针之间的空间称为可读部分
- 写指针与当前容量之间的空间称为可写部分
写入
常用方法如下
方法签名 | 含义 | 备注 |
---|---|---|
writeBoolean(boolean value) | 写入 boolean 值 | **用一字节 01 |
writeByte(int value) | 写入 byte 值 | |
writeShort(int value) | 写入 short 值 | |
writeInt(int value) | 写入 int 值 | Big Endian(大端写入),即 0x250,写入后 00 00 02 50 |
writeIntLE(int value) | 写入 int 值 | Little Endian(小端写入),即 0x250,写入后 50 02 00 00 |
writeLong(long value) | 写入 long 值 | |
writeChar(int value) | 写入 char 值 | |
writeFloat(float value) | 写入 float 值 | |
writeDouble(double value) | 写入 double 值 | |
writeBytes(ByteBuf src) | 写入 netty 的 ByteBuf | |
writeBytes(byte[] src) | 写入 byte[] | |
writeBytes(ByteBuffer src) | 写入 nio 的 ByteBuffer | |
int writeCharSequence(CharSequence sequence, Charset charset) | 写入字符串 | CharSequence为字符串类的父类,第二个参数为对应的字符集 |
注意
- 这些方法的未指明返回值的,其返回值都是 ByteBuf,意味着可以链式调用来写入不同的数据
- 网络传输中,默认习惯是 Big Endian,使用 writeInt(int value)
- CharSequence 是String、StringBuffer、StringBuilder的父类
下面这个例子分别写入了字节类型,int类型以及long类型,值得注意的是,写long类型会进行扩容:
1 | java复制代码public class test { |
扩容
当ByteBuf中的容量无法容纳写入的数据时,会进行扩容操作,借用上面说的例子:
1 | perl复制代码buffer.writeLong(7); |
扩容规则
- 如何写入后数据大小未超过 512 字节,则选择下一个 16 的整数倍进行扩容,例如写入后大小为 12 字节,则扩容后 capacity 是 16 字节
- 如果写入后数据大小超过 512 字节,则选择下一个2n2^n2n ,例如写入后大小为 513 字节,则扩容后 capacity 是 210=1024 字节(292^929=512 已经不够了)
- 扩容不能超过 maxCapacity,否则会抛出
java.lang.IndexOutOfBoundsException
异常
读取
读取主要是通过一系列read方法进行读取,读取时会根据读取数据的字节数移动读指针,此外Netty支持重复读取,如果需要重复读取,需要调用buffer.markReaderIndex()
对读指针进行标记,并通过buffer.resetReaderIndex()
将读指针恢复到mark标记的位置。具体可以看下面的代码:
1 | java复制代码public static void main(String[] args) { |
释放
Netty 采用了引用计数法来控制回收内存,每个 ByteBuf 都实现了 ReferenceCounted 接口
- 每个 ByteBuf 对象的初始计数为 1
- 调用 release 方法计数减 1,如果计数为 0,ByteBuf 内存被回收
- 调用 retain 方法计数加 1,表示调用者没用完之前,其它 handler 即使调用了 release 也不会造成回收
- 当计数为 0 时,底层内存会被回收,这时即使 ByteBuf 对象还在,其各个方法均无法正常使用
释放规则
因为 pipeline 的存在,一般需要将 ByteBuf 传递给下一个 ChannelHandler,如果在每个 ChannelHandler 中都去调用 release ,就失去了传递性(如果在这个 ChannelHandler 内这个 ByteBuf 已完成了它的使命,那么便无须再传递),所以基本规则是,谁是最后使用者,谁负责 release
- 入站 ByteBuf 处理原则
+ 对原始 ByteBuf 不做处理,调用 ctx.fireChannelRead(msg) 向后传递,这时无须 release
+ **将原始 ByteBuf 转换为其它类型的 Java 对象,这时 ByteBuf 就没用了,必须 release**
+ **如果不调用 ctx.fireChannelRead(msg) 向后传递,那么也必须 release**
+ **注意各种异常,如果 ByteBuf 没有成功传递到下一个 ChannelHandler,必须 release**
+ 假设消息**一直向后传**,那么 TailContext 会负责释放未处理消息(原始的 ByteBuf)
- 出站 ByteBuf 处理原则
+ **出站消息最终都会转为 ByteBuf 输出,一直向前传,由 HeadContext flush 后 release**
- 异常处理原则
+ 有时候不清楚 ByteBuf 被引用了多少次,但又必须彻底释放,可以**循环调用 release 直到返回 true :**
1
java复制代码while (!buffer.release()) {}
当ByteBuf被传到了pipeline的head与tail时,ByteBuf会被其中的方法彻底释放。
切片Slice
之前在NIO网络编程(十)—— 零拷贝技术 - 掘金 (juejin.cn)中介绍了一下NIO中的零拷贝问题,这里介绍一下Netty中使用Slice方法实现零拷贝的。
ByteBuf切片是零拷贝的体现之一,对原始 ByteBuf 进行切片成多个 ByteBuf,切片后的 ByteBuf 并没有发生内存复制,还是使用原始 ByteBuf 的内存,切片后的 ByteBuf 维护独立的 read,write 指针。
- 得到分片后的buffer后,要调用其retain方法,使其内部的引用计数加一。避免原ByteBuf释放,导致切片buffer无法使用
- 修改原ByteBuf中的值,也会影响切片后得到的ByteBuf
- 切片不能扩容,即不能向切片写入内容
- 一般使用slice,就需要retain,使用完后使用 release
1 | java复制代码public static void main(String[] args) { |
上方的代码的执行结果:
本文转载自: 掘金