上篇文章中对Netty的内存分配进行大体的阐述,提到了Netty内存分配通过Arena来进行。
1. PoolChunk
Netty一次向系统申请16M的连续内存空间,这块内存通过PoolChunk对象包装,为了更细粒度的管理(分配和释放)它,进一步的把这16M内存分成了2048个页(PageSize=8k)。通过平衡二叉树将Page联系起来,所有子节点管理的内存也属于其父节点。
1.1 Buddy算法
Netty实现的伙伴分配算法中,构造了两棵满二叉树,满二叉树使用数组存储,Netty使用两个字节数组memoryMap和depthMap来表示,其中MemoryMap存放分配信息,depthMap存放节点的高度信息。
两个二叉树经过初始化后变为一样,都是当前层高度,depthMap不在改变,每次分配内存只修改memoryMap用来记录分配信息。当一个节点被分配以后,该节点的值设置为12(最大高度+1)表示不可用,并且会更父节点的值,直至根节点。如下例子:
上图中每一个节点中有三个数字,第一个表示编号,即数组的下标,注意下标是从1开始,第二个表示对应节点memoryMap的值,第三个表示对应节点depthMap的值。
分配过程如下:
- 从根节点开始寻找合适的节点
- 假如4号节点被完全分配,将高度值设置为12表示不可用。
- 4号节点的父亲节点即2号节点,将高度值更新为两个子节点的较小值;其他祖先节点亦然,直到高度值更新至根节点。
memoryMap数组的值有如下三种情况:
- memoryMap[id] = depthMap[id] :该节点没有被分配
- memoryMap[id] > depthMap[id] : 至少有一个子节点被分配,不能再分配该高度满足的内存,但可以根据实际分配较小一些的内存。比如,上图中分配了4号子节点的2号节点,值从1更新为2,表示该节点不能再分配8MB的只能最大分配4MB内存,因为分配了4号节点后只剩下5号节点可用。
- mempryMap[id] = 最大高度 + 1(12): 该节点及其子节点已被完全分配, 没有剩余空间。
ok清楚了Buyddy算法的主要逻辑,PoolChunk代码看起来就轻松多了~
1.2 PoolChunk初始化
该类有两个构造方法,一个用于普通初始化,另一个用于非池化初始化(Huge分配请求)。
PoolChunk(PoolArena<T> arena, T memory, int pageSize, int maxOrder, int pageShifts, int chunkSize, int offset) {
unpooled = false;
this.arena = arena;// 表示该PoolChunk所属的PoolArena。
this.memory = memory;// 具体用来表示内存;byte[]或java.nio.ByteBuffer。
this.pageSize = pageSize;//每个page的大小,默认为8192个字节(8K)
this.pageShifts = pageShifts;// 从1开始左移到页大小的位置,默认13,1<<13 = 8192
this.maxOrder = maxOrder;// 最大高度,默认11
this.chunkSize = chunkSize; // chunk块大小,默认16MB
this.offset = offset;// 暂时没用用到,初始值为0
unusable = (byte) (maxOrder + 1);// 不可用的二叉树深度12
log2ChunkSize = log2(chunkSize);// log2(16MB) = 24
subpageOverflowMask = ~(pageSize - 1); // 判断分配请求为Tiny/Small即分配subpage
freeBytes = chunkSize;// 可分配字节数
maxSubpageAllocs = 1 << maxOrder; // 可分配subpage的最大节点数即11层节点数,默认2048
// 构造两棵二叉树
memoryMap = new byte[maxSubpageAllocs << 1];
depthMap = new byte[memoryMap.length];
int memoryMapIndex = 1; // 注意下标是 1 开始,下标为0的不使用
for (int d = 0; d <= maxOrder; ++ d) { // 遍历每层,
int depth = 1 << d; // depth是每层的节点数
for (int p = 0; p < depth; ++ p) { // 为每一层进行进行初始化,值为当前层数
memoryMap[memoryMapIndex] = (byte) d;
depthMap[memoryMapIndex] = (byte) d;
memoryMapIndex ++;
}
}
// 创建PoolSubpage数组,只有分配小于pageSize的内存才能用到该数组
subpages = newSubpageArray(maxSubpageAllocs);
}
1.3 分配过程
下面看看如何向PoolChunk申请一块内存区域,allocate函数的代码如下;
long allocate(int normCapacity) {
if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
return allocateRun(normCapacity);
} else {
return allocateSubpage(normCapacity);
}
}
- 当需要分配的内存大于等于pageSize时,通过调用allocateRun函数实现内存分配
- 当需要分配的内存小于pageSize时,通过调用allocateSubpage函数实现内存分配
每个Page会被切分成大小相同的多个存储块,存储块的大小由第一次申请的内存块大小决定。第一次申请的时1K,则这个Page就会被分成8个存储块。多个存储块通过链表连接起来。
private long allocateRun(int normCapacity) {
// 计算满足需求的节点的高度
int d = maxOrder - (log2(normCapacity) - pageShifts);
// 在该高度层找到空闲的节点
int id = allocateNode(d);
if (id < 0) {
return id;
}
// 分配后剩余的字节数
freeBytes -= runLength(id);
return id;
}
比如申请16kb的内存大小,log2(1024*16) = 14,pageShifts=13, 最终d = 10,因此就在第10层寻找。继续跟进allocateNode的计算,入参是深度d。
private int allocateNode(int d) {
int id = 1; // 初始的节点编号即下标,值为0
int initial = - (1 << d); // d层第一个节点负数,用于判断是都处于d层
byte val = value(id); // memoryMap[id]
if (val > d) { // 节点的值(深度)大于d,则表示不可用
return -1;
}
while (val < d || (id & initial) == 0) { val<d 子节点可分配内存,id & initial == 0 高度<d
id <<= 1; // 进入下一层,id为下一层的左子节点下标
val = value(id); // 取到左子节点的值
if (val > d) { // 如果左子节点被占用
id ^= 1; // 位异或运算,偶数n与1异或结果为n+1,目的是找到兄弟节点得编号
val = value(id); // 取到兄弟节点值
}
}
byte value = value(id);
setValue(id, unusable); // 并标记为不可用,即赋值当前节点的值为12
updateParentsAlloc(id); // 跟新父节点的值
return id;
}
算法用到了大量的位运算,比如第一次分配4M的内存,因此如入参d为2,id为1,深度(val)等于0,进入循环。
$loop 1
val = 0,id = 1, initial = -4 initial & id=0
id = 2, val = 1, d = 2 进入#loop 2
$loop 2
val = 1, id = 2, initial = -4 initial & id=0
id = 4, val = 2, d = 2 进入#loop3
$loop 3
val < d 不符合跳出循环
更新祖先节点的分配信息:
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;
// 比较兄弟节点的val值,将较大的赋值给父节点
setValue(parentId, val);
id = parentId;
}
}
分配的内存小于pageSize时,通过调用allocateSubpage函数实现内存分配,这块内容当分析了PoolSubPage之后在进行讲解。
2. PoolSubPage
Netty提供了PoolSubpage把一个page节点8k内存划分成更小的内存段,通过对每个内存段的标记与清理标记进行内存的分配与释放。一个Page只能用于分配与第一次申请时大小相同的内存,例如,一个8K的Page,如果第一次分配了1K的内存,那么后面这个Page就只能继续分配1K的内存,如果有一个申请2K内存的请求,就需要在一个新的Page中进行分配。
下面看下PoolSubpage的构造方法:
final class PoolSubpage<T> implements PoolSubpageMetric {
// 用来表示该Page属于哪个Chunk
final PoolChunk<T> chunk;
// Page在Chunk.memoryMap中的索引
private final int memoryMapIdx;
// 当前Page在chunk.memoryMap的偏移量
private final int runOffset;
// Page的大小,默认为8192
private final int pageSize;
//通过对每一个二进制位的标记来修改一段内存的占用状态
private final long[] bitmap;
// arena双向链表的前驱节点
PoolSubpage<T> prev;
// arena双向链表的后继节点
PoolSubpage<T> next;
boolean doNotDestroy;
// 均等切分的大小
int elemSize;
// 最多可以切分的小块数
private int maxNumElems;
private int bitmapLength; //位图大小,maxNumElems >>> 6,一个long有64bit
private int nextAvail; //下一个可用的单位
private int numAvail; //还有多少个可用单位;
}
构造方法有两个,其中一个用于构造双向链表的头节点Head,这是一个特殊节点,相当于一个空节点,下面看下普通节点的构造。
PoolSubpage(PoolSubpage<T> head, PoolChunk<T> chunk, int memoryMapIdx, int runOffset, int pageSize, int elemSize) {
this.chunk = chunk;
this.memoryMapIdx = memoryMapIdx;
this.runOffset = runOffset;
this.pageSize = pageSize;
bitmap = new long[pageSize >>> 10]; // pageSize / 16 / 64
init(head, elemSize);
}
在PoolSubpage的实现中,使用的是字段private final long[] bitmap数组中每一个元素的二进制来记录Page的使用状态,其中bitmap数组的最大长度为:pageSize / 16 / 64 = 8,这里的16指的是块的最小值,64是long类型的所占的bit数。init根据当前需要分配的内存大小,确定需要多少个bitmap元素,实现如下:
void init(PoolSubpage<T> head, int elemSize) {
doNotDestroy = true;
this.elemSize = elemSize;
if (elemSize != 0) {
maxNumElems = numAvail = pageSize / elemSize; // page切分的个数
nextAvail = 0;
bitmapLength = maxNumElems >>> 6; // 切分后需要用多少个long表示
if ((maxNumElems & 63) != 0) {// 如果块的个数不是64的整倍数,则加 1
bitmapLength ++; // 比如elemSize为4096,则maxNumElems为2,那么maxNumElems为0,因此需要加一层判断
}
// 初始化每个元素
for (int i = 0; i < bitmapLength; i ++) {
bitmap[i] = 0;
}
}
addToPool(head);
}
addToPool()方法将该PoolSubpage加入到Arena的双向链表中,代码如下:
private void addToPool(PoolSubpage<T> head) {
assert prev == null && next == null;
prev = head;
next = head.next;
next.prev = this;
head.next = this;
}
每次新加入的节点都在Head节点之后。下面分析allocate方法:
long allocate() {
if (elemSize == 0) return toHandle(0);
if (numAvail == 0 || !doNotDestroy) return -1;
// 找到当前page中可分配内存段的bitmapIdx;
final int bitmapIdx = getNextAvail();
int q = bitmapIdx >>> 6;
int r = bitmapIdx & 63;
assert (bitmap[q] >>> r & 1) == 0;
bitmap[q] |= 1L << r;
if (-- numAvail == 0) removeFromPool();
return toHandle(bitmapIdx);
}
private long toHandle(int bitmapIdx) {
return 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
}
- getNextAvail()方法来得到此Page中下一个可用“块”的位置bitmapIdx,其实就是编号,比如elemSize为64,8kb被分为128块,则需要2个long类型表示,总共2*64=128位,bitmapIdx就是就是指128块小内存的编号。
后面分析具体获取bitmapIdx的逻辑。 - q = bitmapIdx >>> 6,r = bitmapIdx & 63,确定bitmap数组下标为q的元素第r位来标识、描述 bitmapIdx 内存段的状态,假设bitmapIdx=66,则q=1,r=2,即是用bitmap[1]这个long类型数的第2个bit位来表示此“内存块”的。
- 将bitmap[q]这个long型的数的第r 位bit置为1,标识此“块”已经被分配。
- 将page的可用“块数”numAvail减一,减一之后如果结果为0,则表示此Page的内存无可分配的了,因此将subpage从Arena所持有的链表中移除。
- 转换为64位分配信息,其中低32位表示PoolSubpage所属的Page的标号,高32位表示均等切分小块的坐标,
|<-- 24 -->| <-- 6 --> | <-- 32 --> |
| long数组偏移 |long的二进制位偏移 |所属page在memoryMapIdx的编号(只能是叶子节点)|
下面来看下getNextAvail()方法是如何得到此Page中下一个可用“块”的位置bitmapIdx的
private int getNextAvail() {
int nextAvail = this.nextAvail;
if (nextAvail >= 0) {
this.nextAvail = -1;
return nextAvail;
}
return findNextAvail();
}
nextAvail在构造函数中被初始化为0,第一次申请可用“块”的时候nextAvail=0,会直接返回。表示直接用第0位内存块,接下来nextAvail为-1,因此调用findNextAvail方法,继续跟进
private int findNextAvail() {
final long[] bitmap = this.bitmap;
final int bitmapLength = this.bitmapLength;
for (int i = 0; i < bitmapLength; i ++) { // 遍历数组每一个long元素
long bits = bitmap[i];
if (~bits != 0) { // ~表示取反码,如果不等于0,表示还有些块处于空闲,如果等于0,表示全部被占用
return findNextAvail0(i, bits);
}
}
return -1;
}
private int findNextAvail0(int i, long bits) {
final int maxNumElems = this.maxNumElems;
final int baseVal = i << 6;
for (int j = 0; j < 64; j ++) { // 遍历long类型元素的64位二进制
if ((bits & 1) == 0) { // 如果为0,表示空闲,进入if分支
int val = baseVal | j; // baseVal | j计算的结果就是小块内存的编号
if (val < maxNumElems) { //判断是否越界,超过maxNumElems(512)
return val;
} else {
break;
}
}
bits >>>= 1;
}
return -1;
}
总的来说其实分配小于pagesize的内存是通过按顺序遍历标识数组bitmap中每个long元素中的每一bit位中为0的位置。
3. 分配和释放
在PoolChunk一节中,我们分析了分配不小于PageSize(8Kb)内存的过程,在PoolSubPage一节中我们描述PoolSubPage是如何分配更小的内存块(小于8Kb),本节分析PoolChunk一节遗留的问题。PoolChunk- > PoolSubPage,具体的去认识PoolChunk是如何利用PoolSubPage来分配小内存块的。
allocateSubpage()代码如下:
private long allocateSubpage(int normCapacity) {
PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
synchronized (head) {
int d = maxOrder; // subpages are only be allocated from pages i.e., leaves
int id = allocateNode(d); // 找到可分配的叶子节点,该方法在分析Chunk的allocate方法是介绍过
if (id < 0) return id;
final PoolSubpage<T>[] subpages = this.subpages;
final int pageSize = this.pageSize;
// 更新Chunk的可用量
freeBytes -= pageSize;
int subpageIdx = subpageIdx(id);
PoolSubpage<T> subpage = subpages[subpageIdx];
if (subpage == null) {// 一般情况都为null,构造一个PoolSubpage对象
subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
subpages[subpageIdx] = subpage; // 添加到数组中
} else {// 如果不为空,表示那块内存已被释放,只需初始化,即添加道Arena的双向列表中
subpage.init(head, normCapacity);
}
// 找到具体的某块小内存
return subpage.allocate();
}
}
arena中维护了分配的内存小于PageSize,所以分配的节点必然在二叉树的最高层。找到最高层合适的节点后,新建或初始化subpage并加入到chunk的subpages数组,同时将subpage加入到arena的subpage双向链表中,最后完成分配请求的内存。关于Arena的内容会面再说。
当分配好了之后,需要与ByteBuf关联起来,该逻辑的实现也是由PoolChunk负责:
void initBuf(PooledByteBuf<T> buf, long handle, int reqCapacity) {
int memoryMapIdx = memoryMapIdx(handle);
int bitmapIdx = bitmapIdx(handle);
if (bitmapIdx == 0) { // Page级别PooledByteBuf初始化
byte val = value(memoryMapIdx); // 获取到二叉树的值
assert val == unusable : String.valueOf(val);
buf.init(this, handle, runOffset(memoryMapIdx) + offset, reqCapacity, runLength(memoryMapIdx),
arena.parent.threadCache());
} else {// SubPage级别PooledByteBuf初始化
initBufWithSubpage(buf, handle, bitmapIdx, reqCapacity);
}
}
runOffset(memoryMapIdx)是计算从chunk头部开始的byte长度,runLength是计算memoryMapIdx节点对应的内存长度。
private int runLength(int id) {
// log2ChunkSize 为24
return 1 << log2ChunkSize - depth(id);
}
private int runOffset(int id) {
// shift 为某个节点左边的同层节点数量
int shift = id ^ 1 << depth(id);
return shift * runLength(id);
}
接下来调用了PooledByteBuf的init方法,如果是 SubPage级别调用initBufWithSubpage:
private void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int bitmapIdx, int reqCapacity) {
int memoryMapIdx = memoryMapIdx(handle);
PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
buf.init(
this, handle,
runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize + offset,
reqCapacity, subpage.elemSize, arena.parent.threadCache());
}
和 Page级别PooledByteBuf初始化相比,偏移地址offset的计算似乎更加复杂,显示获取到Subpage的偏移量,然后再计算小块内存的额偏移量。
private void init0(PoolChunk<T> chunk, long handle, int offset, int length, int maxLength, PoolThreadCache cache) {
this.chunk = chunk;
memory = chunk.memory;
allocator = chunk.arena.parent;
this.cache = cache;
this.handle = handle;
this.offset = offset;
this.length = length;
this.maxLength = maxLength;
tmpNioBuf = null;
}
init方法内部调用的是init0,这里我们看到将ByteBuf对象与内存偏移量地址,Chunk联系起来。
下面看一下释放的过程:
void free(long handle) {
int memoryMapIdx = memoryMapIdx(handle);
int bitmapIdx = bitmapIdx(handle);
if (bitmapIdx != 0) { // free a subpage
PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
PoolSubpage<T> head = arena.findSubpagePoolHead(subpage.elemSize);
synchronized (head) {
if (subpage.free(head, bitmapIdx & 0x3FFFFFFF)) {
return;
}
}
}
freeBytes += runLength(memoryMapIdx);
setValue(memoryMapIdx, depth(memoryMapIdx)); // 节点分配信息还原为高度值
updateParentsFree(memoryMapIdx); // 更新祖先节点的分配信息
}
long handle的低32位保存memoryMapIdx,高32位保存在bitmap的坐标(如果有意要的话),前两行代码取到memoryMapIdx和bitmap的坐标(可能为空)。
如果bitmapIdx不为0,表明此块内存是小于pageSize的内存块,那么一定是在叶子节点上,因此定位到subpage,调用subpage.free
boolean free(PoolSubpage<T> head, int bitmapIdx) {
int q = bitmapIdx >>> 6;
int r = bitmapIdx & 63; // q r 为小块内存在bitmap数组中的坐标
bitmap[q] ^= 1L << r; // 此处的位异或操作是将将对应的位置位0,表示空闲
setNextAvail(bitmapIdx);// 设置nextAvail为释放的内存
if (numAvail ++ == 0) { // 此次释放一块小内存,如果释放之前subPage管理的所有小块内存全部被占用,则subPage一定从双向列表中移除
// 但是此时释放了一块,因此将subpage再次加入到arena双向链表
addToPool(head);
return true;
}
if (numAvail != maxNumElems) {
return true;
} else {// 可用的内存块和总块数相同,
if (prev == next) {// 如果双向列表只有head和当前subpage,直接返回
return true;
}
// 如果池中还有其他子页面,请从池中删除此subpage。
doNotDestroy = false;
removeFromPool();
return false;
}
}
楼主已开始看后半段代码有些困惑,为什么一会加入到双向列表中,一会又从双向列表中删除。这里整理总结一下,列表是Arena用来表示那些subPage有空闲的内存可以分配,并且按照elemSize划分为多个列表,列表的Head用数组管理起来。 当subpage中的内存块全部被用光,在分配的时刻就会从列表中删除,或者当所有的小内存块都是空闲并且列表中还有其他的subpage,那么将此subpage移除。
回到poolChunk.free方法中,如果subpage.free返回true,则直接返回,释放内存结束。如果返回false,则表明释放了一个subpage,相当于释放了一个叶子节点。逻辑和释放大于pagesize的内存是一样的。由于代码逻辑是分配的逆过程,此处不再赘述。
由于篇幅限制,下面的内容见下文:Netty内存管理深度解析(下)