type
status
date
slug
summary
tags
category
icon
password
上一节我们介绍了 Netty 的通讯通道 Channel,这一节我们开始介绍 Netty 的数据容器 ByteBuf,包括 ByteBuf 的原理、内存管理和常用方法等。

1、ByteBuf是什么

Java IO 的基本单位是字节,我们需要一个容器来存储这些字节数据。通常有下面几种方式。

1.1 Stream

在传统 Java BIO 中,我们可以使用 InputStreamOutputStream 作为字节的容器。

1.2 Bytebuffer

Java NIO(New IO) 是从 Java 1.4 版本开始引入的一个新的 IO API,支持面向缓冲区的、基于通道的IO操作,这些类都放在 java.nio 包下。Java NIO 提供了 Bytebuffer 作为操作字节数据的容器。下面是一个简单例子。
从上面例子看出,操作 Bytebuffer 并不方便。因为 Bytebuffer 只有一个索引 position,是读写共用的。从写模式改成读模式需要调用 flip() 把 position 重置为 0,从读模式改成写模式也需要调用 clear() 把 position 重置为0。
除此之外,Bytebuffer 不能扩容,每次 put 操作时,都会对可用空间进行校检,如果剩余空间不足,需要重新创建一个新的 ByteBuffer,然后将旧的 ByteBuffer 复制到新的 ByteBuffer 中去。

1.3 ByteBuf

ByteBuf 是 Netty 的数据容器,相比起 NIO 原生的数据容器 ByteBuffer,ByteBuf 做了许多优化和改进:
  • Bytebuf 维护了 readIndex 和 writeIndex 两个 index,在读写模式转换不需要调用 flip()。
  • 支持自动扩容。
  • 支持方法链接调用。
  • 支持引用计数。
  • 支持池技术(比如:线程池、数据库连接池)。
  • 通过用其内置的复合缓冲区可实现透明的零拷贝。

2、ByteBuf原理

2.1 ByteBuf的结构

ByteBuf 本质是一个由不同的索引分别控制读访问和写访问的字节数组。以下是官方提供的 ByteBuf 的结构图:
ByteBuf 包含三个参数:
  • readerIndex:读索引,不超过 writerIndex,调用 readXXX()skipBytes() 方法会移动该索引,调用 getXXX() 方法则不会移动。
  • writerIndex:写索引,不超过 capacity,调用 writeXXX() 方法会移动该索引,调用 setXXX() 则不会移动。
  • capacity:容量,超过 capacity 后会自动扩容,最大容量不超过 Integer.MAX_VALUE。
ByteBuf 被起始索引和这三个参数所在的索引分成三部分:
  • discardable bytes(可丢弃字节):范围为 0 ~ readerIndex,这部分字节数据已经被读取完,空间等待回收。
  • readable bytes(可读字节):范围为 readerIndex ~ writerIndex,这部分字节数据已经准备好,等待用户读取。
  • writable bytes(可写字节):范围为 writerIndex ~ capacity,这部分空闲空间可写入字节。

2.2 ByteBuf的的工作过程

1、初始化
ByteBuf 初始化后 readerIndex 和 writeIndex 的取值一开始都是 0。
2、写入N(N < capacity)个字节后,writerIndex = N。
3、读取M(M<N)个字节之后,readerIndex = M。
4、调用 discardReadBytes() 丢弃 0 ~ readerIndex 间的数据,相当于 readerIndex 和 writerIndex 同时向前移动 M 个字节,最后 readerIndex = 0,writerIndex = N - M。
5、当我们已经读取完所有的数据后,可以调用 clear() 使 ByteBuf 恢复到初始状态,这时候 readerIndex = writerIndex = 0。
注意
  • 调用 clear() 的开销没有 discardReadBytes() 那么大,因为它不需要任何内存复制。
  • readerIndex 和 writerIndex 超过边界值,会发生 IndexOutOfBoundException 异常。

3、ByteBuf的内存管理

ByteBuf 按照数据的存储位置,可以分为 堆缓冲区(HEAP BUFFER)直接缓冲区(DIRECT BUFFER)
  • 堆缓冲区(HEAP BUFFER):将数据存储在 JVM 的堆内存中,这些内存需要被 jvm 管理。
  • 直接缓冲区(DIRECT BUFFER):将数据存在 JVM 的堆内存外面,这些内存不需要被 jvm 管理。
按内存对象是否可以重复利用,可以分为 池化内存(Pooled)非池化内存(Unpooled)
  • 池化内存(Pooled):每次都从预先分配好的内存中去取出一段连续内存封装成一个 ByteBuf 给应用程序使用。
  • 非池化内存(Unpooled):每次分配内存的时候,直接调用系统 api,向操作系统申请一块内存。

