所有的网络通讯都涉及到数据的交互,数据的交互本质上就是字节序列的移动。为了实现网络通信的高效性,我们通常需要一个缓冲区来存储这些字节序列。在NIO中我们使用ByteBuffer作为它的字节容器,但是这个类的方法使用起来过于复杂,在日常的编程工作中显得有些繁琐。
Netty为此提供了一款替代ByteBuffer的的组件:ByteBuf,它既解决了 JDK API 的局限性,又为网络应用程序的开发者提供了更好的 API。
ByteBuf是通过字节数组作为缓冲区来存取数据,通过外观模式聚合了JDK NIO元素的ByteBuffer,进行封装。下面是一些 ByteBuf API 的优点:
Java NIO中的数据容器ByteBuffer使用同一套索引来支持读写操作,在写入操作转为读取操作时,需要调用flip()方法将limit属性设置为0,这也是NIO使用起来较为繁琐的原因之一。Netty中的ByteBuf对此提出了新的解决方案,它维护了两个不同的索引:一个用于读取,一个用于写入。当你从 ByteBuf 读取时,它的 readerIndex(读取索引)将会被递增已经被读取的字节数。同样地,当你写入 ByteBuf 时,它的writerIndex(写入索引) 也会被递增相应的字节数。下图展示了一个新创建的空ByteBuf的布局结构和状态。
调用ByteBuf 类中write
开头的任何方法会增加写入索引的值。ByteBuf 类似ArrayList,支持自动扩容,每次添加数据之前会检查是否可以完全写入,如果不能,就会自动扩展ByteBuf的容量,确保不会抛出异常。它的最大容量默认的限制就是Integer.MAX_VALUE。
调用 ByteBuf 中以 read
开头的任何方法会增加读取索引的值。当我们持续读取直至readerIndex = writerIndex,就达到了“可以读取的”数据的末尾,继续执行read相关方法试图读取数据,就会触发一个IndexOutOfBoundsException。
最常用的 ByteBuf 模式是将数据存储在 JVM 的堆空间中。这种模式被称为支撑数组(backing array),它能在没有使用池化的情况下提供快速的分配和释放。
Direct Buffer属于堆外分配的直接内存,不会占用堆的容量。适用于套接字传输过程,避免了数据从内部缓冲区拷贝到直接缓冲区的过程,性能较好。
Netty提供了一个特有的缓冲区:复合缓冲区(CompositeByteBuf),它为多个 ByteBuf 提供一个聚合视图,我们可以动态的添加和删除其中的ByteBuf实例,其内部维护了一个ArrayList集合,用于存储添加进去的ByteBuf实例。
CompositeByteBuf byteBuffers = Unpooled.compositeBuffer();
// UnpooledHeapByteBuf实例
ByteBuf heapBuffer = Unpooled.copiedBuffer("Netty in action!!!".getBytes());
// UnpooledUnsafeDirectByteBuf实例
ByteBuf directBuffer =Unpooled.directBuffer().writeBytes("Hello Netty!".getBytes());
// 添加ByteBuf实例
byteBufs.addComponent(heapBuffer, directBuffer);
// 删除下标为0的ByteBuf实例
byteBufs.removeComponent(0);
for (ByteBuf byteBuffer : byteBuffers) {
System.out.println(byteBuffer.toString());
}
CompositeByteBuf 中的 ByteBuf 实例可能同时包含直接内存分配和非直接内存分配。如果其中只有一个实例,那么对 CompositeByteBuf 上的 hasArray() 方法的调用将返回该实例的 hasArray() 方法的值;否则它将直接返回 false 。
ByteBuf 提供了许多超出基本读、写操作的方法用于修改它的数据。
和ArrayList类似的,ByteBuf的索引也是从0开始的,第一个字节的索引是 0,最后一个字节的索引是 ByteBuf 的 capacity - 1。下面的代码展示了ByteBuf的读写过程:
ByteBuf buf = Unpooled.buffer(16);
// 向buf中写入数据
for (int i = 0; i < buf.capacity(); i++){
buf.writeByte(i + 1);
}
// 根据索引访问buf中的数据
for (int i = 0; i < buf.capacity(); i++){
System.out.println(buf.readByte());
}
注意通过索引访问时不会推进 readerIndex和 writerIndex,我们可以通过 ByteBuf 的 readerIndex(index) 或 writerIndex(index) 来分别推进读取索引或写入索引。
就像我们上面提到的,ByteBuf 同时提供了读索引和写索引,以方便我们从中读取数据或者向其中写入数据。ByteBuf中的几个变量符合:0 <= readerIndex <= writerIndex <= capacity。下图展示了ByteBuf 被两个索引划分后的区域分布。
这个分段包含了已经被读过的字节,其初始大小为 0,存储在 readerIndex 中,会随着 read 操作的执行而增加。通过调用 discardReadBytes()方法,可以丢弃它们并回收空间。
查看源码可知,调用discardReadBytes()方法后,readerIndex = 0,writerIndex -= readerIndex,将可读字节移动到了缓冲区开始的位置,扩展了可写字节的区域,但该操作涉及内存复制,建议只在有真正需要的时候才这样做。
ByteBuf 的可读字节分段存储了实际数据。新分配的、包装的或者复制的缓冲区的默认的readerIndex 值为 0。任何名称以 read 或者 skip 开头的操作都将检索或者跳过位于当前readerIndex 的数据,并且将它增加已读字节数。
ByteBuf sourceBuf = Unpooled.buffer(16);
ByteBuf destBuf = Unpooled.buffer(12);
for (int i = 0; i < sourceBuf.capacity(); i++){
sourceBuf.writeByte(i+1);
}
// 从sourceBuf中读取数据到destBuf
sourceBuf.readBytes(dsetBuf);
// UnpooledUnsafeHeapByteBuf(ridx: 12, widx: 16, cap: 16)
System.out.println(sourceBuf.toString());
// UnpooledUnsafeHeapByteBuf(ridx: 0, widx: 12, cap: 12)
System.out.println(destBuf.toString());
for (int i = 0; i < destBuf.capacity(); i++){
System.out.println(destBuf.getByte(i));
}
如果被调用的方法需要一个 ByteBuf 参数作为写入的目标,并且没有指定目标索引参数,那么该目标缓冲区的 writerIndex 也将被增加。如果尝试在缓冲区的可读字节数已经耗尽后,继续从中读取数据,那么将会引发一个 IndexOutOfBoundsException。
可写字节分段是指一个拥有未定义内容的、写入就绪的内存区域。新分配的缓冲区的writerIndex 的默认值为 0。任何名称以 write 开头的操作都将从当前的 writerIndex 处开始写数据,并将它增加已经写入的字节数。
ByteBuf destBuf = Unpooled.buffer(16);
ByteBuf sourceBuf = Unpooled.buffer(18);
for (int i = 0; i < sourceBuf.capacity(); i++){
sourceBuf.writeByte(i+1);
}
// 将sourceBuf中的数据写入到destBuf
destBuf.writeBytes(sourceBuf);
// UnpooledUnsafeHeapByteBuf(ridx: 0, widx: 18, cap: 64)
System.out.println(destBuf.toString());
// UnpooledUnsafeHeapByteBuf(ridx: 18, widx: 18, cap: 18)
System.out.println(sourceBuf.toString());
for (int i = 0; i < sourceBuf.capacity(); i++){
System.out.println(sourceBuf.getByte(i));
}
在向 ByteBuf 中写入数据时,其将首先确保目标 ByteBuf 具有足够的可写入空间来容纳当前要写入的数据,如果没有,则将检查当前的写索引以及最大容量是否可以在扩展后容纳该数据,可以则会分配并调整容量,否则就会抛出该异常。
ByteBuf提供了众多方法以保证其高效易用,具体看参考API,下面就其中较为重要的进行介绍。
clear()
:调用该方法会将 readerIndex 和 writerIndex 的值都设置为 0,但并不会清除内存中的内容。和 discardReadBytes()相比,它的调用轻量得多,因为它只是重置索引而不会复制任何的内存。
调用duplicate()、slice()、slice(int index, int length)、order(ByteOrder endianness)
会创建一个现有缓冲区的视图。它拥有独立的readerIndex、writerIndex和标注索引,其内部存储和源实例是共享的。因此如果你修改了这个视图的具体内容,那么它对应的源实例也会被修改。如果需要一个现有缓冲区的真实副本,请使用 copy() 或者 copy(int, int) 方法,该调用所返回的 ByteBuf 拥有独立的数据副本。
indexOf(int,int,byte)
:查询指定值的索引的方法,类似于String.indexOf("str")
操作。
我们经常发现,除了实际的数据负载之外,我们还需要存储各种属性值。HTTP 响应便是一个很好的例子,除了表示为字节的内容,还包括状态码、cookie 等。不同的协议消息体包含的数据格式和字段不一样,为此Netty提供了interface ByteBufHolder
,它对ByteBuf进行抽象和包装,不同的子类可以有不同的实现,使用者可以根据自己的需求对其实现,使用它可以更方便的访问ByteBuf中的数据。
如果想要实现一个将其有效负载存储在 ByteBuf 中的消息对象,那么 ByteBufHolder 将是个不错的选择。
ByteBufAllocator是字节缓冲区分配器,可以通过 Channel(每个都可以有一个不同的 ByteBufAllocator 实例)或者绑定到ChannelHandler 的 ChannelHandlerContext 获取一个到 ByteBufAllocator 的引用。根据Netty字节缓冲区的实现不同,ByteBufAllocator分为两种不同的分配器PooledByteBufAllocator
和UnpooledByteBufAllocator
。前者池化了ByteBuf的实例以提高性能并最大限度地减少内存碎片;后者的实现不池化ByteBuf实例,并且在每次调用时都返回一个新的实例。
在某些情况下,我们可能不能获取到一个ByteBufAllocator的引用。对于这种情况,Netty 提供了一个简单的称为 Unpooled 的工具类,它提供了静态的辅助方法来创建未池化的 ByteBuf实例。
ByteBufUtil是一个非常有用的工具类,它提供了一系列静态方法用于操作ByteBuf对象。其中最有用的方法就是对字符串的编码和解码,具体如下:
encodeString(ByteBufAllocator alloc, CharBuffer src, Charset charset)
:对需要编码的字符串src按照指定的字符集charset进行编码,利用指定的ByteBufAllocator生成一个新的ByteBuf;
decodeString(ByteBuffer src, Charset charset)
:使用指定的ByteBuffer和charset进行对ByteBuffer进行解码,获取解码后的字符串。
hexDump(...)
:它能够将参数ByteBuf的内容以十六进制字符串的方式打印出来,用于输出日志或者打印码流,方便问题定位,提升系统的可维护性。hexDump包含了一系列的方法,参数不同,输出的结果也不同。
引用计数
是一种通过在某个对象所持有的资源不再被其他对象引用时释放该对象所持有的,资源来优化内存使用和性能的技术。引用计数背后的想法并不是特别的复杂;它主要涉及跟踪到某个特定对象的活动引用的数量。一个 ReferenceCounted 实现的实例将通常以活动的引用计数为 1 作为开始。只要引用计数大于 0,就能保证对象不会被释放。当活动引用的数量减少到 0 时,该实例就会被释放。当然我们可以自定义一个特定的类实现ReferenceCounted ,你可以用它自己的独特方式来定义它的引用计数规则。
Netty中的 ByteBuf 和 ByteBufHolder 通过实现 interface ReferenceCounted
引入了该技术,引用计数对于池化实现(如 PooledByteBufAllocator )来说是至关重要的,它降低了内存分配的开销。试图访问一个已经被释放的引用计数的对象,将会导致一个 IllegalReferenceCountException。