Netty ByteBuf 和相关的辅助类

之前公司在公司第一次用 Netty 做游戏服务器,在网上找的 Demo,就把游戏服务器做起来了,但是一直也没有管去优化,最近在开始总结和回归的时候,会思考一些问题?

  1. Netty 为什么要用 ByteBuf 呢?NIO 不是已经有了 ByteBuffer 了吗?
  2. 除了 Unpooled.buffer() 还有没有别的方法返回 ByteBuf 呢?
  3. 好多对象的数据长度是固定的,在写数据中,能不能申请固定长度的 ByteBuf 呢?
  4. 使用了这么多 ByteBuf,系统会不会有大量的时间在拷贝 byte[] 数组呢?
  5. ByteBuf 如何才能高效的使用呢?

1. ByteBuf 功能说明

往往在做 IO(网络 IO, 文件IO ) 操作时, 会用到缓冲区。缓冲区的作用就是,当有数据的时候,不会立即写入,而是先放到一个地方暂存,等数据存到一定的量过后,再做写入操作。
在 NIO 中,有提供一个 ByteBuffer 操作类,为什么 Netty 还需要一个 ByteBuf 呢?这个就要说到 ByteBuffer 的缺点了:

  1. 长度固定,一旦分配完成,容量不能动态扩展和收缩。对象小了容易浪费,一旦需要的长度大于分配的长度,又会引发越界异常。
  2. ByteBuffer 只有一个标识位置的指针 position,导致同时操作读写的时候会操作同一个 position,增加操作的复杂度,而且容易出错。

基于上面的原因,Netty 自己提供了一个自己的实现 ByteBuf。

1.1. ByteBuf 的工作原理

实现的方法可以用两种:

  1. 参考 NIO 的 ByteBuffer 实现,自己实现,解决已有的问题。
  2. 基于 ByteBuffer,使用 Facade 模式对其进行包装,降低实现成本。

ByteBuf 提供了两个位置指针,一个 readerIndex(读操作),一个 writerIndex(写操作)。
一开始的时候,readerIndex = writerIndex = 0。
bytebuf-init
当操作写的时候,移动 writerIndex,readerIndex 不变。
bytebuf-write
当操作读的时候,移动 readerIndex 指针,但是要保证 0 < readerIndex <= writerIndex. 其中 0 到 readerIndex 中的数据已经无用了,可以调用 discardReadBytes 方法。
这样,因为读写的指针分离开了,就不会出现使用 ByteBuffer 时,常常忘记 flip() 操作而导致读写错误了。
ByteBuf 在 write 数据时,会自动的检查剩余容量是否小于当前需要的容器,如果小于,会自动扩容。
由于 NIO 中的 Channel 读写的参数都是 ByteBuffer,所以 ByteBuf 需要能很方便的转换为 ByteBuffer. 正是因为这样,所以 ByteBuf 内部实现是采用第二种方式,聚合了一个 ByteBuffer.

1.1.1. discardReadBytes 方法

当 ByteBuf 调用了 read 方法过后,那么 0 到 readerIndex 之前的数据就没有用了,可以是所以 discardReadBytes 方法重用这部分空间,以节约内存。这个往往在私有协议栈消息解码的时候非常有用,因为 TCP 底层可能念包,几百个整包消息被 TCP 粘包后作为一个整包发送。
但是,注意,discardReadBytes 的实现方式是把 当前 readerIndex 到 writerIndex 之前的数据复制拷贝到 index = 0 的位置,如图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
*  BEFORE discardReadBytes()  
*
* +-------------------+------------------+------------------+
* | discardable bytes | readable bytes | writable bytes |
* +-------------------+------------------+------------------+
* | | | |
* 0 <= readerIndex <= writerIndex <= capacity
*
* AFTER discardReadBytes()
*
* +------------------+--------------------------------------+
* | readable bytes | writable bytes (got more space) |
* +------------------+--------------------------------------+
* | | |
* readerIndex (0) <= writerIndex (decreased) <= capacity

这样会发生字节数组的复制,所以,频繁调用的话会导致性能下降,所以调用的时候需要确定是否需要调用。

1.1.2. clear 方法

该方法不会将 ByteBuf 中的数据都填充为 0, 仅仅只是设置 readerIndex=writerIndex=0。

1.1.3. Mark/Reset