3.1 堆内存和直接内存

3.1.1 堆缓冲区

最常用的模式,这种模式直接将数据存储在 JVM 的堆空间中,这种情况下 ByteBuf 数组被称为支撑数组(backing array)。
优点:能在没有使用池化的情况下提供快速的分配和释放,非常适合于有遗留的数据需要处理的情况。
缺点:在 socket 发送前,需要先把数据拷贝到直接缓冲区,导致 IO 效率不高。

3.1.2 直接缓冲区

将数据存储在 JVM 堆以外的内存,需要调用系统级别的 API
优点:免去中间交换的内存拷贝,提高 IO 速度。
缺点:分配和释放代价大,而且这部分内存不受 JVM 管理。需要主动释放。
Netty 默认使用直接缓冲区(直接缓冲区创建和销毁的性能问题,可通过下面的池化技术解决)。如果想切换成堆缓冲区,需要设置下面的参数。

3.2 池化内存和非池化内存

3.3.1 池化内存

Netty 会先向系统申请一大块内存,通过池化算法管理这块内存,并向上层提供申请内存接口。我们需要使用内存的时候,再通过接口申请一段连续内存封装成一个 ByteBuf 给应用程序使用。池化的最大意义在于可以重用 ByteBuf。
优点:池对象可以回收复用,提高内存分配的效率。
缺点:需要对预先申请的内存进行管理。

3.2.2 非池化内存

同 java 对象的创建销毁过程,直接向 JVM 申请内存创建 ByteBuf 对象,使用完后交给 JVM 进行 gc 回收。
优点:使用简单
缺点:对象不能复用,创建销毁代价很大。
池化功能是否开启,可以通过下面的系统环境变量来设置
  • 4.1 以后,非 Android 平台默认启用池化实现,Android 平台启用非池化实现
  • 4.1 之前,池化功能还不成熟,默认是非池化实现

4、ByteBuf 使用

4.1 ByteBuf常用API

4.1.1 ByteBuf读写

主要分两类:
  • readXXX()和 writeXXX()操作,从给定的索引开始,并且会根据已经访问过的字节数对索引进行调整 。
  • getXXX()和 setXXX()操作,从给定的索引开始,并且保持索引不变。
其他操作
  • isReadable () 如果至少有一个字节可供读取,则返回 true
  • isWritable () 如果至少有一个字节可被写入,则返回 true
  • readableBytes() 返回可被读取的字节数
  • writableBytes() 返回可被写入的字节数
  • capacity() 返回 ByteBuf 可容纳的字节数 。在此之后,它会尝试再次扩展直到达到maxCapacity ()
  • maxCapacity() 返问 ByteBuf 可以容纳的最大字节数
  • hasArray() 如果 ByteBuf 由一个字节数组支撑,则返回 true
  • array () 如果 ByteBuf 由一个字节数组支撑则返问该数组;否则,它将抛出一个 UnsupportedOperat工onException 异常

4.1.2 ByteBuf引用计数

对于非池化类型的 bytebuffer,例如 UnpooledHeapByteBufUnpooledDirectByteBuf ,能够依赖 JVM GC 回收器自动回收。
而对于池化类型的 bytebuffer,例如 PooledHeapByteBufPooledDirectByteBuf,则必须要主动将用完的 bytebuf 放回池里,如果不释放,内存池会越来越大,直到内存溢出。
所以所有的 ByteBuf(包括池化和非池化)需要在 JVM 的 GC 机制之外,有自己的引用计数器和回收过程(主要是回收到netty申请的内存池)。这个计数器机制如下:
  • 所有 ByteBuf 的引用计数器初始值为1。
  • 可以增加/减少 ByteBuf 的引用计数器,
  • 当 ByteBuf 的引用计数器为 0 时该对象就会被释放内存或回收到内存池。
  • 访问一个被释放的 ByteBuf 会抛 IllegalReferenceCountException 异常。
ByteBuf 引用计数器的常用方法有:
  • release():计数器减1。从 InBound 里读取的 ByteBuf 和自己创建的 ByteBuf 要自己调用 release() 手动释放,写入到 OutBound 的 Bytebuf 例如调用 writeAndFlush() 由 netty 负责释放,不需要手动调用release()
  • retain():计数器加1。在使用派生缓冲区的时候,为了防止源 ByteBuf 突然被释放导致派生缓冲区操作异常,需要调用 retain() 来显示派生缓冲区的存在。
  • refCnt():返回计数器的值。

