本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。
本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经
数据结构
PoolSubpage 用于分配内存规格小于 28KB(1<<(pageshift+LOG22_SIZE_CLASS_GROUP
) 的内存,数据结构如下:
在 PoolArena 中有一个 PoolSubpage 类型的数组:PoolSubpage<T>[] smallSubpagePools
,它每一个元素都是一个 PoolSubpage 的双向链表,在 PoolArena 初始化时会初始化该数组,同时会新建一个 PoolSubpage 的 head 节点,注意 PoolSubpage 内部属性全为 null,详细情况见:PoolArena。
在第一次申请内存时,PoolArena 会先判断双向链表中是否有空闲空间的 PoolSubpage ,如果没有就向 PoolChunk "借"一块内存块(run)过来,然后根据本次申请内存的大小将该 run 切分为大小相同的多个存储块,比如我们申请的 run 为 4MB,本次申请的内存大小为 1KB,那么该 run 会被分为 4 MB / 1KB = 4096
块内存块,在 PoolSubpage 中这些内存块并不是通过什么链表来串连起来的,而是通过下一个可用内存块坐标 nextAvail 来标记的。直接看 PoolSubpage 的定义就知道了:
java
final class PoolSubpage<T> implements PoolSubpageMetric {
// 所属 PoolChunk
final PoolChunk<T> chunk;
// 一块内存的大小
final int elemSize;
// page 偏移量
private final int pageShifts;
// 在 PoolChunk 中所处的位置
private final int runOffset;
// runSize
private final int runSize;
// bitMap
private final long[] bitmap;
// 前置节点
PoolSubpage<T> prev;
// 后置节点
PoolSubpage<T> next;
// 标记是否已被释放
boolean doNotDestroy;
// 最大内存块数
private int maxNumElems;
// bitmap 长度
private int bitmapLength;
// 下一个可用的内存块坐标
private int nextAvail;
// 可用的内存块数量
private int numAvail;
// ...
}
在 PoolSubpage 中,Netty 使用 bitmap 来记录该 PoolSubpage 中每一块内存的使用情况,数组中每个 long 的每一位表示一个内存块的占用情况,0 表示未占用,1 表示已占用。在看内存分配和内存释放的源码时我们需要注意 bitmap 的变化情况。bitmap 这个标志位比较难理解,各位小伙伴一定要对它有清晰的认识,否则后面的源码部分你会莫名其妙,看不懂为什么要这么做,不过有大明哥在,一切都不是问题。
构造函数
PoolSubpage 的构造函数主要是初始化 bitmap 数组,同时将其添加到对一个的双向链表中。
ini
PoolSubpage(PoolSubpage<T> head, PoolChunk<T> chunk, int pageShifts, int runOffset, int runSize, int elemSize) {
this.chunk = chunk;
this.pageShifts = pageShifts;
this.runOffset = runOffset;
this.runSize = runSize;
this.elemSize = elemSize;
bitmap = new long[runSize >>> 6 + LOG2_QUANTUM]; // runSize / 64 / QUANTUM
doNotDestroy = true;
if (elemSize != 0) {
// 可用内存块 = runSize / elemSize
maxNumElems = numAvail = runSize / elemSize;
nextAvail = 0;
bitmapLength = maxNumElems >>> 6;
// 如果不是整数则 + 1
if ((maxNumElems & 63) != 0) {
bitmapLength ++;
}
for (int i = 0; i < bitmapLength; i ++) {
bitmap[i] = 0;
}
}
// 添加到双线链表中
addToPool(head);
}
bitmapLength = maxNumElems >>> 6
等价于 bitmapLength = maxNumElems / 64
,64 为 long 类型所占的 bit 数。
内存分配
内存分配我们需要从 PoolArena 开始。
arduino
private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
final int sizeIdx = size2SizeIdx(reqCapacity);
if (sizeIdx <= smallMaxSizeIdx) {
tcacheAllocateSmall(cache, buf, reqCapacity, sizeIdx);
} else if (sizeIdx < nSizes) {
// Normal
} else {
// Huge
}
}
根据申请内存大小计算 sizeIdx,若 sizeIdx ≤ smallMaxSizeIdx(38,
则为 Small 内存规格,调用tcacheAllocateSmall()
:
java
private void tcacheAllocateSmall(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity,
final int sizeIdx) {
// 内存中分配
if (cache.allocateSmall(this, buf, reqCapacity, sizeIdx)) {
return;
}
// 获取 sizeIdx 位置的 head 节点
final PoolSubpage<T> head = smallSubpagePools[sizeIdx];
final boolean needsNormalAllocation;
synchronized (head) {
final PoolSubpage<T> s = head.next;
// 相等意味双向链表为空,需要从 PoolChunk 中分配一块内存来进行 PoolSubpage 分配
needsNormalAllocation = s == head;
if (!needsNormalAllocation) {
assert s.doNotDestroy && s.elemSize == sizeIdx2size(sizeIdx) : "doNotDestroy=" +
s.doNotDestroy + ", elemSize=" + s.elemSize + ", sizeIdx=" + sizeIdx;
// 在双向链表中分配
long handle = s.allocate();
assert handle >= 0;
s.chunk.initBufWithSubpage(buf, null, handle, reqCapacity, cache);
}
}
if (needsNormalAllocation) {
synchronized (this) {
// 在 PoolChunk 中分配
allocateNormal(buf, reqCapacity, sizeIdx, cache);
}
}
incSmallAllocation();
}
我们知道 PoolChunk 是 Netty 进行内存分配真正的位置,PoolSubpage 的内存块也是从 PoolChunk 中获取的,所以在PoolSubpage 在进行内存分配时需要判断当前双向链表是否需要到 PoolChunk 中去进行内存分配,如果需要就调用 allocateNormal()
在 PoolChunk 中分配内存,否则就在 PoolSubpage 中分配内存。
PoolSubpage#allocate()
当 PoolSubpage 的双向链表有足够的内存空间来完成内存分配,就调用 PoolSubpage#allocate()
来分配内存:
ini
long allocate() {
// numAvail == 0 表示内存已分配完了
// !doNotDestroy 则表示 PoolSubpage 已经从池中被移除了
if (numAvail == 0 || !doNotDestroy) {
return -1;
}
// 获取下一个可用的的坐标
final int bitmapIdx = getNextAvail();
// q = bitmapIdx / 64;
int q = bitmapIdx >>> 6;
int r = bitmapIdx & 63;
assert (bitmap[q] >>> r & 1) == 0; // q 位置的值应该为 0,未被使用
// 将 bitmap[q] 设置为 1,标记此"块"内存已分配
bitmap[q] |= 1L << r;
if (-- numAvail == 0) {
// 该 PoolSubpage 已经没有内存块是空闲的,从 PoolArena 的双向链表中移除
removeFromPool();
}
return toHandle(bitmapIdx);
}
-
调用
getNextAvail()
来获取该 PoolSubpage 中下一个可用的 "内存块"的位置 bitmapIdx。 -
将 bitmapIdx 在 bitmap 所对应的位置标识为"已占用"状态。如何来做?
- 根据
q = bitmapIdx >>> 6
得到 bitmapIdx 这个内存块在 bitmap 数组中第 q 个元素。 - 根据
r = bitmapIdx & 63
得到 r 位的值。 - 利用
(bitmap[q] >>> r & 1) == 0
来判断bitmap[q]
的第 r 位是否为 0,如果是 0 则表示未被占用,未被占用则将第 r 位设置为 1(bitmap[q] |= 1L << r
)
- 根据
-
numAvail - 1
,若- 1
后numAvail == 0
,表示该 PoolSubpage 中已经没有空闲的内存块了,则调用removeFromPool()
将该 PoolSubpage 从 PoolArena 的双向链表中移除。 -
最后调用
toHandle()
转换为 handle。
这段代码有两个值 q 和 r 很不好理解,在这里我们需要认真梳理下 PoolSubpage 是如何来标识它的内存块情况的。我们来进行 100 次内存分配:
ini
for(int i = 0 ; i < 100 ; i++) {
ByteBufAllocator.DEFAULT.heapBuffer(32);
}
首先大明哥的默认 chunkSize = 8192,所以在我们第一次申请 32B 的内存时,会将整个 run 切分为 8192 / 32 = 256
块,即 maxNumElems = numAvail = 256
,所以 bitmapLength = maxNumElems >>> 6 = 4
,这里为什么是 4 呢?各位小伙伴想想,一个 long 有 64 位,我们总共需要标注 256 块内存块,不就是 256 / 64
么?
在这 100 次内存分配过程中,我们只需要关注几个位置即可,第 1、2 、60、100 次。
第 1 次
- 分配前
- 分配后
分配前都为 0,这个应该好理解,分配后 bitmap[0] = 1,对应的 2 进制 long 为:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
,表明 bitmap[0] 第 0 位所对应的内存卡已经分配出去了。
第 2 次
- 分配前
- 分配后
bitmapIdx = 1
,表明分配第 2 块内存块q = 0 ,r = 1
,说明对应的位置为 bitmap[0] 第 1 位。- 分配完后,
bitmap[0] = 3
,对应二进制位00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000011
。
第 60 次
- 分配前
- 分配后
q = 0 ,r = 59
,说明对应的位置为 bitmap[0] 第 59 位,是不是一直都在符合我们的预期值?- 分配前
bitmap[0] = 576460752303423487
,对应二进制00000111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
。 - 分配后
bitmap[0] = 1152921504606846975
对应二进制00001111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
。
第 100 次
- 分配前
- 分配后
- q = 1,r = 35。一个 long 最多只能标记 64 个内存块,所以第 100 次内存分配时,就要到第 2 个元素来了。
bitmap[0] = -1
,表明这个元素所对应的所有内存块都已经分配完毕了。- 分配前后的 bitmap[1] 值大明哥就标记了,详细各位小伙伴明白是什么意思了。
到这里各位小伙伴应该对这个 bitmap 的定位很清晰了吧?也明白了 PoolSubpage 是如何来进行内存块的管理了吧?
我们再回到源码部分。
getNextAvail()
getNextAvail()
就是寻找下一个可用的内存块,通过上面的演示,我们基本上可以猜测它是如何实现的:按照顺序遍历判断标识位数组 bitmap 的每一个 long 元素的每一个 bit 是否为 0,我们来看看。
java
private int getNextAvail() {
int nextAvail = this.nextAvail;
// nextAvail >= 0,有两种情况
// 1. 初始化,第一次申请内存时 nextAvail = 0,可以直接返回,这里会设置 nextAvail = -1 ,后面申请就不会再次进入这个逻辑
// 2. 释放内存,释放内存后,会将释放的内存块的 bitmapIdx 设置为 nextAvail
if (nextAvail >= 0) {
this.nextAvail = -1;
return nextAvail;
}
return findNextAvail();
}
// 遍历整个 bitmap 数组
private int findNextAvail() {
final long[] bitmap = this.bitmap;
final int bitmapLength = this.bitmapLength;
// 对 bitmap 数组中的每一个元素进行判断
for (int i = 0; i < bitmapLength; i ++) {
long bits = bitmap[i];
// ~bits != 0 说明该 long 中至少还有一个 bit 位不为 0
if (~bits != 0) {
return findNextAvail0(i, bits);
}
}
return -1;
}
// 检查 bitmap 数组中 long 的每一个 bit 是否为 0
private int findNextAvail0(int i, long bits) {
final int maxNumElems = this.maxNumElems;
final int baseVal = i << 6;
for (int j = 0; j < 64; j ++) {
if ((bits & 1) == 0) {
int val = baseVal | j;
if (val < maxNumElems) {
return val;
} else {
break;
}
}
bits >>>= 1;
}
return -1;
}
通过源码证实了我们的猜想:就是遍历 bitmap 数组中元素的每一个 bit 位,看是否有一个位为 0。
removeFromPool()
当一个 PoolSubpage 中的所有内存块都分配完毕后,需要将其从 PoolArena 的双向链表中移除。
ini
private void removeFromPool() {
assert prev != null && next != null;
prev.next = next;
next.prev = prev;
next = null;
prev = null;
}
toHandle()
这个方法很有意思,因为如果你不理解它,你就无法理解 PoolChunk#allocate()
,我们再看看 PoolChunk 中 handle 的定义:
它的第 31 ~ 63 位都是跟 PoolSubpage 相关的,但是你看 PoolChunk#allocateSubpage()
方法根本就没有设置第 31 ~ 63 位的值,大明哥当时也很懵逼,直到看了这个方法才一目了然。
arduino
private long toHandle(int bitmapIdx) {
int pages = runSize >> pageShifts;
return (long) runOffset << RUN_OFFSET_SHIFT
| (long) pages << SIZE_SHIFT
| 1L << IS_USED_SHIFT
| 1L << IS_SUBPAGE_SHIFT
| bitmapIdx;
}
这个方法很简单,就是根据 bitmapIdx 、runSize、runSize来构建 handle,各个 SHIFT 定义如下:
arduino
static final int IS_SUBPAGE_SHIFT = BITMAP_IDX_BIT_LENGTH;
static final int IS_USED_SHIFT = SUBPAGE_BIT_LENGTH + IS_SUBPAGE_SHIFT;
static final int SIZE_SHIFT = INUSED_BIT_LENGTH + IS_USED_SHIFT;
static final int RUN_OFFSET_SHIFT = SIZE_BIT_LENGTH + SIZE_SHIFT;
具体转换成啥样,各位小伙伴去验证验证。
PoolChunk#allocate()
java
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
final long handle;
if (sizeIdx <= arena.smallMaxSizeIdx) {
// small
handle = allocateSubpage(sizeIdx);
if (handle < 0) {
return false;
}
assert isSubpage(handle);
} else {
// normal
}
//...
return true;
}
如果sizeIdx ≤ smallMaxSizeIdx
则调用 allocateSubpage()
来进行 Small 内存规格分配:
ini
private long allocateSubpage(int sizeIdx) {
// 获取 head
PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);
synchronized (head) {
// 计算 size 与 pageSize 的最小公倍数
int runSize = calculateRunSize(sizeIdx);
// 将这个部分的 run 分配出去
long runHandle = allocateRun(runSize);
if (runHandle < 0) {
return -1;
}
int runOffset = runOffset(runHandle);
// 确保这个位置的 run 没有分配出去过
assert subpages[runOffset] == null;
int elemSize = arena.sizeIdx2size(sizeIdx);
// 构造一个 PoolSubpage 对象
PoolSubpage<T> subpage = new PoolSubpage<T>(head, this, pageShifts, runOffset,
runSize(pageShifts, runHandle), elemSize);
subpages[runOffset] = subpage;
// 调用 PoolSubpage 的 allocate() 分配内存
return subpage.allocate();
}
}
- 首先调用
calculateRunSize()
计算 size 与 pageSize 的最小公倍数 runSize,因为 PoolSubpage 需要对 size 进行均分。 - 调用
allocateRun()
将 runSize 分配给 PoolSubpage。 - 根据对应的信息构建一个 PoolSubpage 对象,然后调用其
allocate()
进行内存分配。 - 最后将该 subpage 添加到 subpages 数组中。
内存释放
PoolSubpage 的内存释放入口也是 PoolChunk :
ini
void free(long handle, int normCapacity, ByteBuffer nioBuffer) {
int runSize = runSize(pageShifts, handle);
if (isSubpage(handle)) {
//是 PoolSubpage
int sizeIdx = arena.size2SizeIdx(normCapacity);
// 找到对应的 PoolSubpage 链表 head
PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);
int sIdx = runOffset(handle);
// 找到要释放的 PoolSubpage
PoolSubpage<T> subpage = subpages[sIdx];
assert subpage != null && subpage.doNotDestroy;
synchronized (head) {
// 调用 PoolSubpage#free() 释放内存
if (subpage.free(head, bitmapIdx(handle))) {
// 没有释放完全,直接返回
return;
}
assert !subpage.doNotDestroy;
// 释放完全
subpages[sIdx] = null;
}
}
// ...
}
PoolChunk 释放 PoolSubpage 逻辑很简单,因为我们在 PoolChunk 的 subpages 数组中保存了 PoolSubpage,我们只需要根据对应的 handle 就可以计算出在 subpages 数组中的索引 sIdx,最后调用其 free()
方法来释放内存就可以了。
ini
boolean free(PoolSubpage<T> head, int bitmapIdx) {
if (elemSize == 0) {
return true;
}
//
int q = bitmapIdx >>> 6;
int r = bitmapIdx & 63;
assert (bitmap[q] >>> r & 1) != 0;
// 设置为 0
bitmap[q] ^= 1L << r;
//设置 nextAvail = bitmapIdx
setNextAvail(bitmapIdx);
if (numAvail ++ == 0) {
// numAvail == 0 说明整个 PoolSubpage 已经完全释放了,则将其加入到双向链表中
addToPool(head);
if (maxNumElems > 1) {
return true;
}
}
if (numAvail != maxNumElems) {
// numAvail != maxNumElems 说明 PoolSubpage 还没有完全释放
return true;
} else {
// 已经完全释放了
if (prev == next) {
return true;
}
doNotDestroy = false;
removeFromPool();
return false;
}
}
PoolSubpage#free()
也比较简单,主要是将对应位置的标识位设置为 0,然后设置 nextAvail = bitmapIdx
(这里可以在内存分配时加快检索效率,不需要从头到尾依次循环检索),最后判断该 PoolSubpage 是否已完全释放,是就返回 true,否则返回 false,如果已经完全释放了在 PoolChunk 中的 subpages 数组需要删除该 PoolSubpage 对象。
到这里 PoolSubpage 的源码就已经分析完毕了,相比 PoolChunk,PoolSubpage 相对会简单些,没有难理解的数据结构,也没有难理解的复杂逻辑。