初识内存分配ByteBuf
初识ByteBuf:
ByteBuf 是Netty 整个结构里面最为底层的模块,主要负责把数据从底层IO 里面读到ByteBuf,然后传递给应用程序,应用程序处理完成之后再把数据封装成ByteBuf 写回到IO。所以,ByteBuf 是直接与底层打交道的一层抽象。这块内容,相对于Netty 其他模块来说,是非常复杂的。从不同角度来分析ByteBuf 的分配和回收。主要从内存与内存管理器的抽象、不同规格大小和不同类别的内存的分配策略以及内存的回收过程来展开。
ByteBuf 的基本结构:
我们可以首先来看一下源码中对ByteBuf 的描述如下:
从上面ByteBuf 的结构来看,我们发现ByteBuf 有三个非常重要的指针,分别是readerIndex(记录读指针的开始位置)、writerIndex(记录写指针的开始位置)和capacity(缓冲区的总长度),三者的关系是readerIndex<=writerIndex<=capacity。然后,从0 到readerIndex 为discardable bytes 表示是无效的,从readerIndex 到writerIndex 为readablebytes 表示可读数据区,从writerIndex 到capacity 为writable bytes 表示这段区间空闲可以往里面写数据。除了这三个指针,其实ByteBuf 里面还有一个maxCapacity,这就相当于是ByteBuf 扩容的最大阈值,我们看它的源码中有定义:
/**返回此缓冲区的最大允许容量。此值提供一个上限
* Returns the maximum allowed capacity of this buffer. This value provides an upper
* bound on {@link #capacity()}
*/
public abstract int maxCapacity();
这个指针可以看做是capactiy 之后的这段,当Netty 发现writable bytes 写数据超出空间大小时,ByteBuf 会提前帮我们自动扩容,扩容之后,就有了足够的空间来写数据,同时capactiy 也会同步更新,maxCapacity 就是扩容后capactiy的最大值。
ByteBuf 的重要API:
接下来我们来看ByteBuf 的基本API,主要包括read()、write()、set()以及mark()、reset()方法。我们用下面的表格对ByteBuf 最重要的API 做一个详细说明:
- readByte():从当前readerIndex 指针开始往后读1 个字节的数据并移动readerIndex,将数据转化为byte。
- readUnsignedByte() :读取一个无符号的byte 数据。
- readShort(): 从当前readerIndex 指针开始往后读2 个字节的数据并移动readerIndex,将数据转化为short。
- readInt(): 从当前readerIndex 指针开始往后读4 个字节的数据并移动readerIndex,将数据转化为int。
- readLong() :从当前readerIndex 指针开始往后读8 个字节的数据并移动readerIndex,将数据转化为long。
- writeByte() :从当前writerIndex 指针开始往后写1 个字节的数据并移动writerIndex。
- setByte() :将byte 数据写入到指定位置,不移动writerIndex。
- markReaderIndex() :在读数据之前,将readerIndex 的状态保存起来,方便在读完数据之后将readerIndex 复原。
- resetReaderIndex() :将readerIndex 复原到调用markReaderIndex()之后的状态。
- markWriterIndex() :在写数据之前,将writerIndex 的状态保存起来,方便在读完数据之后将writerIndex 复原。
- resetWriterIndex() :将writerIndex 复原到调用markWriterIndex()之后的状态。
- readableBytes(): 获取可读数据区大小,相当于获取当前writerIndex 减去readerIndex 的值。
- writableBytes(): 获取可写数据区大小,相当于获取当前capactiy 减去writerIndex 的值。
- maxWritableBytes(): 获取最大可写数据区的大小,相当于获取当前maxCapactiy 减去writerIndex 的值。
在Netty 中,ByteBuf 的大部分功能是在AbstractByteBuf 中来实现的,我们可以先进入AbstractByteBuf 的源码看看:
public abstract class AbstractByteBuf extends ByteBuf { ......
int readerIndex;//读指针 int writerIndex;//写指针 private int markedReaderIndex;//mark 之后的读指针 private int markedWriterIndex;//mark 之后的写指针 private int maxCapacity;//最大容量
......
}
最重要的几个属性readerIndex、writerIndex、markedReaderIndex、markedWriterIndex、maxCapacity 被定义在AbstractByteBuf 这个抽象类中,下面我们可以来看看基本读写的骨架代码实现。例如,几个基本的判断读写区间的API,我们来看一下它的具体实现:
@Override
public boolean isReadable() {return writerIndex > readerIndex;}
@Override
public boolean isReadable(int numBytes) {return writerIndex - readerIndex >= numBytes;}
@Override
public boolean isWritable() {return capacity() > writerIndex;}
@Override
public boolean isWritable(int numBytes) {return capacity() - writerIndex >= numBytes;}
@Override
public int readableBytes() {return writerIndex - readerIndex;}
@Override
public int writableBytes() {return capacity() - writerIndex;}
@Override
public int maxWritableBytes() {return maxCapacity() - writerIndex;}
@Override
public ByteBuf markReaderIndex() {
markedReaderIndex = readerIndex;
return this;
}
@Override
public ByteBuf resetReaderIndex() {
readerIndex(markedReaderIndex);
return this;
}
@Override
public ByteBuf markWriterIndex() {
markedWriterIndex = writerIndex;
return this;
}
@Override
public ByteBuf resetWriterIndex() {
writerIndex(markedWriterIndex);
return this;
}
//再来看几个读写操作的API,具体源码如下:
@Override
public byte readByte() {
checkReadableBytes0(1);
int i = readerIndex;
byte b = _getByte(i);
readerIndex = i + 1;
return b;
}
@Override
public ByteBuf writeByte(int value) {
ensureWritable0(1);
_setByte(writerIndex++, value);
return this;
}
@Override
public byte getByte(int index) {
checkIndex(index);
return _getByte(index);
}
protected abstract byte _getByte(int index);
protected abstract void _setByte(int index, int value);
我们看到,上面的代码中readByte()方法和getByte()方法都调用了一个抽象的_getByte(),这个方法在AbstractByteBuf的子类中实现。在writeByte()方法中有调用一个抽象的_setByte()方法,这个方法同样也是在子类中实现。
ByteBuf 的基本分类:
AbstractByteBuf 之下有众多子类,大致可以从三个维度来进行分类,分别如下:
- Pooled:池化内存,就是从预先分配好的内存空间中提取一段连续内存封装成一个ByteBuf 分给应用程序使用。
- Unsafe:是JDK 底层的一个负责IO 操作的对象,可以直接拿到对象的内存地址,基于内存地址进行读写操作。
- Direct:堆外内存,是直接调用JDK 的底层API 进行物理内存分配,不在JVM 的堆内存中,需要手动释放。
综上所述,其实ByteBuf 一共会有六种组合:Pooled 池化内存和Unpooled 非池化内存;Unsafe 和非Unsafe;Heap堆内存和Direct 堆外内存。下图是ByteBuf 最重要的继承关系类结构图,通过命名就能一目了然:
ByteBuf 最基本的读写API 操作在AbstractByteBuf 中已经实现了,其众多子类采用不同的策略来分配内存空间,下面对重要的几个子类总结如下:
- PooledHeapByteBuf :池化的堆内缓冲区
- PooledUnsafeHeapByteBuf :池化的Unsafe 堆内缓冲区
- PooledDirectByteBuf :池化的直接(堆外)缓冲区
- PooledUnsafeDirectByteBuf :池化的Unsafe 直接(堆外)缓冲区
- UnpooledHeapByteBuf :非池化的堆内缓冲区
- UnpooledUnsafeHeapByteBuf :非池化的Unsafe 堆内缓冲区
- UnpooledDirectByteBuf :非池化的直接(堆外)缓冲区
- UnpooledUnsafeDirectByteBuf :非池化的Unsafe 直接(堆外)缓冲区
下面这段代码演示了ByteBuf的创建以及内容的打印,这里显示出了和普通ByteBuffer最大的区别之 一,就是ByteBuf可以自动扩容,默认长度是256,如果内容长度超过阈值时,会自动触发扩容
public class ByteBufExample {
public static void main(String[] args) {
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();//可自动扩容
log(buf);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 32; i++) { //演示的时候,可以把循环的值扩大,就能看到扩容效果
sb.append(" - " + i);
}
buf.writeBytes(sb.toString().getBytes());
log(buf);
}
private static void log(ByteBuf buf) {
StringBuilder builder = new StringBuilder()
.append(" read index:").append(buf.readerIndex()) //获取读索引
.append(" write index:").append(buf.writerIndex()) //获取写索引
.append(" capacity:").append(buf.capacity()) //获取容量
.append(StringUtil.NEWLINE);
//把ByteBuf中的内容,dump到StringBuilder中
ByteBufUtil.appendPrettyHexDump(builder, buf);
System.out.println(builder.toString());
}
}
ByteBuf创建的方法有两种:
第一种,创建基于堆内存的ByteBuf
ByteBuf buffer=ByteBufAllocator.DEFAULT.heapBuffer(10);
第二种,创建基于直接内存(堆外内存)的ByteBuf(默认情况下用的是这种)
Java中的内存分为两个部分,一部分是不需要jvm管理的直接内存,也被称为堆外内存。堆 外内存就是把内存对象分配在JVM堆意外的内存区域,这部分内存不是虚拟机管理,而是由 操作系统来管理,这样可以减少垃圾回收对应用程序的影响
ByteBufAllocator.DEFAULT.directBuffer(10);
直接内存的好处是读写性能会高一些,如果数据存放在堆中,此时需要把Java堆空间的数据发送到 远程服务器,首先需要把堆内部的数据拷贝到直接内存(堆外内存),然后再发送。如果是把数据 直接存储到堆外内存中,发送的时候就少了一个复制步骤。
但是它也有缺点,由于缺少了JVM的内存管理,所以需要我们自己来维护堆外内存,防止内存溢 出。
另外,需要注意的是,ByteBuf默认采用了池化技术来创建。池化技术的核心思想是实现对象的复用,从而减少对象频繁创建销毁带来的性能开销。
池化功能是否开启,可以通过下面的环境变量来控制,其中unpooled表示不开启。
-Dio.netty.allocator.type={unpooled|pooled}
ByteBuf的存储结构
ByteBuf的存储结构如图所示,从这个图中可以看到ByteBuf其实是一个字节容器,该容器中包含三 个部分
- 已经丢弃的字节,这部分数据是无效的
- 可读字节,这部分数据是ByteBuf的主体数据,从ByteBuf里面读取的数据都来自这部分;
- 可写字节,所有写到ByteBuf的数据都会存储到这一段 可扩容字节,表示ByteBuf最多还能扩容多少容量。
在ByteBuf中,有两个指针
- readerIndex: 读指针,每读取一个字节,readerIndex自增加1。ByteBuf里面总共有witeIndexreaderIndex个字节可读,当readerIndex和writeIndex相等的时候,ByteBuf不可读
- writeIndex: 写指针,每写入一个字节,writeIndex自增加1,直到增加到capacity后,可以触发 扩容后继续写入。
- ByteBuf中还有一个maxCapacity最大容量,默认的值是 Integer.MAX_VALUE ,当ByteBuf写入数 据时,如果容量不足时,会触发扩容,直到capacity扩容到maxCapacity。
ByteBuf中常用的方法:
对于ByteBuf来说,常见的方法就是写入和读取
Write相关方法
对于write方法来说,ByteBuf提供了针对各种不同数据类型的写入,比如 writeChar,写入char类型 writeInt,写入int类型 writeFloat,写入float类型 writeBytes, 写入nio的ByteBuffer writeCharSequence, 写入字符串
public class ByteBufExample {
public static void main(String[] args) {
ByteBuf buf= ByteBufAllocator.DEFAULT.heapBuffer();//可自动扩容
buf.writeBytes(new byte[]{1,2,3,4}); //写入四个字节
log(buf);
buf.writeInt(5); //写入一个int类型,也是4个字节
log(buf);
}
private static void log(ByteBuf buf){
System.out.println(buf);
StringBuilder builder=new StringBuilder()
.append(" read index:").append(buf.readerIndex())
.append(" write index:").append(buf.writerIndex())
.append(" capacity:").append(buf.capacity())
.append(StringUtil.NEWLINE);
//把ByteBuf中的内容,dump到StringBuilder中
ByteBufUtil.appendPrettyHexDump(builder,buf);
System.out.println(builder.toString());
}
}
扩容
当向ByteBuf写入数据时,发现容量不足时,会触发扩容,而具体的扩容规则是 假设ByteBuf初始容量是10。
- 如果写入后数据大小未超过512个字节,则选择下一个16的整数倍进行库容。 比如写入数据后大 小为12,则扩容后的capacity是16。
- 如果写入后数据大小超过512个字节,则选择下一个2 n。 比如写入后大小是512字节,则扩容后的 capacity是2 10=1024 。(因为2 9=512,长度已经不够了)
- 扩容不能超过max capacity,否则会报错。
Reader相关方法
reader方法也同样针对不同数据类型提供了不同的操作方法, readByte ,读取单个字节 readInt , 读取一个int类型 readFloat ,读取一个float类型
public class ByteBufExample {
public static void main(String[] args) {
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();//可自动扩容
buf.writeBytes(new byte[]{1,2,3,4});
log(buf);
System.out.println(buf.readByte());
log(buf);
}
private static void log(ByteBuf buf){
StringBuilder builder=new StringBuilder()
.append(" read index:").append(buf.readerIndex())
.append(" write index:").append(buf.writerIndex())
.append(" capacity:").append(buf.capacity())
.append(StringUtil.NEWLINE);
//把ByteBuf中的内容,dump到StringBuilder中
ByteBufUtil.appendPrettyHexDump(builder,buf);
System.out.println(builder.toString());
}
}
从下面结果中可以看到,读完一个字节后,这个字节就变成了废弃部分,再次读取的时候只能读取未读取的部分数据。
另外,如果想重复读取哪些已经读完的数据,这里提供了两个方法来实现标记和重置
public static void main(String[] args) {
ByteBuf buf= ByteBufAllocator.DEFAULT.heapBuffer();//可自动扩容
buf.writeBytes(new byte[]{1,2,3,4,5,6,7});
log(buf);
buf.markReaderIndex(); //标记读取的索引位置
System.out.println(buf.readInt());
log(buf);
buf.resetReaderIndex();//重置到标记位
System.out.println(buf.readInt());
log(buf);
}
另外,如果想不改变读指针位置来获得数据,在ByteBuf中提供了 get 开头的方法,这个方法基于索引 位置读取,并且允许重复读取的功能。
ByteBuf的零拷贝机制:
需要说明一下,ByteBuf的零拷贝机制和操作系统层面的零拷贝不同,操作系统层面的 零拷贝,是我们要把一个文件发送到远程服务器时,需要从内核空间拷贝到用户空间,再从用户空间拷 贝到内核空间的网卡缓冲区发送,导致拷贝次数增加。 而ByteBuf中的零拷贝思想也是相同,都是减少数据复制提升性能。如图所示,假设有一个原始 ByteBuf,我们想对这个ByteBuf其中的两个部分的数据进行操作。按照正常的思路,我们会创建两个 新的ByteBuf,然后把原始ByteBuf中的部分数据拷贝到两个新的ByteBuf中,但是这种会涉及到数据拷贝,在并发量较大的情况下,会影响到性能。
ByteBuf中提供了一个slice方法,这个方法可以在不做数据拷贝的情况下对原始ByteBuf进行拆分,使 用方法如下
public static void main(String[] args) {
ByteBuf buf= ByteBufAllocator.DEFAULT.buffer();//可自动扩容
buf.writeBytes(new byte[]{1,2,3,4,5,6,7,8,9,10});
log(buf);
ByteBuf bb1=buf.slice(0,5);
ByteBuf bb2=buf.slice(5,5);
log(bb1);
log(bb2);
System.out.println("修改原始数据");
buf.setByte(2, 5); //修改原始buf数据
log(bb1);//再打印bb1的结果,发现数据发生了变化
}
在上面的代码中,通过slice对原始buf进行切片,每个分片是5个字节。 为了证明slice是没有数据拷贝,我们通过修改原始buf的索引2所在的值,然后再打印第一个分片bb1, 可以发现bb1的结果发生了变化。说明两个分片和原始buf指向的数据是同一个。
Unpooled:
在前面的案例中我们经常用到Unpooled工具类,它是同了非池化的ByteBuf的创建、组合、复制等操 作。 假设有一个协议数据,它有头部和消息体组成,这两个部分分别放在两个ByteBuf中
ByteBuf header=...
ByteBuf body= ...
ByteBuf allBuf=Unpooled.buffer(header.readableBytes()+body.readableBytes());
allBuf.writeBytes(header);
allBuf.writeBytes(body);
在这个过程中,我们把header和body拷贝到了新的allBuf中,这个过程在无形中增加了两次数据拷贝操 作。那有没有更高效的方法减少拷贝次数来达到相同目的呢? 在Netty中,提供了一个CompositeByteBuf组件,它提供了这个功能。
public static void main(String[] args) {
ByteBuf header= ByteBufAllocator.DEFAULT.buffer();//可自动扩容
header.writeCharSequence("header", CharsetUtil.UTF_8);
ByteBuf body=ByteBufAllocator.DEFAULT.buffer();
body.writeCharSequence("body", CharsetUtil.UTF_8);
CompositeByteBuf compositeByteBuf= Unpooled.compositeBuffer();
//其中第一个参数是 true, 表示当添加新的 ByteBuf 时, 自动递增 CompositeByteBuf的 writeIndex.
//默认是false,也就是writeIndex=0,这样的话我们不可能从compositeByteBuf中读取到数据。
compositeByteBuf.addComponents(true,header,body);
log(compositeByteBuf);
}
private static void log(ByteBuf buf){
StringBuilder builder=new StringBuilder()
.append(" read index:").append(buf.readerIndex())
.append(" write index:").append(buf.writerIndex())
.append(" capacity:").append(buf.capacity())
.append(StringUtil.NEWLINE);
//把ByteBuf中的内容,dump到StringBuilder中
ByteBufUtil.appendPrettyHexDump(builder,buf);
System.out.println(builder.toString());
}
之所以CompositeByteBuf能够实现零拷贝,是因为在组合header和body时,并没有对这两个数据进 行复制,而是通过CompositeByteBuf构建了一个逻辑整体,里面仍然是两个真实对象,也就是有一个 指针指向了同一个对象,所以这里类似于浅拷贝的实现。
wrappedBuffer:
在Unpooled工具类中,提供了一个wrappedBuffer方法,来实现CompositeByteBuf零拷贝功能。使 用方法如下。
ByteBuf total=Unpooled.wrappedBuffer(header,body);
copiedBuffer:
copiedBuffer 和wrappedBuffer最大的区别是,该方法会实现数据复制 ,修改了原始ByteBuf的值, 并没有影响到原来的引用。
内存释放:
针对不同的ByteBuf创建,内存释放的方法不同。
- UnpooledHeapByteBuf,使用JVM内存,只需要等待GC回收即可
- UnpooledDirectByteBuf,使用对外内存,需要特殊方法来回收内存
- PooledByteBuf和它的之类使用了池化机制,需要更复杂的规则来回收内存
如果ByteBuf是使用堆外内存来创建,那么尽量手动释放内存,那怎么释放呢? Netty采用了引用计数方法来控制内存回收,每个ByteBuf都实现了ReferenceCounted接口。 每个ByteBuf对象的初始计数为1 调用release方法时,计数器减一,如果计数器为0,ByteBuf被回收 调用retain方法时,计数器加一,表示调用者没用完之前,其他handler即时调用了release也不会 造成回收。 当计数器为0时,底层内存会被回收,这时即使ByteBuf对象还存在,但是它的各个方法都无法正 常使用