4.1.3 派生缓冲区

ByteBuf 还提供了一种视图的方式来读写内容,这种视图被称作派生缓冲区
派生缓冲区维护单独的 readIndex、writeIndex 和 markIndex,但与原生 ByteBuf 共享数据和引用计数器。这意味着派生缓冲区的创建开销很低,但是如果修改了其他内容,也同时修改对应的原生 ByteBuf。
派生缓冲区的常用方法有:
  • copy():返回源 ByteBuf 的完全拷贝。该拷贝和源 ByteBuf 完全独立,在新 ByteBuf 上修改不会影响旧 ByteBuf。
  • duplicate():返回源 ByteBuf 的整个空间的共享缓冲区,该缓冲区的 readIndex、writeIndex、capacity 的位置和源 ByteBuf 一致,但是一套独立的索引,读写操作不会影响源 ByteBuf 的索引。该方法内部不会调用 retain() 方法,所以计数器不会增加。
  • slice():只返回源 ByteBuf 已经写入数据区域的共享缓冲区。和 duplicate() 方法不同,slice() 方法只返回源 ByteBuf 已写入数据部分的拷贝,所以 readIndex、writeIndex、capacity 的位置都是重置的,也是一套独立的索引。该方法内部不会调用 retain() 方法,所以计数器不会增加。
输出如下:

4.2 ByteBuf工具类

4.2.1 ByteBufAllocator

Netty 为创建 ByteBuf 实例专门提供了一个接口 ByteBufAllocator,适用于可以获取到 channelHandlerContext 或 channel 实例的场景。实现类有两个
  • PooledByteBufAllocator 返回池化实例。
  • UnpooledByteBufAllocator 返回非池化实例。
Netty 默认使用了池化的 ByteBufAllocator 。但我们可以在 Bootstrap 配置参数来自定义使用池化还是非池化分配器。
使用 PooledByteBufAllocator 创建 ByteBuf
使用 UnpooledByteBufAllocator 创建 ByteBuf

4.2.2 Unpooled

提供了大量静态方法来创建缓冲区的工具类。该工具实际上是通过调用 UnpooledByteBufAllocator 来实现最终缓冲区的创建工作的。静态方法主要分为以下三类:
  • 创建新的缓冲区
  • 基于已有缓冲区、byte数组或字符串创建一个wrapper缓冲区
  • 基于已有缓冲区创建一个副本缓冲区
Unpooled 使得 ByteBuf 同样可用于那些并不需要Netty的其他组件的非网络项目。当没有办法获取到 channelHandlerContext 或 channel 实例的时候,可以使用 Unpooled 类来创建 ByteBuf。

4.2.3 ByteBufUtil

Unpooled 工具类的关注点是缓冲区的创建,而 ByteBufUtil 工具的类的关注点是对于已有的缓冲区的操作,如打印(hexDump)、编码、解码、拷贝等。

4.2.4 CompositeByteBuf

CompositeByteBuf 提供了将多个 buffer 虚拟成一个合并的 Buffer 的技术。CompositeByteBuf 中的 ByteBuf 实例可能同时包含堆缓冲区的和直接缓冲区的。如果 CompositeByteBuf 只含有一个实例,调用 hasArray() 方法会返回这个实例的 hasArray() 方法的值;否则总是返回 false。
例如一条消息包含 header 和 body 两部分,每条消息需要使用不同的 header 搭配同一个 body,就可以使用复合缓冲区。

四、总结

  • ByteBuf 本质是一个由不同的索引分别控制读访问和写访问的字节数组。
  • ByteBuf 有 readerIndex、writerIndex 和 capacity 三个参数,这三个负责控制 ByteBuf 的读写操作。
  • ByteBuf 的内存管理,按照数据的存储位置,可以分为堆缓冲区、直接缓冲区和复合缓冲区;按内存对象是否可以重复利用,可以分为池化内存和非池化内存。
  • 创建 ByteBuf 实例,可以使用 ByteBufAllocator 接口或者 Unpooled 工具类。
  • ByteBuf 具有引用计数器的概念,当引用计数器为 0 时 ByteBuf 对象会被回收。

五、参考

《Netty In Action》
 
Docker系列:什么是容器Netty系列(六):Channel
mcbilla
mcbilla
一个普通的干饭人🍚
Announcement
type
status
date
slug
summary
tags
category
icon
password
🎉欢迎来到飙戈的博客🎉
-- 感谢您的支持 ---
👏欢迎学习交流👏