从netty 4开始,netty加入了内存池管理,采用内存池管理比普通的new ByteBuf性能提高了数(2)十倍。相信有些朋友会和我一样,对他的实现方式很感兴趣。这里把学习netty内存池的过程记录下来,与大家一起分享。
首先给大家介绍的是PoolChunk, 该类主要负责内存块的分配与回收,首先来看看两个重要的术语:
page: 可以分配的最小的内存块单位。
chunk: 一堆page的集合。
下面我们用一张图直观的看下PoolChunk是如何管理内存的:
上图中是一个默认大小的chunk, 由2048个page组成了一个chunk,一个page的大小为8192, chunk之上有11层节点,最后一层节点数与page数量相等。每次内存分配需要保证内存的连续性,这样才能简单的操作分配到的内存,因此这里构造了一颗完整的平衡二叉树,所有子节点的管理的内存也属于其父节点。如果我们想获取一个8K的内存,则只需在第11层找一个可用节点即可,而如果我们需要16K的数据,则需要在第10层找一个可用节点。如果一个节点存在一个已经被分配的子节点,则该节点不能被分配,例如我们需要16K内存,这个时候id为2048的节点已经被分配,id为2049的节点未分配,就不能直接分配1024这个节点,因为这个节点下的内存只有8K了。
通过上面这个树结构,我们可以看到每次内存分配都是8K*(2^n), 比如需要24K内存时,实际上会申请到一块32K的内存。为了分配一个大小为chunkSize/(2^k)的内存段,需要在深度为k的层从左开始查找可用节点。如想分配16K的内存,chunkSize = 16M, 则k=10, 需要从第10层找一个空闲的节点分配内存。
如何高效的从这么多page中分配到指定的内存呢。来看看下面这个图:
这个图与上图结构一致,不同的是上方的二叉树的值为当前的层数,两张图和起来用一个数组memoryMap表示,上面的图中的数字表示数组的index,下面的图中的数字表示当前节点及其子节点可以分配的层的高度。如对于id=512的节点,其深度为9,则:
1)memoryMap[512] = 9,则表示其本身到下面所有的子节点都可以被分配;
2)memoryMap[512] = val (从10到11), 则表示512节点下有子节点已经分配过,则该节点不能直接被分配,而其子节点中的第val和val以下层还存在未分配的节点;
3)memoryMap[512] = 12 (即总层数 + 1), 可分配的深度已经大于总层数, 则该节点下的所有子节点都已经被分配。
下面我们在从源码分析下PoolChunk是如何实现的,首先看看它的构造方法:
PoolChunk(PoolArena<T> arena, T memory, int pageSize, int maxOrder, int pageShifts, int chunkSize) {
unpooled = false;
this.arena = arena;
// memory是一个容量为chunkSize的byte[](heap方式)或ByteBuffer(direct方式)
this.memory = memory;
// 每个page的大小,默认为8192
this.pageSize = pageSize;
// 13, 2 ^ 13 = 8192
this.pageShifts = pageShifts;
// 默认11
this.maxOrder = maxOrder;
// 默认 8192 << 11 = 16MB
this.chunkSize = chunkSize;
// 12, 当memoryMap[id] = unusable时,则表示id节点已被分配
unusable = (byte) (maxOrder + 1);
// 24, 2 ^ 24 = 16M
log2ChunkSize = log2(chunkSize);
// -8192
subpageOverflowMask = ~(pageSize - 1);
freeBytes = chunkSize;
assert maxOrder < 30 : "maxOrder should be < 30, but is: " + maxOrder;
// 2048, 最多能被分配的Subpage个数
maxSubpageAllocs = 1 << maxOrder;
// Generate the memory map.
memoryMap = new byte[maxSubpageAllocs << 1];
depthMap = new byte[memoryMap.length];
int memoryMapIndex = 1;
// 分配完成后,memoryMap->[0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3…]
// depthMap->[0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3…]
for (int d = 0; d <= maxOrder; ++ d) { // move down the tree one level at a time
int depth = 1 << d;
for (int p = 0; p < depth; ++ p) {
// in each level traverse left to right and set value to the depth of subtree
memoryMap[memoryMapIndex] = (byte) d;
depthMap[memoryMapIndex] = (byte) d;
memoryMapIndex ++;
}
}
// subpages包含了maxSubpageAllocs(2048)个PoolSubpage, 每个subpage会从chunk中分配到自己的内存段,两个subpage不会操作相同的段,此处只是初始化一个数组,还没有实际的实例化各个元素
subpages = newSubpageArray(maxSubpageAllocs);
}
需要注意的是由于memoryMap表示的树中一个包含2 ^ maxOrde - 1个节点,因此memoryMap中的其中一个节点是无用的,为了方便后续的计算,这里将第一个节点作为无用的节点,这样从父节点计算左子结点只需要简单的*2即可,即left node id = parent id << 1。随着内存不断的分配和回收,memoryMap中的值也不停的更新,而depthMap中保存的值表示各个id对应的深度,是个固定值,初始化后不再变更。
下面看看如何向PoolChunk申请一段内存:
// normCapacity已经处理过
long allocate(int normCapacity) {
if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
// 大于等于pageSize时返回的是可分配normCapacity的节点的id
return allocateRun(normCapacity);
} else {
// 小于pageSize时返回的是一个特殊的long型handle
// handle = 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
// 与上面直接返回节点id的逻辑对比可以知道,当handle<Integer.MAX_VALUE时,它表示chunk的节点id;
// 当handle>Integer.MAX_VALUE,他分配的是一个Subpage,节点id=memoryMapIdx, 且能得到Subpage的bitmapIdx,bitmapIdx后面会讲其用处
return allocateSubpage(normCapacity);
}
}
allocateRun与allocateSubpage都需要查询到一个可用的id, allocateSubpage相对allocateRun多出分配PoolSubpage的步骤,下面先看看allocateRun的实现:
private long allocateRun(int normCapacity) {
// log2(val) -> Integer.SIZE - 1 - Integer.numberOfLeadingZeros(val)
// 如normCapacity=8192,log2(8192)=13,d=11
int d = maxOrder - (log2(normCapacity) - pageShifts);
// 通过上面一行算出对应大小需要从那一层分配,得到d后,调用allocateNode来获取对应id
int id = allocateNode(d);
if (id < 0) {
return id;
}
// runLenth=id所在深度占用的字节数
freeBytes -= runLength(id);
return id;
}
allocateNode(int d)传入的参数为depth, 通过depth来搜索对应层级第一个可用的node id。下面看allocateNode的实现:
private int allocateNode(int d) {
int id = 1;
// 如d=11,则initial=-2048
int initial = - (1 << d); // has last d bits = 0 and rest all = 1
// value(id)=memoryMap[id]
byte val = value(id);
// 第一层的节点的值大于d,表示d层及以上都不可分配,此次分配失败
if (val > d) { // unusable
return -1;
}
// 这里从第二层开始从上往下查找, 一直找到指定层
while (val < d || (id & initial) == 0) { // id & initial == 1 << d for all ids at depth d, for < d it is 0
// 往下一层
id <<= 1;
val = value(id);
// 上面对一层节点的值判断已经表示有可用内存可分配,因此发现当前节点不可分配时,
// 直接切换到父节点的另一子节点,即id ^= 1
if (val > d) {
id ^= 1;
val = value(id);
}
}
byte value = value(id);
assert value == d && (id & initial) == 1 << d : String.format("val = %d, id & initial = %d, d = %d",
value, id & initial, d);
// 该节点本次分配成功,将其标为不可用
setValue(id, unusable); // mark as unusable
// 更新父节点的值
updateParentsAlloc(id);
return id;
}
上面有几个需要注意的点:1、分配的节点在d层;2、每一层都可能遇到节点被分配的情况,此时需要切换到父节点的另一个子节点继续往下查找;3、分配的节点需要标记为不可用,防止后面再被分配;4、分配一个节点后,其父节点的值会发生变更,并可能引起更上层的父节点的变更。下面我们来举个例子看看第4点(depth=10节点的子节点从2个可用变为1个可用的情况):
1)parent的depth=10,表示该层及所有子层可直接分配;
2)left节点被分配,其depth=12, 不能再被分配;
3)由于left节点已经被分配,此时parent节点已经不能直接分配,但还存在一个可分配节点right,此时parent的depth被设置为right的值11;
4)parent不可分配,则其以上的所有父节点的状态可能也会发生变化,此时需要递归的修改更上层parent的值,及指针上移后重复上面的操作。
private void updateParentsAlloc(int id) {
while (id > 1) {
int parentId = id >>> 1;
byte val1 = value(id);
byte val2 = value(id ^ 1);
// 得到左节点和右节点较小的值赋给父节点,即两个节点只要有一个可分配,则父节点的值设为可分配的这个节点的值
byte val = val1 < val2 ? val1 : val2;
setValue(parentId, val);
id = parentId;
}
}
到这里一个节点的分配就完成了,但是还有另一种情况,即分配的ByteBuf大小小于pageSize, 这种情况会调用allocateSubpage方法:
private long allocateSubpage(int normCapacity) {
int d = maxOrder; // subpages are only be allocated from pages i.e., leaves
int id = allocateNode(d);
if (id < 0) {
return id;
}
final PoolSubpage<T>[] subpages = this.subpages;
final int pageSize = this.pageSize;
freeBytes -= pageSize;
// 包含数据的节点在最后一层,而最后一层的左边第一个节点index=2048,因此若id=2048则subpageIdx=0,id=2049,subpageIdx=1
// 根据这个规律找到对应位置的PoolSubpage
int subpageIdx = subpageIdx(id);
PoolSubpage<T> subpage = subpages[subpageIdx];
if (subpage == null) {
// 如果PoolSubpage未创建则创建一个,创建时传入当前id对应的offset,pageSize,本次分配的大小normCapacity
subpage = new PoolSubpage<T>(this, id, runOffset(id), pageSize, normCapacity);
subpages[subpageIdx] = subpage;
} else {
// 已经创建则初始化数据
subpage.<span style="color:#009900;">init</span>(normCapacity);
}
// 调用此方法得到一个可以操作的handle
return subpage.<span style="color:#009900;">allocate</span>();
}
这里可以看到,在小size的对象分配时会先分配一个PoolSubpage,最终返回一个包含该PoolSubpage信息的handle,后面的操作也是通过此handle进行操作。在看如何操作内存之前,我们先来看看分配一个节点后如何释放(比较简单,所以先讲这里),其对应的方法:
void free(long handle) {
// 传入的handle即allocate得到的handle
int memoryMapIdx = (int) handle;
int bitmapIdx = (int) (handle >>> Integer.SIZE);
if (bitmapIdx != 0) { // free a subpage
// !=0表示分配的是一个subpage
PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
assert subpage != null && subpage.doNotDestroy;
if (subpage.free(bitmapIdx & 0x3FFFFFFF)) {//subpage释放方法后面再讲
return;
}
}
freeBytes += runLength(memoryMapIdx);
// 将节点的值改为可用,及其depth
setValue(memoryMapIdx, depth(memoryMapIdx));
// 修改其父节点的值
updateParentsFree(memoryMapIdx);
}
这里会将节点的值改为可用,完成后该节点就可以再次被分配了,此时父节点的值还未更新,可能会导致分配节点时无法访问到此节点,因此还需要同时改变其父节点的值,
private void updateParentsFree(int id) {
int logChild = depth(id) + 1;
while (id > 1) {
int parentId = id >>> 1;
byte val1 = value(id);
byte val2 = value(id ^ 1);
logChild -= 1; // in first iteration equals log, subsequently reduce 1 from logChild as we traverse up
if (val1 == logChild && val2 == logChild) {
//当两个子节点都可分配时,该节点变回自己所在层的depth,表示该节点也可被分配
setValue(parentId, (byte) (logChild - 1));
} else {
// 否则与上面的updateParentsAlloc逻辑相同
byte val = val1 < val2 ? val1 : val2;
setValue(parentId, val);
}
id = parentId;
}
}
PoolChunk本身主要是负责节点的分配与释放,因此节点的分配与释放都了解了对这个类的了解就已经差不多了。但这里还有几个方法没讲到,这几个方法是干什么用的呢,下面我们来看看:
void initBuf(PooledByteBuf<T> buf, long handle, int reqCapacity) {
int memoryMapIdx = (int) handle;
int bitmapIdx = (int) (handle >>> Integer.SIZE);
if (bitmapIdx == 0) {
// 到这里表示分配的是>=pageSize的数据
byte val = value(memoryMapIdx);
assert val == unusable : String.valueOf(val);
buf.init(this, handle, runOffset(memoryMapIdx), reqCapacity, runLength(memoryMapIdx));
} else {
// 到这里表示分配的是<pageSize的数据
initBufWithSubpage(buf, handle, bitmapIdx, reqCapacity);
}
}
void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int reqCapacity) {
initBufWithSubpage(buf, handle, (int) (handle >>> Integer.SIZE), reqCapacity);
}
private void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int bitmapIdx, int reqCapacity) {
assert bitmapIdx != 0;
int memoryMapIdx = (int) handle;
PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
assert subpage.doNotDestroy;
assert reqCapacity <= subpage.elemSize;
buf.init(
this, handle,
runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize, reqCapacity, subpage.elemSize);
}
通过allocate拿到了handle,相当于得到了一把钥匙,现在就可以拿这把钥匙开启后面的操作了。首先我们拿handle+buf+reqCapacity几个参数来进行实际的分配,前面讲过handle有两种含义,1、handle<Integer.MAX_VALUE, 表示一个node id; 2、handle>Integer.MAX_VALUE, 则里面包含node id + 对应的subpage的bitmapIdx。最终传递给PooledByteBuf的信息包括,PoolChunk中的memory: 完整的byte数组或ByteBuf;handle:表示node id的数值,释放内存段的时候使用这个参数; offset:该PooledByteBuf可操作memory的第一个位置; length:该PooledByteBuf本次申请的长度;maxLength:该PooledByteBuf最大可用的长度。PooledByteBuf通过这些参数完成自身的初始化后,就可以开始实际的读写了,它的可读写区域就是memory数组的offset 到 offset + length, 如果写入的数据超过length, 可以扩容至maxLength, 即可读写的区域变为offset 到 offset + maxLength, 如果超过maxLength, 则需要重新申请数据块了,这个后面再说。
到这里PoolChunk的功能基本上已经讲完,下面来回顾下PoolChunk的简单使用过程(真实的使用比较复杂,后面再讲):
1) 初始化PoolChunk,初始时传入分配好的内存块memory(heap或direct),通过maxOrder构造完整的平衡二叉树,并且将对应值存入memoryMap;
2) 应用获取一个大小为size0的内存段,通过调用PoolChunk.allocate方法获取到对应的二叉树节点id并转换为handle,
如果size0 >= 8192, handle=id,如果size0 < 8192, handle = 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
获取完成后更新memoryMap, 保证该节点不会重复分配,同时其父节点的可分配状况也级联的改变。
3) 通过上一步获取到的handle来初始化应用的ByteBuf,调用的方法为PoolChunk.initBuf,ByteBuf会接收memory, handle, offset, length, maxLengh;
4) 应用对ByteBuf进行写入读取等操作(可能会触发扩容等操作,后面的文章会深入讲;
5) 应用使用完ByteBuf后释放它,ByteBuf释放时调用PoolChunk.free,传入的参数为handle,PoolChunk将handle对应的节点置为可分配,同时改变其父节点的状态。
该节点可以再次分配了(其父节点也有可能被分配了)。
6)下次应用获取内存段时再次进入第二步。
需要注意的是PoolChunk提供了内存池的功能,然后它的所有方法都是线程不安全的,因此需要应用保证其可以被安全的访问,否则会导致节点被重复分配等问题;ByteBuf的设计使内存不使用后不需要手动的将数据置0,直接重置节点状态即可;申请小内存后会通过PoolSubpage进行管理,为什么这么做,下一篇再慢慢讲,回家先。