理解ByteBuf

Netty的数据处理API通过两个组件暴露ByteBuf、ByteBufHolder。ByteBuf有如下优点:

  1. 它可以被用户自定义的缓冲区类型扩展
  2. 通过内置的复合缓冲区类型实现了透明的零拷贝(不是很理解)
  3. 容量可以按需增长
  4. 在读和写这两种模式之间切换不需要调用flip()方法
  5. 读和写使用了不同的索引
  6. 支持方法的链式调用
  7. 支持引用计数(不是很理解)
  8. 支持池化(不是很理解)

使用模式

堆缓冲区

最常用的ByteBuf模式是将数据存储在JVM的堆空间中。这种模式被称为支撑数据,它能在没有使用池化的情况下提供快速的分配和释放。这种方式如下所示,非常适合有遗留的数据需要处理的情况(有遗留数据需要处理,是怎样的一个场景):

1
2
3
4
5
6
7
8
9

ByteBuf heapBuf = ...;
if(heapBuf.hasArray()) {
    byte[] array = heapBuf.array();
    int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
    int length = heapBuf.readableBytes();
    handlerArray(array, offset, length);
}

(对ByteBuf的这种处理方式才是正确的,我在学习尚硅谷的课程时,课程中直接拿到array,然后调用toString方法,最后得到的数据会存在乱码)

当hasArray()方法返回false时,尝试访问支撑数组将触发一个UnsupporterOperationException。

直接缓冲区

直接缓冲区的内容驻留在常规的会被垃圾回收的堆之外,这解释了为什么直接缓冲区对于网络数据传输是理想的选择。如果数据包含在一个在堆上分配的缓冲区中,那么事实上,在通过套接字发送数据之前,JVM将会在内部把缓冲区复制到一个直接缓冲区中。

直接缓冲区的主要缺点是,相对于基于堆的缓冲区,它们的分配和释放都较为昂贵。如果在处理遗留代码,可能因为数据不是在堆上,所以不得不再进行一次复制。

和支撑数组相比,这涉及的工作更多。因此,如果事先知道容器中的数据将会被作为数组来访问,可能更愿意使用堆内存(不是很理解这个说法)。

1
2
3
4
5
6
7
8
9

ByteBuf directBuf = ...;
if(!directBuf.hasArray()) {
    int length = directBuf.readableBytes();
    byte[] array = new byte[length];
    directBuf.getBytes(directBuf.readerIndex(), array);
    handleArray(array, 0, length);
}

符合缓冲区

复合缓冲区为多个ByteBuf提供一个聚合视图,Netty通过CompositeByteBuf实现这个模式,这个类提供了一个将多个缓冲区表示为单个合并缓冲区的虚拟表示。

CompositeByteBuf中的ByteBuf实例可能同时包含直接内存和非直接内存,如果其中只有一个实例,那么对CompositeByteBuf上的hasArray()方法调用将返回该组件上的hasArray()方法的值,否则它将返回false。

这个技术的使用场景如下:一个HTTP协议消息由头部和主体组成,这两部分由应用程序的不同模块产生,将会在消息被发送时组装。应用程序可以选择为多个消息重用相同的消息主体。当这种情况发生时,对于每个消息都将会创建一个新的头部。

2021-08-06-19-23-45

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

CompositeByteBuf messageBuf = Unpooled.compositeBuffer();

ByteBuf headerBuf = ...;
ByteBuf bodyBuf = ...;

messageBuf.addComponents(headerBuf, bodyBuf);

// todo something

messageBuf.removeComponent(0);

CompositeByteBuf可能不支撑访问其支撑数据,因此访问CompositeByteBuf中的数据类似于访问直接缓冲区的模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

CompositeByteBuf compBuf = Unpooled.compositeBuffer();

int length = compBuf.readableBytes();
byte[] array = new byte[length]

compBuf.read(compBuf.readerIndex(), array);

handleArray(array, 0, array.length);

Netty使用了CompositeByteBuf来优化套接字的IO操作,尽可能地消除了由JDK的缓冲区实现所导致的性能及内存使用率的惩罚。这种优化发生在Netty的核心代码中,因此不会被暴露出来。(不是很理解在讲什么)

查找操作

最简单的是使用indexOf()进行查找,较为复杂是通过ByteProcessor作为参数的方法搜索,这个接口只定义了一个方法:

1
2
3

boolean process(byte value)

使用ByteProcessor的简单案例:

1
2
3
4

ByteBuf buffer = ...;
int index = buffer.forEachByte(ByteProcessof.FIND_CR);

派生缓冲区

派生缓冲区为ByteBuf提供了以专门的方式来呈现内容的视图,这类视图是通过如下方法创建的:

  • duplicate()
  • slice()
  • slice(int, int)
  • Unpooled.unmodifiableBuffer()
  • order(ByteOrder)
  • readSlice()

这些方法都将返回一个新的ByteBuf实例,它们具有自己的读、写、标记索引。其内部存储是共享的,这使得派生缓冲区的创建成本很低廉,同时也意味着,如果你修改了它的内容,也同时修改了其对应的源实例。

