type
Post
status
Published
date
Dec 10, 2023
slug
summary
Netty系列(七):ByteBuf
tags
Netty
category
技术栈
password

1、ByteBuf是什么

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

1.1 Stream

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

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,例如 UnpooledHeapByteBuf 和 UnpooledDirectByteBuf ,能够依赖 JVM GC 回收器自动回收。
而对于池化类型的 bytebuffer,例如 PooledHeapByteBuf 和 PooledDirectByteBuf,则必须要主动将用完的 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和相关组件
Loading...