ByteBuf
Java ByteBuffer
Java NIO支持的字节缓冲区
- HeapByteBuffer:在jvm堆上面的一个buffer,底层的本质是一个数组。由于内容维护在jvm里,所以把内容写进buffer里速度会快些;并且,可以更容易回收。
- DirectByteBuffer:底层的数据其实是维护在操作系统的内存中,而不是jvm里,DirectByteBuffer里维护了一个引用address指向了数据,从而操作数据。跟外设(IO设备)打交道时会快很多,因为外设读取jvm堆里的数据时,不是直接读取的,而是把jvm里的数据读到一个内存块里,再在这个块里读取的,如果使用DirectByteBuffer,则可以省去这一步,实现zero copy。题外:外设之所以要把jvm堆里的数据copy出来再操作,不是因为操作系统不能直接操作jvm内存,而是因为jvm在进行gc(垃圾回收)时,会对数据进行移动,一旦出现这种问题,外设就会出现数据错乱的情况
// 分配HeapByteBuffer的方法是:
//参数大小为字节的数量
ByteBuffer.allocate(int capacity);
// 分配DirectByteBuffer的方法是:
/** 可以看到分配内存是通过unsafe.allocateMemory()来实现的,
* 这个unsafe默认情况下java代码是没有能力可以调用到的,
* 不过你可以通过反射的手段得到实例进而做操作,当然你
* 需要保证的是程序的稳定性,既然叫unsafe的,就是告诉你
* 这不是安全的,其实并不是不安全,而是交给程序员来操作,
* 它可能会因为程序员的能力而导致不安全,而并非它本身不安全。
*/
ByteBuffer.allocateDirect(int capacity);
ByteBuffer 属性
byte[] buff //buff即内部用于缓存的数组。
position //当前读取的位置。
mark //为某一读过的位置做标记,便于某些时候回退到该位置。
capacity //初始化时候的容量。
limit //当写数据到buffer中时,limit一般和capacity相等,当读数据时,limit代表buffer中有效数据的长度。
0 <= mark <= position <= limit <= capacity
ByteBuffer 常规方法
ByteBuffer allocate(int capacity) //创建一个指定capacity的ByteBuffer。
ByteBuffer allocateDirect(int capacity) //创建一个direct的ByteBuffer,这样的ByteBuffer在参与IO操作时性能会更好
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length) //把一个byte数组或byte数组的一部分包装成ByteBuffer。
//get put方法不多说
byte get(int index)
ByteBuffer put(byte b)
int getInt() //从ByteBuffer中读出一个int值。
ByteBuffer putInt(int value) // 写入一个int值到ByteBuffer中。
// 特殊方法
Buffer clear() // 把position设为0,把limit设为capacity,一般在把数据写入Buffer前调用。
Buffer flip() // 把limit设为当前position,把position设为0,一般在从Buffer读出数据前调用。
Buffer rewind() // 把position设为0,limit不变,一般在把数据重写入Buffer前调用。
compact() // 将 position 与 limit之间的数据复制到buffer的开始位置,复制后 position = limit -position,limit = capacity, 但如果 position 与 limit 之间没有数据的话发,就不会进行复制。
mark() & reset() //通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。
- put
写模式下,往buffer里写一个字节,并把postion移动一位。写模式下,一般limit与capacity相等。 - flip
写完数据,需要开始读的时候,将postion复位到0,并将limit设为当前postion。 - get
从buffer里读一个字节,并把postion移动一位。上限是limit,即写入数据的最后位置。 - clear
将position置为0,并不清除buffer内容。 - mark & reset
mark相关的方法主要是mark()(标记)和reset()(回到标记)。
Netty ByteBuf
因为Java NIO的ByteBuffer使用复杂,netty对其重新封装了一层,即ByteBuf。
A random and sequential accessible sequence of zero or more bytes (octets).This interface provides an abstract view for one or more primitive byte arrays ({@code byte[]}) and {@linkplain ByteBuffer NIO buffers}.
ByteBuf 优点
- 它可以被用户自定义的缓冲区类型扩展;
- 通过内置的复合缓冲区类型实现了透明的零拷贝;
- 容量可以按需增长(类似于JDK的StringBuilder);
- 在读和写这两种模式之间切换不需要调用ByteBuffer的flip()方法;
- 读和写使用了不同的索引;
- 支持方法的链式调用;
- 支持引用计数;
- 支持池化。
ByteBuf 注意点
- ByteBuf 维护了两个不同的索引:一个用于读取,一个用于写入。当从 ByteBuf 读取时,它的 readerIndex 将会被递增已经被读取的字节数。同样地,当写入 ByteBuf 时,它的 writerIndex 也会被递增。
- 当读取字节,readerIndex 达到 writerIndex 后继续读取,会触发 IndexOutOfBoundsException。名称以 read 或者 write 开头的 ByteBuf 方法,将会推进其对应的索引,而名称以 set 或者 get 开头的操作则不会。
- 可以指定 ByteBuf 的最大容量。试图移动写索引(即 writerIndex)超过这个值将会触发一个异常。(默认的限制是 Integer.MAX_VALUE。)
堆缓冲区
最常用的 ByteBuf 模式是将数据存储在 JVM 的堆空间中,这种模式被称为支撑数组(backing array),它能在没有使用池化的情况下提供快速的分配和释放。
/**
* 支撑数组
*/
ByteBuf heapBuf = ...;
// 检查ByteBuf是否有一个支撑数组
if (heapBuf.hasArray()) {
// 如果有,则获取对该数组的引用
byte[] array = heapBuf.array();
// 计算第一个字节的偏移量
int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
// 获得可读字节数
int length = heapBuf.readableBytes();
// 使用数组、偏移量和长度作为参数调用方法
handleArray(array, offset, length);
}
// 当 hasArray()方法返回 false 时,尝试访问支撑数组将触发一个 UnsupportedOperationException。
// 这个模式类似于 JDK 的 ByteBuffer 的用法。
直接缓冲区
ByteBuffer 的 Javadoc 明确指出:“直接缓冲区的内容将驻留在常规的会被垃圾回收的堆之外。”这也就解释了为何直接缓冲区对于网络数据传输是理想的选择。如果数据包含在一个在堆上分配的缓冲区中,那么事实上,在通过套接字发送它之前,JVM将会在内部把堆缓冲区复制到一个直接缓冲区中。
直接缓冲区的主要缺点是,相对于基于堆的缓冲区,它的分配和释放都较为昂贵。
/**
* 访问直接缓冲区的数据
*/
ByteBuf directBuf = ...;
// 检查ByteBuf是否由数组支撑。如果不是,则是这个是一个直接缓冲区
if (!directBuf.hasArray()) {
// 获取可读字节数
int length = directBuf.readableBytes();
// 分配一个新的数组来保存具有该刻度的字节数据
byte[] array = new byte[length];
// 讲字节复制到该数组
directBuf.getBytes(directBuf.readerIndex(), array);
// 使用数组、偏移量和长度作为参数调用方法
handleArray(array, 0, length);
}
复合缓冲区
Netty 通过一个 ByteBuf 子类——CompositeByteBuf——实现了这个模式,它提供了一个将多个缓冲区表示为单个合并缓冲区的虚拟表示。
注意 CompositeByteBuf 中的 ByteBuf 实例可能同时包含直接内存分配和非直接内存分配。如果其中只有一个实例,那么对 CompositeByteBuf 上的 hasArray()方法的调用将返回该组件上的 hasArray()方法的值;否则它将返回 false。
举个栗子,HTTP协议数据由头部和主体组成,这两部分由应用程序的不同模块产生,将会在消息被发送的时候组装。该应用程序可以选择为多个消息重用相同的消息主体。当这种情况发生时,对于每个消息都将会创建一个新的头部。
因为我们不想为每个消息都重新分配这两个缓冲区,所以使用 CompositeByteBuf 是一个完美的选择。它在消除了没必要的复制的同时,暴露了通用的 ByteBuf API。
/**
* 使用 ByteBuffer 的复合缓冲区模式
*
* 分配和复制操作,以及伴随着对数组管理的需要,
* 使得这个版本的实现效率低下而且笨拙。
*/
// Use an array to hold the message parts
ByteBuffer[] message = new ByteBuffer[] { header, body };
// Create a new ByteBuffer and use copy to merge the header and body
ByteBuffer message2 =
ByteBuffer.allocate(header.remaining() + body.remaining());
message2.put(header);
message2.put(body);
message2.flip();
/**
* 使用 CompositeByteBuf 的复合缓冲区模式
*
* CompositeByteBuf 可能不支持访问其支撑数组,
* 因此访问 CompositeByteBuf
* 中的数据类似于(访问)直接缓冲区的模式
*/
CompositeByteBuf messageBuf = Unpooled.compositeBuffer();
ByteBuf headerBuf = ...; // can be backing or direct
ByteBuf bodyBuf = ...; // can be backing or direct
// 讲ByteBuf实例追加到CompositeByteBuf
messageBuf.addComponents(headerBuf, bodyBuf);
.....
// 删除位于索引位置为0(第一个组件)的ByteBuf
messageBuf.removeComponent(0); // remove the header
// 循环遍历所有的ByteBuf实例
for (ByteBuf buf : messageBuf) {
System.out.println(buf.toString());
}
/**
* 访问 CompositeByteBuf 中的数据
*
* CompositeByteBuf 可能不支持访问其支撑数组,
* 因此访问 CompositeByteBuf
* 中的数据类似于(访问)直接缓冲区的模式
*/
CompositeByteBuf compBuf = Unpooled.compositeBuffer();
// 获得可读字节数
int length = compBuf.readableBytes();
// 分配一个具有可读字节数长度的新数组
byte[] array = new byte[length];
// 将字节读到该数组中
compBuf.getBytes(compBuf.readerIndex(), array);
// 使用偏移量和长度作为参数使用该数组
handleArray(array, 0, array.length);
随机访问索引
ByteBuf的索引跟Java数组一样,是从零开始的,索引范围是[0, capacity() - 1]。
/**
* 访问数据
*
* 使用那些需要一个索引值参数的方法
* (的其中)之一来访问数据既不会改变
* readerIndex 也不会改变 writerIndex。
* 可以通过调用 readerIndex(index)
* 或者 writerIndex(index)来手动移动这两者
*/
ByteBuf buffer = ...;
for (int i = 0; i < buffer.capacity(); i++) {
byte b = buffer.getByte(i);
System.out.println((char)b);
}
顺序访问索引
下图展示了ByteBuf 是如何被它的两个索引划分成 3 个区域的。
可丢弃字节
可丢弃字节包含已被读过(read*)的字节,通过调用discardReadBytes()方法,可以丢弃它们并回收空间。
下图展示了缓冲区上调用discardReadBytes()方法后的结果。可以看到,可丢弃字节分段中的空间已经变为可写的了。注意,在调用discardReadBytes()之后,对可写分段的内容并没有任何的保证。
虽然你可能会倾向于频繁地调用 discardReadBytes()方法以确保可写分段的最大化,但是请注意,这将极有可能会导致内存复制,因为可读字节(图中标记为 CONTENT 的部分)必须被移动到缓冲区的开始位置。我们建议只在有真正需要的时候才这样做,例如,当内存非常宝贵的时候。
可读字节
ByteBuf 的可读字节分段存储了实际数据。新分配的、包装的或者复制的缓冲区的默认的 readerIndex 值为 0。任何名称以 read 或者 skip 开头的操作都将检索或者跳过位于当前readerIndex 的数据,并且将它增加已读字节数。
/**
* 如果被调用的方法需要一个 ByteBuf 参数
* 作为写入的目标,并且没有指定目标索引参数,
* 那么该目标缓冲区的 writerIndex 也将被增加。
*/
readBytes(ByteBuf dest);
如果尝试在缓冲区的可读字节数已经耗尽时从中读取数据,那么将会引发一个 IndexOutOfBoundsException。
/**
* 读取所有数据
*/
ByteBuf buffer = ...;
while (buffer.isReadable()) {
System.out.println(buffer.readByte());
}
可写字节
可写字节分段是指一个拥有未定义内容的、写入就绪的内存区域。新分配的缓冲区的 writerIndex 的默认值为 0。任何名称以 write 开头的操作都将从当前的 writerIndex 处开始写数据,并将它增加已经写入的字节数。
/**
* 如果写操作的目标也是 ByteBuf,并且没有指定
* 源索引的值,则源缓冲区的 readerIndex
* 也同样会被增加相同的大小。
*/
writeBytes(ByteBuf dest);
如果尝试往目标写入超过目标容量的数据,将会引发一个IndexOutOfBoundException。
/**
* 写数据
*/
// Fills the writable bytes of a buffer with random integers.
ByteBuf buffer = ...;
while (buffer.writableBytes() >= 4) {
buffer.writeInt(random.nextInt());
}
索引管理
ByteBuf可以通过调用 markReaderIndex()、markWriterIndex()、resetWriterIndex() 和 resetReaderIndex()来标记和重置 readerIndex 和 writerIndex。
通过调用 clear()方法来将 readerIndex 和 writerIndex 都设置为 0。注意,这并不会清除内存中的内容。调用 clear()比调用 discardReadBytes()轻量得多,因为它将只是重置索引而不会复制任何的内存。
下图展示了在 clear()方法被调用之后 ByteBuf 的状态。
查找操作
ByteBuf可以通过 indexOf() 方法确定指定值的索引。
/**
* 检查输入值是否是正在查找的值
*/
boolean process(byte value)
/**
* 使用 ByteBufProcessor 来寻找\r
*/
ByteBuf buffer = ...;
int index = buffer.forEachByte(ByteBufProcessor.FIND_CR);
派生缓冲区
派生缓冲区通过以下方式创建:
- duplicate();
- slice();
- silce(int, int);
- Unpooled.unmodifiableBuffer(…);
- order(byteOrder);
- readSlice(int);
每个这些方法都将返回一个新的 ByteBuf 实例,它具有自己的读索引、写索引和标记索引。 其内部存储和 JDK 的 ByteBuffer 一样也是共享的。 这使得派生缓冲区的创建成本是很低廉的,但是这也意味着,如果你 修改了它的内容,也同时修改了其对应的源实例,所以要小心。
如果需要一个现有缓冲区的真实副本,请使用 copy()或者 copy(int, int)方法。不同于派生缓冲区,由这个调用所返回的 ByteBuf 拥有独立的数据副本。
/**
* 对 ByteBuf 进行切片
*/
Charset utf8 = Charset.forName("UTF-8");
// 创建一个用于保存给定字符串的ByteBuf
ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8);
// 创建该ByteBuf从索引0开始到索引15结束的一个新切片
ByteBuf sliced = buf.slice(0, 15);
System.out.println(sliced.toString(utf8));
// 更新索引0处字节
buf.setByte(0, (byte)'J');
// 将会成功,因为数据是共享的,对其中的一个所做的更改对另外一个也是可见的
assert buf.getByte(0) == sliced.getByte(0);
/**
* 复制一个 ByteBuf
*/
Charset utf8 = Charset.forName("UTF-8");
// 创建ByteBuf以保存所提供的字符串的字节
ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8);
// 创建该ByteBuf从索引0开始到索引15结束的分段的副本
ByteBuf copy = buf.copy(0, 15);
System.out.println(copy.toString(utf8));
buf.setByte(0, (byte) 'J');
// 将会成功,因为数据不是共享的
assert buf.getByte(0) != copy.getByte(0);
读/写操作
get() 和 set()
- get()和 set()操作,从给定的索引开始,并且保持索引不变;
- read()和 write()操作,从给定的索引开始,并且会根据已经访问过的字节数对索引进行调整。
/**
* get()和 set()方法的用法
*/
Charset utf8 = Charset.forName("UTF-8");
ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8);
System.out.println((char)buf.getByte(0));
// 存储当前的readerIndex和writerIndex
int readerIndex = buf.readerIndex();
int writerIndex = buf.writerIndex();
// 更新索引0处字节
buf.setByte(0, (byte)'B');
System.out.println((char)buf.getByte(0));
// 将会成功,因为这些操作不会修改相应的索引
assert readerIndex == buf.readerIndex();
assert writerIndex == buf.writerIndex();
read() 和 write()
/**
* ByteBuf 上的 read()和 write()操作
*/
Charset utf8 = Charset.forName("UTF-8");
ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8);
// 打印第一个字符
System.out.println((char)buf.readByte());
// 存储当前的readerIndex
int readerIndex = buf.readerIndex();
// 存储当前的writerIndex
int writerIndex = buf.writerIndex();
// 将字符‘?’追加到缓冲区
buf.writeByte((byte)'?');
assert readerIndex == buf.readerIndex();
// 将会成功,因为writeByte()方法移动了writerIndex
assert writerIndex != buf.writerIndex();
更多操作
ByteBufHolder 接口
在日常开发中,除了需要存储字节数据外,可能还需要存储各种属性值,例如HTTP相应的状态码、cookie等。
ByteBufHolder 为 Netty 的高级特性提供了支持,如缓冲区池化,其中可以从池中借用 ByteBuf,并且在需要时自动释放。如果想要实现一个将其有效负载存储在 ByteBuf 中的消息对象,那么 ByteBufHolder 将是个不错的选择。
按需分配:ByteBufAllocator 接口
Netty 通过 interface ByteBufAllocator 实现了(ByteBuf 的)池化,它可以用来分配我们所描述过的任意类型的 ByteBuf 实例。
可以通过 Channel(每个都可以有一个不同的 ByteBufAllocator 实例)或者绑定到
ChannelHandler 的 ChannelHandlerContext 获取一个到 ByteBufAllocator 的引用。
/**
* 获取一个到 ByteBufAllocator 的引用
*/
Channel channel = ...;
// 从CHannel 获取一个到 ByteBufAllocator的引用
ByteBufAllocator allocator = channel.alloc();
ChannelHandlerContext ctx = ...;
// 从 ChannelHandlerContext获取一个到ByteBufAllocator的引用
ByteBufAllocator allocator2 = ctx.alloc();
Netty提供了两种ByteBufAllocator的实现:PooledByteBufAllocator和UnpooledByteBufAllocator。前者池化了ByteBuf的实例以提高性能并最大限度地减少内存碎片。此实现使用了一种称为jemalloc的已被大量现代操作系统所采用的高效方法来分配内存。后者的实现不池化ByteBuf实例,并且在每次它被调用时都会返回一个新的实例。
Netty默认使用PooledByteBufAllocator,可以通过CHannelConfig API指定不同的分配器。
Unpooled 缓冲区
Unpooled 提供了静态的辅助方法来创建未池化的 ByteBuf 实例。
引用计数
引用计数是一种通过在某个对象所持有的资源不再被其他对象引用时释放该对象所持有的资源来优化内存使用和性能的技术。
Netty 在第 4 版中为 ByteBuf 和 ByteBufHolder 引入了引用计数技术,它们都实现了 interface ReferenceCounted。
一个 ReferenceCounted 实现的实例将通常以活动的引用计数为 1 作为开始。只要引用计数大于 0,就能保证对象不会被释放。当活动引用的数量减少到 0 时,该实例就会被释放。注意,虽然释放的确切语义可能是特定于实现的,但是至少已经释放的对象应该不可再用了。
/**
* 引用计数
*/
Channel channel = ...;
// 从 CHannel 获取 ByteBufAllocator
ByteBufAllocator allocator = channel.alloc();
// 从 ByteBufAllocator 分配一个 ByteBuf
ByteBuf buffer = allocator.directBuffer();
// 检查引用技术是否为预期的1
assert buffer.refCnt() == 1;
/**
* 释放引用计数的对象
*/
ByteBuf buffer = ...;
// 减少到该对象的活动引用。当减少到0时,该对象将被释放,并且返回true
// 试图访问一个已经被释放的引用计数的对象,将会导致一个 IllegalReferenceCountException。
boolean released = buffer.release();