copy()和copy(int, int)方法可以实现缓冲区的复制。这两个调用返回的ByteBuf拥有独立的数据副本。

order是用于指定使用哪种字节序读取数据。

duplicate和slice的区别

如下代码,最终输出结果为2,0:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

ByteBuf byteBuf = Unpooled.copiedBuffer("Hello,World", StandardCharsets.UTF_8);

byteBuf.readByte();
byteBuf.readByte();

ByteBuf duplicate = byteBuf.duplicate();
ByteBuf slice = byteBuf.slice();

System.out.println(duplicate.readerIndex());
System.out.println(slice.readerIndex());

slice和duplicate的区别就在于此,duplicate会将readerIndex和writeIndex都duplicate下来,而slice不会,slice会新建一条readerIndex和writeIndex,具体是如何实现的,我暂时还不想花费精力去研究。

ByteBufHolder

书中简单的提了一下ByteBufHolder,我暂时不研究,等未来有需要再研究。

ByteBuf分配

为了降低分配和释放内存的开销,Netty通过ByteBufAllocator实现了ByteBuf的池化,它可以用来分配任意类型的ByteBuf实例。使用池化是特定于应用程序的决定,并不会以任何方式改变ByteBuf API的含义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

// 返回一个基于堆或者直接内存的ByteBuf
buffer();
buffer(int initialCapacity);
buffer(int initialCapacity, int maxCapacity);

// 返回一个基于堆内存的ByteBuf
heapBuffer();
heapBuffer(int initialCapacity)
heapBuffer(int initialCapacity, int maxCapacity)

// 返回一个基于直接内存的ByteBuf
directBuffer();
directBuffer(int initialCapacity);
directBuffer(int initialCapacity, int maxCapacity);

// 返回一个可以通过添加最大到指定数据的基于堆的或者直接内存存储的缓冲区来扩展的CompositeByteBuf
compositeBuffer();
compositeBuffer(int maxNumComponents);
compositeDirectBuffer();
compositeDirectBuffer();
compositeHeapBuffer();
compositeHeapBuffer();

// 返回一个用于套接字的IO操作的ByteBuf
ioBuffer();

可以通过Channel(每个都可以用一个不同的ByteBufAllocator实例)或者绑定到ChannelHandler的ChannelHandlerContext获取一个到ByteBufAllocator的引用。

1
2
3
4
5
6
7

Channel channel = ...;
ByteBufAllocator allocator = channel.alloc();

ChannelHandlerContext ctx = ...;
ByteBufAllocator allocator = ctx.alloc();

Netty提供了两种ByteBufAllocator的实现:PooledByteBufAllocator和UnpooledByteBufAllocator。前者池化了ByteBuf的实例以提高性能并最大限度地减少内存碎片。此实现使用了一种成为jemalloc的已被大量现代操作系统所采用的高效方法来分配内存。后者的实现不池化ByteBuf实例,并且每次它被调用时都会返回一个新的实例。

Netty默认使用了PooledByteBufAllocator,但是可以很容易地通过ChannelConfig或者引导程序时指定一个不同的分配器来更改。

Unpooled

这个好东西我已经在使用了。

引用技术

(我觉得Java可能不需要这个技术,但是池化需要这个技术)(好尴尬,刚理解到这一层就发现书中也是怎么说的)

Netty为ByteBuf和ByteBufHolder引入了引用技术技术,它们都实现了ReferenceCounted。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

// 查看引用技术

Channel channel = ...;
ByteBuf buffer = channel.alloc().directBuffer();
assert buffer.refCnt() == 1;

// 释放引用计数的对象
ByteBuf buffer = ...;
boolean released = buffer.release();

其他知识点

  1. 可以调用readerIndex(index)、writeIndex(index)来手动设置readerIndex和writeIndex。

  2. 可以调用discardReadBytes()丢弃已经读取过的字节,从而回收空间。

  3. markReaderIndex()、markWriterIndex()、resetWriterIndex()、resetReaderIndex()可以用来标记和重置ByteBuf的readerIndex和writerIndex(我实际上不知道到这些技术的应用场景)。

  4. 可以调用clear()方法来将readerIndex和writerIndex都设置为0。

  5. 往ByteBuf中写入数据时,首先确保目标ByteBuf具有足够的可写入空间来容纳当前要写入的数据,如果没有,则将检查当前的写索引以及最大容量是否可以在扩展后容纳该数据,可以则分配并调整容量,否则就会抛出异常(如何体现在代码中)。

  6. ByteBufUtils中的hexdump()以十六进制的表达形式打印ByteBuf的内容;equals(ByteBuf, ByteBuf)用于判断两个ByteBuf实例的相等性。

验证的问题

如果尝试在缓冲区的可读字数已经耗尽时从中读取数据,那么将引发一个IndexOutOfBoundException。那么source.readBytes(ByteBuf dest);会报错么?

最后验证的结果为会报错,readBytes会尽量去将dest的空间塞满,如果此时source的数据不够,则直接抛出异常。