Mark 备份指针的位置,Reset 重制 指针的位置:

  1. markReaderIndex():将当前的readerIndex备份到markedReaderIndex中;
  2. resetReaderIndex():将当前的readerIndex重置为markedReaderIndex的值;
  3. markWriterIndex() :将当前的writerIndex备份到markedWriterIndex中;
  4. resetWriterIndex():将当前的writerIndex重置为markedWriterIndex的值;

    1.1.4. Derived buffers

  5. duplicate,返回当前 ByteBuf 的复制对象,复制后返回的 ByteBuf 和当前操作的 ByteBuf 共享缓冲区中的内容,只是自己有自己的读写索引。修改复制后的 ByteBuf,会同时修改被复制的 ByteBuf.
  6. copy,返回新的 ByteBuf 对象,内容和索引都是独立的。

1.1.5. 转换成标准的 ByteBuffer

通过 nioBuffer()方法返回一个 ByteBuffer 对象,两者共享同一缓冲区内容。对 ByteBuffer 的操作不会修改原 ByteBuf 的读写索引。需要指出的是,返回后的 ByteBuffer 无法感知原 ByteBuf 的动态扩展操作。

1.2. ByteBuf 源码

1.2.1. ByteBuf 的类结构图

bytebuf-class-hierarchy

1.2.1.1. 按照 Heap/Direct 分类:

  1. Heap 在 JVM 堆分配内存,特点是内存的分配和回收速度快,可以被 JVM 自动回收;缺点就是如果进行 Socket 的 I/O 读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到 Channel 内核中,性能有一定程度的下降。
  2. Direct 在非堆内存,在堆外分配内存,分配和回收速度慢了一些,但是在写入 Socket Channel 中,没有复制,所以速度比堆内存快。

正因为这样,所以最佳实践是在 I/O 通信线程的读写使用堆 DirectByteBuf 会好一些,后端业务消息的编解码模块使用 HeapByteBuf,这样组合可以达到性能最优

1.2.1.2. 按照 Pooled/Unpooled 分类:

  1. Unpooled 每次分配内存申请新内存,频繁进行大块内存的分配和回收,对性能会造成一定的影响。
  2. Pooled 预先分配好一整块内存,分配的时候用一定算法从一整块内存取出一块连续内存,可以重用 ByteBuf,提高内存利用效率,减少 GC 次数。Netty 的实现叫做 PoolArena。

PoolArena:Arena本身是指一块连续区域,Netty 的 PoolArena 由多个Chunk 组成,每个 Chunk 由多个 Page 组成,并形成一个多层的二叉树,最底层是一个 Page,使用的时候采用深度优先的遍历,选择在合适的层进行分配。

无论是Chunk和Page,都通过状态位来标识内存是否可用。

1.2.1.3. 按照非 Unsafe / Unsafe 分类:

  1. 非 Unsafe [调用 JDK 的 API 进行读写]
  2. Unsafe [通过 JDK 的 Unsafe 对象基于物理内存地址进行数据读写]

1.2.2. ByteBuf 动态扩容的规则

首先设置门限阀值为 4MB. 当需要的新容量正好等于 4MB,使用 4MB 为新的缓冲区容量。如果申请的内存空间大于 4MB,不能采用倍增的方式扩展内存(防止内存膨胀和浪费),而采用每次步进 4MB 的方式进行内存扩张。扩张的时候,需要和最大内存(maxCapacity)进行比较,如果大于缓冲区的最大长度,则使用 maxCapacity 作为扩容后的缓冲区容量。

如果扩容后的新容量小于 4MB,则以 64 为技术进行倍增,直到倍增后的容量大于或等于需要的容量值。

其实就是保证在每次申请内存的时候,尽量多给一点内存,防止多次申请,造成扩容时内存多次复制,导致性能下降。但是,又不能多太多,防止内存的浪费。所以,在 4MB 以下才有倍增,在 4MB 以上采用步进的方式。

1.2.3. retain/release

每一个新分配的ByteBuf的引用计数值为1,每对这个ByteBuf对象增加一个引用,需要调用ByteBuf.retain()方法,而每减少一个引用,需要调用ByteBuf.release()方法。当这个ByteBuf对象的引用计数值为0时,表示此对象可回收。(涉及到 CAS(Compare And Swap)。

Just for my love !!