本文是Netty文集中“Netty in action”系列的文章。主要是对Norman Maurer and Marvin Allen Wolfthal 的 《Netty in action》一书简要翻译,同时对重要点加上一些自己补充和扩展。
本章含盖
- ByteBuf —— Netty 数据容器
- API细节
- 使用场景
- 内容分配
如我们之前提到的,网络数据的基本单位是字节。JAVA NIO 提供了 ByteBuffer 作为字节的容器,但是这个类使用过于复杂并且在一些情况下使用过于笨重。
Netty使用ByteBuf 替换 ByteBuffer,一个强大的实现解决了JDK API 地址的局限性,并且提供了更好的API给网络应用开发者
Q:JDK API 的局限性指什么?
A:JDK API 的局限性有如下几点:
① 长度固定。一旦buffer分配完成,它的容量不能动态扩展或者收缩,当需要编码的POJO对象大于ByteBuffer容量时,会发生索引越界异常。
② 只有一个标识位置的指针position。读写是需要手动flip和rewind等,需要十分小心使用这些API,否则很容易导致异常。
③ API功能有限。不支持一些高级使用的特性,需要用户自己实现。
The ByteBuf API
Netty数据处理API通过两个组件暴露 —— ByteBuf抽象类 和 ByteBufHolder接口
下面是ByteBuf API 的一些优点:
- 它是可扩展的用户自定义缓冲器类型
- 通过构建一个复合缓冲器类型来实现传输的零拷贝
- 容量根据需求可扩展( 如同JDK的StringBuilder )
- 读模式和写模式的转换不需要调用ByteBuffer的flip方法
- 读和写使用不同的索引,即有两个索引,一个读索引和一个写索引。
- 支持方法链,即链式调用方法
- 支持引用计数,即一个ByteBuf的引用次数
- 支持池
ByteBuf 类 —— Netty 数据容器
ByteBuf是如何工作的
ByteBuf包含了两个不同的索引:一个用于读,一个用于写。当你从ByteBuf中读数据时,readerIndex将增加所读字节数量。类似的,当你写数据到ByteBuf时,writeIndex将增加。
如果你尝试读取大于writeIndex位置的数据,将触发IndexOutOfBoundsException。
ByteBuf类以 ‘read’ 和 ‘write’ 打头的方法将增加相应的索引,然后以’set’和‘get’打头的方法并不会增加索引的值。后一种方法,对相对索引的操作,会将索引作为参数传递给方法。
比如:
能够指定ByteBuf的最大容量,当尝试移动写索引超过最大容量时将触发异常。( 默认限制 Integer.MAX_VALUE )
ByteBuf 使用模式
当我们通过Netty工作时,你将遇到几种围绕ByteBuf构建的常见使用模式。
ByteBuf主要3种使用模式:①Heap Buffers —— 堆缓冲区;②Direct Buffers —— 直接缓冲区;③Composite Buffers —— 复合缓冲区
Heap Buffers
Heap Buffers :最经常使用的ByteBuf模式,存储数据到JVM的堆空间。看做一个后台数组,这种模式支持快速分配和释放在不是用池的情况下。
适用于处理遗留数据的场景
注意:尝试去访问一个后台数组当hasArray()返回false,这将触发一个UnsupportedOperationException异常。这种模式类似与JDK ByteBuffer的使用。
Direct Buffers
Direct Buffer是另外一种ByteBuf模式。我们希望总是从堆中给创建的对象分配内存,但是这不是必须的 —— JDK1.4引进的用于NIO的ByteBuffer允许JVM通过本地调用实现一个内存分配。这个做的目的是为了避免拷贝缓冲区内容到( or from )一个中间缓冲区在每次本地I/O操作调用前( or after )。
Javadoc 对于ByteBuffer 明确声明,“直接缓冲区的内容将属于标准垃圾回收的堆范围外”。这就解释了为什么直接缓冲区是网络数据传输的理想选择。如果你的数据被包含在一个堆分配的缓冲区中,则JVM实际上就是复制你的缓冲区数据到直接缓冲区,然后在通过socket发送。
直接缓冲区的主要缺点就是:分配和释放比基于堆的缓冲区开销更高些。如果你工作在一个遗留代码上,你可能还会遇到另外一个缺点:因为数据不在堆上,所以你需要将数据拷贝到堆上。如下:
Composite Buffers
最后一个模式是复合缓冲区,该复合缓冲区表示一个多ByteBuf的聚合视图。你能够根据需要添加或删除ByteBuf实例,这是JDK ByteBuffer现实完全不具有的特性。
Netty通过ByteBuf 的子类 CompositeByteBuf来实现这个模式,该模式提供将多个缓冲区合并为一个缓冲区的虚拟表示。
例子:一个包含了两个部分的消息,消息头和消息体,通过HTTP传输。这两个部分通过不同的应用模式生成和装配当消息被发送的时候。应用可选择复用消息体对于多个不同的消息。当这发生时,每个消息都会创建一个新的消息头。
因为我们不想重新分配两个缓冲区给每个消息,CompositeByteBuf完美适用该情况;它消除了不必要的拷贝通过暴露通用的ByteBuf API。
👇直接当做整个缓冲区模式的访问
注意,Netty使用CompositeByteBuf优化socket I/O 的操作,尽可能的消除JDK的buffer实现造成的性能和内存使用量的问题。这个优化实现在了Netty的核心代码中,也就是说它不会被暴露,但是我们需要意识到这个造成的影响。
所说的影响可能是:如果你将ByteBuf1、ByteBuf2复合成一个CompositeByteBuf,那么你对ByteBuf1、ByteBuf2的修改都会影响到CompositeByteBuf,因为CompositeByteBuf并不会将ByteBuf1和ByteBuf2中的数据拷贝一份过来,而是共享了ByteBuf1、ByteBuf2数据。而JDK Bytebuffer的话,不存在该问题,因为ByteBuffer的复合使用只能够直接拷贝数据过来,这样多个ByteBuffer和复合ByteBuffer之间就不存在数据共享的情况了。
字节操作
ByteBuf提供了大量的读和写操作用于修改它的数据。
随机访问索引
就像一个普通的java数组,ByteBuf索引从0开始,最后一个索引值为capacity()-1.
顺序访问索引
ByteBuf有两个索引,一个读索引,一个写索引。而JDK的ByteBuffer只有个一个索引,这就是为什么在从写模式转换到读模式时需要调用flip()方法。
废弃的字节
废弃的字节:已经被读取过的字节。
已经读取过的字节能被丢弃并通过调用discardReadBytes()回收空间。并初始化readerIndex大小为0。
下图显示了在图5.3所示的基础上调用discardReadBytes()后的结果。你能看到废弃字节段的空间被转换成了可写入空间。
你可能尝试通过频繁调用discardReadBytes()为了获得最大的可写段,请留意这将很可能造成内存拷贝,因为可读字节必须被移动到缓冲区头。除非真的需要我们应该避免这样的操作。
可读字节
ByteBuf的可读字节段存储了真实的数据。一个新分配、封装、或复制的缓冲区默认的readerIndex为0。任何以’read’打头的方法或skip方法将检索或跳过数据,从当前的readerIndex起并通过读入字节的数增加readerIndex。
如果方法调用传入ByteBuf参数作为一个写目标对象并且没有一个目标index参数,那么这个ByteBuf [ 作为参数传入的ByteBuf ]的writerIndex将会增加,比如:
readBytes(ByteBuf dest);
如果尝试从一个可读字节已经耗尽的缓冲区里进行读操作,那么将引发IndexOutOfBoundsException异常。
下面展示了如何读取所有可读的字节
可写字节
可写字节段是一个未定义内容的内存区域,并为写入作好准备。一个新分配的缓冲区writerIndex的默认值是0。任何一个以‘write’打头的方法操作都会从writerIndex索引开始,增加相应的写入的字节数。如果写操作的目标是一个ByteBuf [ 如,为下面例子中dest ]并且没有指定源索引,那么这个被操作的ByteBuf [ 如,为下面例子中dest ]的readerIndex将增加相应的数量,比如:
writeBytes(ByteBuf dest);
如果试图在超过目标容量的索引下进行写入操作,这将引发一个IndexOutOfBoundException异常。
Q:这里说的目标容量是不是指最大容量,因为前面的内容说,如果写索引超过了容量会自动进行扩容,只有写索引超过了最大容量时,才会引发一个异常。
A:是超过最大容量才会引发异常的
ByteBuf buf = Unpooled.buffer(20, 32);
for(int i = 1; i <= 6; i++ ) {
buf.writeInt(i);
}
for(int i = 7; i <= 9; i++) {
buf.writeInt(i);
}
// 异常
Exception in thread "main" java.lang.IndexOutOfBoundsException: writerIndex(32) + minWritableBytes(4) exceeds maxCapacity(32): UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 32, cap: 32/32)
at io.netty.buffer.AbstractByteBuf.ensureWritable0(AbstractByteBuf.java:275)
at io.netty.buffer.AbstractByteBuf.writeInt(AbstractByteBuf.java:982)
at com.bayern.netty.buffer.BufferDemo.main(BufferDemo.java:26)
A:参照的是初始化长度。
但是请注意,当写入的数据超过了初始容量大小,但是小于最大容量大小时,ByteBuf会根据一定的逻辑进行扩容操作,并更新capacity为新的容量大小值。新的capacity范围在minNewCapacity到maxCapacity作为一个上界。
索引管理
你能够设置和重定位ByteBuf的readerIndex和writerIndex通过调用markReaderIndex(),markWriterIndex(),resetReaderIndex(),和resetWriterIndex()。这些操作类似于JDK InputStream的mark(int readlimit)和reset()方法,除了这里没有指定readlimit参数来指定读入多少字节后mark变成无效。
你能够移动索引到指定位置通过调用readerIndex(int)或writerIndex(int)。设置任何一个索引到一个无效的位置将会引发IndexOutOfBoundsException异常。
通过调用clear()可以将readerIndex和writerIndex同时置为0。注意,这并没有清除内存中的内容。如下图所示:
调用clear()的开销远远小于discardReadBytes(),因为clear()重置了索引的位置,当没有进行内存拷贝。
查询操作
这有几种方式去确定ByteBuf中一个指定值的索引。
① indexOf()
② 更复杂的查询可以执行一个带ByteBufProcessor参数的方法。
派生的缓冲区
一个派生缓冲区提供专门的方式表示其内容的ByteBuf的视图。
视图的创建方法:
- duplicate()
- slice()
- slice(int, int)
- Unpooled.unmodifiableBuffer(...)
- orderSlice(int)
每个方法都会返回一个新的ByteBuf实例,每个实例都有他们自己的reader、writer、marker索引。
同JDK ByteBuffer一样,其内部存储是共享的。这使得能够廉价的创建一个被派生的缓冲区,但这也意味着,如果你修改了派生缓冲区的内容,那么源实例的内容也会被修改。
JDK ByteBuffer的slice()派生的缓冲器也是内容共享的:
ByteBuf copying
如果你需要对一个已经存在的缓冲区完全拷贝,可以使用copy()或copy(int, int)。不同于一个派生的缓冲区,该方法返回的ByteBuf是数据的独立副本。
读/写 操作
读/写的两种分类:
- get() 和 set() 操作。从一个给定的索引开始操作,操作完索引值不会改变
- read() 和 write() 操作。从一个给定索引开始操作,操作完毕会根据访问的字节数量对索引值做调整。
更多操作
ByteBuf提供了额外的实用性操作
ByteBufHolder 接口
我们经常发现,除了实际数据的有效负载外,我们还需要存储各种属性值。HTTP的返回是一个很好的例子;除了字节代表的内容外,还有状态码、cookies等其他属性。
Netty提供了ByteBufHolder来处理这个常见的情况。ByteBufHolder还提供了Netty高级功能的支持,比如:缓冲池。一个ByteBuf能从一个池中获取,并在不需要的时候自动释放( 释放的确切含义能被实现特定 )。
ByteBufHolder只有几个少数的方法用于访问底层数据和引用计数( reference counting )。
ByteBufHolder是一个很好的选择,如果你想要实现将一个消息对象的负载存储在一个ByteBuf中。
ByteBuf 分配
该章节我们将介绍几种管理ByteBuf实例的方法
ByteBufAllocator 接口
为了减少分配和释放内存的消耗,Netty使用ByteBufAllocator接口实现了池,该实现能够分配我们所描述的任何种类的ByteBuf实例。池的使用是特定于应用程序的决定,这决定不会对ByteBuf API做任何改变。
你能够从一个Channel或通过ChannelHandlerContext得到一个ByteBufAllocator的引用。
Netty为ByteBufAllocator提供了两个实现:PooledByteBufAllocator和UnpooledByteBufAllocator。
前一种( PooledByteBufAllocator )实现池的ByteBuf,用于提高性能和减小内存碎片。该实现使用了jemalloc的内存分配的有效方法,jemalloc已经被大部分现代操作系统所采用。
后一种( UnpooledByteBufAllocator )实现非池的ByteBuf实例,当调用时总是返回一个新的对象。
尽管Netty默认使用PooledByteBufAllocator,但这能被轻易的改变,通过ChannelConfig API 或在启动你的应用时指定一个不同的分配器。
Unpooled buffers
当你没有一个ByteBufAllocator引用时,Netty提供了一个可利用的类叫Unpooled,Unpooled提供了静态的帮助方法去创建一个非池的ByteBuf实例。
Unpooled类使ByteBuf能在在非网络项目中有效使用,这使得项目能从高性能可扩展的buffer API中获益并且不需要其他的Netty组件。
ByteBufUtil 类
ByteBufUtil 提供静态的帮助方法用于管理一个ByteBuf。因为ByteBufUtil的API 非常通用且与池无关,所以它的方法实现都在分配类外面。
ByteBufUtil最重要的方法大概就是hexdump(),hexdump()打印一个ByteBuf内容的十六进制的表示。这在多种情况下是非常有用的,比如打印内容用于debug。一个十六进制的表示通常比直接使用二进制的表示提供更有用的日志条目。而且,十六进制版本能够更简单的被转换回实际的字节的表示。
另一个有用的方法是boolean equals(ByteBuf , ByteBuf),该方法决定了两个ByteBuf实例是否相同。
reference counting —— 引用计数
引用计数是一个用于优化内存使用和性能的技术,该技术通过释放对象持有的资源来实现优化内存和性能,当对象不再被任何一个对象引用时该对象就会被释放。
Netty 4 对ByteBuf 和 ByteBufHolder 引入了引用计数,ByteBuf 和 ByteBufHolder都实现了ReferenceCounted接口。
引用计数的思路并不复杂;通常它包含追踪活跃引用的数量到一个指定的对象。一个ReferenceCounted实现实例通常以活跃引用值1开始( 也就是当ReferenceCounted实现实例被创建的时候,其引用计数值就为1了 )。当引用计数值大于0时,该对象保证不会被释放。当引用计数指减小到0时,该实例将被释放。注意,释放的确切含义能被实现特定,但是已经被释放的对象不应该再被使用。
引用计数对于池的实现是不可以缺少的,比如像PooledByteBufAllocator,它减少了内存分配的开销。
尝试去访问一个已经释放的引用计数对象,将返回一个IllegalReferenceCountException异常。
注意,一个指定的类可以定义它自己的释放计数契约以它们特有的方式。举个例子,我们能想象一个类,它实现了release()方法,总是设置引用值为0无论当前的值是什么,这将使所有的有效引用同时变得无效。
后记
本文主要对Netty的ByteBuf进行了详细的介绍。Bytebuf是Netty中存储数据的容器,相比于JDK 的ByteBuffer又进行了进一步的优化和加强。后期我们会通过源码解析的方式进一步的了解ByteBuf在Netty中的使用。
若文章有任何错误,望大家不吝指教:)