ByteBuf
当我们使用NIO进行数据传输的时候,往往需要使用到缓冲区Buffer,常用的缓冲区就是JDK中的NIO类库提供的java.nio.Buffer。
实际上,7种基本类型(Boolean除外)都有自己的缓冲区实现。对于NIO编程而言,我们主要使用的是ByteBuffer。从功能角度而言,ByteBuffer完全可以满足NIO编程的需要,但是由于NIO编程的复杂性,ByteBuffer也有其局限性,它的主要缺点如下:
- ByteBuffer长度固定,一旦分配完成,它的容量不能动态扩展和收缩(BufferBuffer底层采用数组实现,数组被声明为final类型的属性,无法被修改,也就是长度一旦分配就无法更改),当需要编码的POJO对象大于ByteBuffer的容量时,会发生索引越界异常,如果要预防这个异常,就需要在存储数据之前确定好容量是否足够,如果容量不足,需要手动创建一个新的ByteBuffer,将旧的ByteBuffer数据拷贝过去,然后再存储新的数据,这一切都需要开发者手动完成。
- ByteBuffer只有一个标识位置的指针position,读写的时候需要手工调用flip()和rewind()等来切换切斜模式,使用者必须小心谨慎地处理这些API,否则很容易导致程序处理失败。
为了弥补这些不足,Netty提供了自己的ByteBuffer实现类ByteBuf。
ByteBuf的简介
ByteBuf依然是个byte数组的缓冲区,它的基本功能应该与JDK的ByteBuffer一致。
由于JDK的ByteBuffer已经提供了这些基础能力的实现,因此Netty ByteBuf的实现可以有两种策略:
- 参考JDK ByteBuffer的实现,增加额外的功能,解决原ByteBuffer的缺点;
- 聚合JDK ByteBuffer,通过门面模式对其进行包装,可以减少自身的代码量,降低实现成本。
Netty里两种方式都用了。
ByteBuf的使用
创建ByteBuf
ByteBuf byteBuf = Unpooled.buffer();
随机访问
可以像一个普通的原生byte数组一样通过索引随机访问ByteBuf。
for (int i = 0; i < byteBuf.readableBytes(); i++) {
byte b = byteBuf.getByte(i);
System.out.println(b);
}
顺序访问
ByteBuf提供了两个指针变量来支持顺序的读和写操作,使用readerIndex代表读操作,使用writerIndex代表写操作,下面的图展示了buffer被这两个指针分割为了三块区域:
+-------------------+------------------+------------------+
| discardable bytes | readable bytes | writable bytes |
| | (CONTENT) | |
+-------------------+------------------+------------------+
| | | |
0 <= readerIndex <= writerIndex <= capacity
可读的字节
[readerIndex, writerIndex)
这块区域是数据实际存储的区域,任何以read或skip开头的方法都会从readerIndex索引开始获得或者跳过数据,然后readerIndex会增加读取的字节个数。
while (byteBuf.isReadable()) {
byte b = byteBuf.readByte();
System.out.println(b);
}
可写的字节
[writerIndex, capacity)
这块区域是未存放数据的区域,可以用来填充数据。任何以write开头的方法都会从当前writerIndex索引开始写入数据,然后writerIndex会增加写入的字节数。
while (buffer.maxWritableBytes() >= 4) {
buffer.writeInt(random.nextInt());
}
使用Unpooled.buffer()
创建的Buffer的实例具体类型为UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf
,可以通过方法参数initialCapacity指定其初始的capacity的大小,当写入的数据超过initialCapacity时会自动进行扩容,maxWritableBytes()和maxCapacity()都为Integer.MAX_VALUE。
可丢弃的字节
[0, readerIndex)这块区域的数据已经读过了,初始时,这块区域的大小为0,随着读操作的进行,这块区域的大小最大可以增大到writerIndex。
可以调用discardReadBytes()来回收这块区域,下面的图展示了调用discardReadBytes()方法Buffer前后的变化:
BEFORE discardReadBytes()
+-------------------+------------------+------------------+
| discardable bytes | readable bytes | writable bytes |
+-------------------+------------------+------------------+
| | | |
0 <= readerIndex <= writerIndex <= capacity
AFTER discardReadBytes()
+------------------+--------------------------------------+
| readable bytes | writable bytes (got more space) |
+------------------+--------------------------------------+
| | |
readerIndex (0) <= writerIndex (decreased) <= capacity
discardReadBytes()方法不一定会往前移动字节数组,取决于ByteBuffer底层的实现类。
清空buffer
可以使用clear()方法来使得readerIndex=writerIndex=0
,这个方法并不会清除buffer的内容,仅仅只是调整了两个指针的位置。
BEFORE clear()
+-------------------+------------------+------------------+
| discardable bytes | readable bytes | writable bytes |
+-------------------+------------------+------------------+
| | | |
0 <= readerIndex <= writerIndex <= capacity
AFTER clear()
+---------------------------------------------------------+
| writable bytes (got more space) |
+---------------------------------------------------------+
| |
0 = readerIndex = writerIndex <= capacity
搜索操作
简单搜索之indexOf()和bytesBefore()
// 查找某个byte在索引为[0, readableBytes)之中第一次出现的位置
int i = byteBuf.indexOf(0, byteBuf.readableBytes(), (byte) 5);
// 第一个byte元素5前面元素的个数
int i = byteBuf.bytesBefore(3, byteBuf.readableBytes(), (byte) 5);
复杂搜索之forEachByte()
List<Byte> evenList = new ArrayList<>();
byteBuf.forEachByte(value -> {
if(0 == value % 2) {
evenList.add(value);
}
return true;
});
evenList.forEach(System.out::println);
标记和重置
readerIndex和writerIndex都有其对应的mark索引。
System.out.println(buffer.readerIndex());
buffer.markReaderIndex(); // mark
buffer.setIndex(10, 10); // change
System.out.println(buffer.readerIndex());
buffer.resetReaderIndex(); // reset
System.out.println(buffer.readerIndex());
派生buffer
派生buffer是已存在的buffer的一个视图,可以通过如下方法来创建:
duplicate()
slice()
slice(int, int)
readSlice(int)
retainedDuplicate()
retainedSlice()
retainedSlice(int, int)
readRetainedSlice(int)
一个派生的buffer有独立的readerIndex、writerIndex、marker indexes,但是其内部的数据和原来的buffer是共享的。如果想完全的拷贝一份新的buffer(深拷贝)可以使用copy()方法。
上面的方法中如果不带retain关键字,那么buffer的引用计数不会增加,带retain关键字的buffer的引用计数会加+1。
与JDK中其他类型的转换
转换为byte数组
byte[] array = buffer.array();
底层是采用数组实现的才能转换,可以使用hasArray()来判断是否能转换,例如DirectBuffer就不能转换为byte数组。
转换为NIO Buffers
ByteBuffer byteBuffer = buffer.nioBuffer();
转换为字符串
String s = buffer.toString(CharsetUtil.UTF_8);
转换为流
ByteBufInputStream byteBufInputStream = new ByteBufInputStream(buffer);
ByteBufOutputStream byteBufOutputStream = new ByteBufOutputStream(buffer);
Buffer的分类
从内存分配的角度看,ByteBuf 可以分为两类。
- 堆内存(HeapByteBuf)字节缓冲区:特点是内存的分配和回收速度快,可以被JVM自动回收;缺点就是如果进行Socket的I/O读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到堆外,性能会有一定程度的下降。
- 直接内存(DirectByteBuf)字节缓冲区:非堆内存,它在堆外进行内存分配,相比于堆内存,它的分配和回收速度会慢一些,但是将它写入或者从Socket Channel中读取时,由于少了一次内存复制,速度比堆内存快。
正是因为各有利弊,所以Netty提供了多种ByteBuf供开发者使用,经验表明,ByteBuf的最佳实践是在IO通信线程的读写缓冲区使用DirectByteBuf,后端业务消息的编解码模块使用HeapByteBuf,这样组合可以达到性能最优。
从内存回收角度看,ByteBuf也分为两类:
- 基于对象池的ByteBuf
- 普通的ByteBuf。
两者的主要区别就是基于对象池的ByteBuf可以重用ByteBuf对象,它自己维护了一个内存池,可以循环利用创建的ByteBuf,提升内存的使用效率,降低由于高负载导致的频繁GC。测试表明使用内存池后的Netty在高负载、大并发的冲击下内存和GC更加平稳。
尽管推荐使用基于内存池的ByteBuf,但是内存池的管理和维护更加复杂,使用起来也需要更加谨慎,因此,Netty提供了灵活的策略供使用者来做选择。