本章讲包括:
- ByteBuf —— Netty 的数据容器(data container)
- API 详情
- 使用样例
- 内存分配
如前所述,网络数据的基本单位始终是字节。 Java NIO提供了ByteBuffer作为其字节容器,但是这个类使得使用过于复杂并且使用起来有点麻烦。 Netty替代ByteBuffer的是ByteBuf,这是一个强大的实现,可以解决JDK API的局限性,并为网络应用程序开发人员提供更好的API。
在本章中,我们将说明与JDK的ByteBuffer相比,ByteBuf的卓越功能和灵活性。 这也将使您更好地了解Netty的数据处理方法,并为您在第6章中对ChannelPipeline和ChannelHandler的讨论做好准备。
5.1 The ByteBuf API
Netty的数据处理API通过两个组件 - 抽象类公开
ByteBuf和接口ByteBufHolder。
以下是ByteBuf API的一些优点:
- 它可以扩展到用户定义的缓冲区类型。
- 透明零拷贝(zero-copy)是通过内置的复合缓冲区类型(buffer type)实现的。
- 容量按需扩展(与JDK StringBuilder一样)。
- 在读写器模式之间切换不需要调用 ByteBuffer 的 flip() 方法。
- 阅读和写作采用不同的指数(distinct indices)。
- 支持方法链接。
- 支持引用计数。
- 支持池化。
其他类可用于管理 ByteBuf 实例的分配以及对容器及其拥有的数据执行各种操作。 我们将在详细研究 ByteBuf 和 ByteBufHolder 时探索这些功能。
5.2 Class ByteBuf —— Netty’s data container
因为所有网络通信都涉及字节序列的移动,所以显然需要一种有效且易于使用的数据结构。 Netty的ByteBuf实现满足并超出了这些要求。 让我们先看看它是如何使用的
索引,以简化对其包含的数据的访问。
5.2.1 How it works
ByteBuf维护两个不同的索引:一个用于读取,一个用于写入。 当您从ByteBuf读取时,其readerIndex会增加读取的字节数。 同样,当您写入ByteBuf时,其writerIndex会递增。 图5.1显示了空ByteBuf的布局和状态。
要理解这些索引之间的关系,请考虑在readerIndex达到与writerIndex相同的值之前读取字节会发生什么。 那时,您将达到可读数据的末尾。 试图读取超过该点将触发IndexOutOfBoundsException,就像当您尝试访问数组末尾之外的数据时。
名称以read或write开头的ByteBuf方法会使相应的索引前进,而以set和get开头的操作则不会。 后一种方法对相对索引进行操作,该索引作为参数传递给方法。
可以指定 ByteBuf 的最大容量,并且尝试将写入索引移动超过此值将触发异常。 (默认限制为Integer.MAX_VALUE。)
5.2.2 ByteBuf usage patterns
在使用Netty时,您将遇到围绕ByteBuf构建的几种常见使用模式。 在我们检查它们时,它将有助于记住图5.1 - 具有不同索引的字节数组来控制读和写访问。
heap buffers
最常用的ByteBuf模式将数据存储在JVM的堆空间中。 这种模式称为 backing array,在未使用池的情况下提供快速分配和释放。 清单5.1中显示的这种方法非常适合您必须处理遗留数据的情况。
注意
当 hasArray() 返回false时,尝试访问支持数组,将触发UnsupportedOperationException。 这种模式类似于JDK的ByteBuffer的使用。
direct buffers
direct buffer 是另一种 ByteBuf pattern. 我们希望为对象创建分配的内存总是来自堆,但它不必。 ByteBuffer类,JDK 1.4中引入的NIO允许JVM实现通过本机调用分配内存。 这旨在避免在每次调用本机 I / O操作之前(或之后)将缓冲区的内容复制到(或来自)中间缓冲区。
ByteBuffer的Javadoc明确指出,“直接缓冲区的内容将驻留在正常的垃圾收集堆之外。”这解释了为什么直接缓冲区是网络数据传输的理想选择。 如果您的数据包含在堆分配的缓冲区中,那么JVM实际上会在将缓冲区发送到套接字之前将其复制到内部的直接缓冲区。
直接缓冲区的主要缺点是分配和释放它们比基于堆的缓冲区更昂贵。 如果您使用遗留代码,您可能还会遇到另一个缺点:因为数据不在堆上,您可能需要制作副本,如下所示。
显然,这涉及比使用支持数组更多的工作,因此如果您事先知道容器中的数据将作为数组访问,您可能更喜欢使用堆内存。
composite buffers
第三个也是最后一个模式使用复合缓冲区,它提供了聚合视图多个ByteBuf。 在这里,您可以根据需要添加和删除ByteBuf实例,这是JDK的ByteBuffer实现中完全没有的功能。
Netty使用ByteBuf的子类CompositeByteBuf实现此模式,后者提供多个缓冲区的虚拟表示形式作为单个合并缓冲区。
为了说明,让我们考虑一个由两部分组成的消息,标题和正文,通过HTTP传输。 这两个部分由不同的应用程序模块生成,并在发送消息时进行组装。 应用程序可以选择为多个消息重用相同的消息体。 发生这种情况时,会为每条消息创建一个新标头。
因为我们不想为每条消息重新分配两个缓冲区,所以CompositeByteBuf非常适合; 它暴露了常见的ByteBuf API,消除了不必要的复制。 图5.2显示了生成的消息布局。
以下清单显示了如何使用JDK的ByteBuffer实现此要求。 创建两个ByteBuffer的数组来保存消息组件,并创建第三个以保存所有数据的副本。
分配和复制操作以及管理阵列的需要使得这个版本既低效又笨拙。 下一个清单显示了使用CompositeByteBuf的版本。
CompositeByteBuf可能不允许访问后备阵列(backing array),因此访问CompositeByteBuf中的数据类似于直接缓冲区模式(direct buffer pattern),如下所示。
请注意,Netty优化了使用CompositeByteBuf的套接字I / O操作,从而尽可能消除了JDK缓冲区实现所带来的性能和内存使用损失。 此优化在Netty的核心代码中进行,因此不会公开,但您应该了解其影响。
The CompositeByteBuf API
除了它从ByteBuf继承的方法之外,CompositeByteBuf还提供了大量增加的功能。 有关API的完整列表,请参阅Netty Javadocs。
5.3 Byte-level opeartions
除了用于修改其数据的基本读写操作之外,ByteBuf 提供了许多方法。 在接下来的部分中,我们将讨论其中最重要的部分。
5.3.1 Random access indexing
就像在普通的Java字节数组中一样,ByteBuf索引是从零开始的:第一个字节的索引是0,最后一个字节的索引总是 capacity() - 1。 下一个清单显示存储机制的封装使迭代ByteBuf的内容变得非常简单。
请注意,使用采用索引参数的方法之一访问数据不会改变 readerIndex 或 writerIndex 的值。 要么必要时可以通过调用 readerIndex(index) 或 writerIndex(index) 手动移动。
5.3.2 Sequential access indexing
虽然ByteBuf同时具有读取器和写入器索引,但JDK的ByteBuffer只有一个,这就是为什么必须调用 flip() 来在读写模式之间切换的原因。 图5.3显示了ByteBuf如何通过其两个索引划分为三个区域。
5.3.3 Discardable bytes
图5.3中标记为可废弃字节的段包含已经读取的字节。 可以通过调用 discardReadBytes() 来丢弃它们并回收空间。 存储在 readerIndex 中的该段的初始大小为0,随着读取操作的执行而增加(getXXXX 操作不会移动readerIndex)。
图5.4显示了在图5.3所示的缓冲区上调用 discardReadBytes() 的结果。 您可以看到可丢弃字节段中的空间已可用于写入。 请注意,在调用discardReadBytes() 之后,无法保证可写段的内容。
虽然您可能经常调用 discardReadBytes() 以最大化可写段,但请注意,这很可能会导致内存复制(memory copying),因为可读字节(图中标记为 CONTENT )必须移动到缓冲区。 我们建议只在真正需要的时候这样做; 例如,当内存使用率很高时。
5.3.4 Readable bytes
ByteBuf的可读字节段(readable bytes segment)存储实际数据。新分配,包装或复制 的 buffer’s 的 readerIndex 的默认值为0.任何名称以read或skip开头的操作都将检索或跳过当前 readerIndex 的数据,并将其增加读取的字节数。如果调用的方法将ByteBuf参数作为写目标并且没有目标索引参数,目标缓冲区的writerIndex也会增加;例如,
readBytes(ByteBuf dest);
如果在可读字节用尽时尝试从缓冲区读取,则会引发IndexOutOfBoundsException。
此清单显示了如何读取所有可读字节。
5.3.5 Writable bytes
可写字节段是一块可以写入的,内存未定义的内存区域。新分配的缓冲区的 writerIndex 的默认值为0.任何名称以write开头的操作都将开始在当前的writerIndex中写入数据,并将其增加写入的字节数。如果写操作的目标也是ByteBuf并且未指定源索引,则源缓冲区的readerIndex将增加相同的量。此调用将显示如下:
writeBytes(ByteBuf dest);
如果尝试去写超出容量(capacity)的区域。就会抛出一个 IndexOutOfBoundException的异常。
下面的例子,生成随机数并填充到 buffer 里面,知道内存溢出。此处使用 writeableBytes() 方法来确定缓冲区中是否有足够的空间。
5.3.6 Index management
JDK的InputStream定义了方法 mark(int readlimit)和 reset()。这些用于将流中的当前位置标记为指定值和分别将流重置为该位置。
简单来说,你可以通过调用 markReaderIndex(), markWriterIndex(), resetReaderIndex(), 和 resetWriterInex() 方法来设置或者调整 ByteBuf 的 readerIndex 和 writerIndex 的值。这些操作类似于 Inputstream 的嗲用。除了没有 readlimit 参数指定标记何时变为无效。
您还可以通过调用 readerIndex(int) 或 writerIndex(int) 将索引移动到指定的位置。尝试将索引设置为无效位置将导致 IndexOutOfBoundsException。
通过调用clear() 方法,你可以将 readerIndex 和 writerIndex 的值设置为 0. 注意,这样做并不会清除内存中的文本。下图表明了他是如何工作的:
调用前的情况:
调用后的情况:
调用 clear() 方法的开销比调用 discardReadBytes() 的开销小,因为它只是重置了索引。并没有拷贝任何内存。
5.3.7 Search opeartions
有几种方法可以确定 ByteBuf 中指定值的索引。其中最简单的方法是使用 indexOf() 方法。使用 ByteBufProcessor 参数的方法可以执行更复杂的搜索。该接口定义了一个
单一方法,
boolean process(byte value)
报告输入值是否是正在寻找的值。
ByteBufProcessor定义了许多针对常见值的便捷方法。假设您的应用程序需要与所谓的Flash套接字集成,具有NULL终止内容。调用简单有效地使用Flash数据,因为在处理期间执行的边界检查更少。
forEachByte(ByteBufProcessor.FIND_NUL)
此列表显示了搜索回车符(\ r)的示例。
5.3.8 Derived buffers
一个 derived buffer 提供ByteBuf的视图,以专门的方式表示其内容。这些视图是通过以下方法创建的:
- duplicate()
- slice()
- slice(int, int)
- Unpooled.unmodifiableBuffer(…)
- order(ByteOrder)
- readSlice(int)
每个实例都返回一个带有自己的reader,writer和marker索引的新ByteBuf实例。内部存储与JDK ByteBuffer一样共享。这使得派生缓冲区的创建成本低廉,但这也意味着如果修改其内容,您也要修改源实例,所以要小心。
ByteBuf copying
如果需要现有缓冲区的真实副本,请使用 copy() 或 copy(int,int)。与派生缓冲区不同,此调用返回的ByteBuf具有数据的独立副本。
下一个清单显示了如何使用slice(int,int) 处理ByteBuf段。
现在让我们看看ByteBuf段的副本与切片的不同之处。
除了修改原始ByteBuf的切片或副本的效果之外,这两种情况是相同的。尽可能使用slice()来避免复制内存的成本。
5.3.9 Read/write opeartions
就像我们之前提到的那样,读写操作有两大类:
- get() 和 set() 操作. 从给定的索引开始并保持 index 不变。
- read() 和 write() 操作. 从给定索引开始并按访问的字节数进行调整。
图5.1 罗列了最长使用的 get() 方法,如果想看完整的,请参考 API 文档。
这些操作中的大多数都有相应的 set() 方法。 这些列于表5.2中
下面的清单说明了 get() 和 set() 方法的使用,表明它们不会改变读写索引。
现在让我们检查一下 read() 操作,它们作用于当前的 readerIndex 或 writerIndex。 这些方法用于从ByteBuf中读取,就像它是一个流一样。 表5.3显示了最常用的方法。
几乎每个 read() 方法都有一个相应的 write() 方法,用于附加到ByteBuf。 请注意,表5.4中列出的这些方法的参数是要写入的值,而不是索引值。
5.3.10 More opeartions
表5.5 显示了 ByteBuf 提供的一些附加的,有用的操作。
5.4 Interface ByteBufHolder
我们经常发现除了实际的数据有效负载之外,我们还需要存储各种属性值。 HTTP 响应就是一个很好的例子; 以及以字节表示的内容,还有状态代码,cookie等。 Netty提供了ByteBufHolder 来处理这个常见用例。 ByteBufHolder还支持Netty的高级功能,例如缓冲池,其中 ByteBuf 可以从池中借用,并且如果需要也可以自动释放。
ByteBufHolder只有少数方法可以访问底层数据和引用计数。 表5.6列出了它们(除了从ReferenceCounted继承的那些)。
如果要实现将其有效负载存储在ByteBuf中的消息对象,ByteBufHolder是一个不错的选择。
5.5 ByteBuf allocation
在本节中,我们将描述管理ByteBuf实例的方法。
5.5.1 On-demand: interface ByteBufAllocator
为了减少分配和释放内存的开销,Netty使用ByteBufAllocator接口实现池化,该接口可用于分配我们描述的任何ByteBuf变种的实例。 池的使用是一种特定于应用程序的决定,它不会以任何方式改变ByteBuf API。表5.7列出了ByteBufAllocator提供的操作。
您可以从Channel(每个可以具有不同的实例)或通过绑定到ChannelHandler的ChannelHandlerContext获取对ByteBufAllocator的引用。 以下列表说明了这两种方法。
Netty 提供了 ByteBufAllocator 的两种实现: PooledByteBufAllocator 和 UnpooledByteBufAllocator. 前者将ByteBuf实例池化,以提高性能并最大限度地减少内存碎片。 此实现使用一种称为jemalloc的内存分配的有效方法,该方法已被许多现代操作系统采用。 后一种实现不会池化ByteBuf实例,并且每次调用它时都会返回一个新实例。
尽管Netty默认使用PooledByteBufAllocator,但可以通过ChannelConfig API轻松更改,也可以在引导应用程序时指定其他分配器。 更多细节可以在第8章找到。
5.5.2 Unpooled buffers
在某些情况下,您可能没有对ByteBufAllocator的引用。 对于这种情况,Netty提供了一个名为Unpooled的实用程序类,它提供了静态帮助程序方法来创建非池化的ByteBuf实例。 表5.8列出了最重要的这些方法。
Unpooled类还使ByteBuf可用于非网络项目,这些项目可以从高性能可扩展缓冲区API中受益,并且不需要其他Netty组件。
5.5.3 Class ByteBufUtil
ByteBufUtil提供了用于操作ByteBuf的静态帮助方法。 由于此API是通用的且与池无关,因此这些方法已在分配类之外实现。
这些静态方法中最有价值的可能是hexdump(),它打印ByteBuf内容的十六进制表示。 这在各种情况下都很有用,例如记录ByteBuf的内容以进行调试。 十六进制表示通常将提供比直接表示字节值更可用的日志条目。 此外,十六进制版本可以很容易地转换回实际的字节表示。
另一个有用的方法是boolean equals(ByteBuf,ByteBuf),它确定两个ByteBuf实例的相等性。 如果您实现自己的ByteBuf子类,您可能会发现ByteBufUtil的其他方法很有用。
5.6 Reference counting
引用计数是一种通过在对象不再被其他对象引用时释放对象所拥有的资源来优化内存使用和性能的技术。 Netty在版本4中为ByteBuf和ByteBufHolder引入了引用计数,两者都实现了接口ReferenceCounted。
引用计数背后的想法并不是特别复杂; 主要涉及跟踪指定对象的活动引用数。ReferenceCounted实现实例通常以活动引用计数1开始。只要引用计数大于0,就保证不释放对象。当活动引用数减少为0时,将释放实例。 请注意,虽然释放的确切含义可能是特定于实现的,但至少已释放的对象应该不再可用。
引用计数对于池化实现至关重要,例如PooledByteBufAllocator,它可以减少内存分配的开销。 示例显示在接下来的两个列表中。
尝试访问已释放的引用计数对象将导致 IllegalReferenceCountException。 请注意,特定类可以使用其自己的唯一定义其发布计数合同办法。 例如,我们可以设想一个类,其release() 的实现总是将引用计数设置为零,无论其当前值如何,从而立即使所有活动引用无效。
who is responsible for release? 通常,访问对象的最后一方负责释放它。 在第6章中,我们将解释此 conept 与 ChannelHandler 和 ChannelPipeline 的相关性。
5.7 Summary
本章专门介绍基于ByteBuf的Netty数据容器。 我们首先解释了ByteBuf对JDK提供的实现的优势。 我们还强调了可用变体的API,并指出哪些最适合特定用例。
这些是我们讨论的要点:
- 使用不同的读写索引来控制数据访问
- 内存使用的不同方法 - 支持数组和直接缓冲区
- 使用CompositeByteBuf的多个ByteBuf的聚合视图
- 数据访问方法:搜索,切片和复制
- read,write,get和set API
- ByteBufAllocator 池化和引用计数
在下一章中,我们将重点介绍ChannelHandler,它为您的数据处理逻辑提供了工具。 由于ChannelHandler大量使用ByteBuf,您将开始看到Netty整体架构的重要组成部分。