本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。
本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经
如果有小伙伴以前看过基于 jemalloc3 的 PoolChunk 的源码,我相信小伙伴在这个过程是比较痛苦的,因为 jemalloc3 使用了二叉树,源码阅读非常吃力。相对于 jemalloc3,jemalloc4 算法思想足够简单,阅读起来还是稍微会轻松些。本篇文章大明哥将详细介绍基于 jemalloc4 算法的 PoolChunk,一起来目睹其风采吧!!
重要术语
在理解 PoolChunk 之前我们需要清楚 PoolChunk 中几个比较重要的术语。
Page
:page 是 PoolChunk 中内存分配的最小单位。run
:由若干个连续的 Page 组成的内存块。handle
:句柄,用于表示 PoolChunk 中一块内存的基本信息,在 PoolChunk 中进行内存分配时我们首先需要知道这块内存的基本信息,这些信息就是从 handle 中获取。
handle 是一个 64 位的数据,各位含义如下:
0~ 14 位
表示这个句柄所在的位置。15 ~ 29 位
表示这个句柄表示的是多少页。30 位
表示这段内存是否被使用。31 位
表示这段内存是否用于 subPage 的分配。32 ~ 63 位
表示的是这块内存在 subPage 中 bitMap 的第几个。
重要数据结构
在 PoolChunk 中有两个非常重要的数据结构 LongPriorityQueue[] runsAvail
和 LongLongHashMap runsAvailMap
,要想掌握 PoolChunk,这两个数据结构是必须要了解的。
LongPriorityQueue[] runsAvail
LongPriorityQueue 是 Netty 内部实现的一个关于 Long 的优先队列,它是基于二叉堆实现,目前只在 PoolChunk 中使用,主要用于 long 型的 handle 值。
LongPriorityQueue 属于小顶堆,通过 LongPriorityQueue#poll()
我们可以每次获取小顶堆内部最小的 handle 值,这样我们在申请内存时都是从最低位的地址开始分配的。
数组 runsAvail,存储该 PoolChunk 的所有 run,这里需要注意数组 runsAvail 的索引是 Size 对应的 sizeIdx,在内存分配过程过程中,Netty 需要根据 sizeIdx 从 runsAvail 中向下检索可用的 LongPriorityQueue。
LongLongHashMap runsAvailMap
LongLongHashMap 也是 Netty 内部实现的一个特殊的存储 long 原型的 HashMap,底层采用线性探测法,目前也仅在 PoolChunk 中使用。
Netty 使用 LongLongHashMap 存储某个 run 的首页偏移量和句柄的映射关系,最后一页偏移量和句柄的映射关系,这样做的目的主要是为了在向前、向后合并过程中能够通过 pageOffset 来获取 handle,进而判断是否可以进行合并操作。
定义
我们再看 PoolChunk 的定义:
java
final class PoolChunk<T> implements PoolChunkMetric {
// 所属 PoolArena
final PoolArena<T> arena;
final Object base;
// 维护的内存块
final T memory;
// 是否池化
final boolean unpooled;
// 存储所有 run 的第一个和最后一个 page 的句柄
private final LongLongHashMap runsAvailMap;
// 存储所有的 run
private final LongPriorityQueue[] runsAvail;
// 管理该 PoolChunk 中的所有 PoolSubpage
private final PoolSubpage<T>[] subpages;
// 页大小,默认 8kb
private final int pageSize;
// pageSize 的偏移量
private final int pageShifts;
// 一个 chunk 的大小
private final int chunkSize;
// 缓存
// 主要是对 PooledByteBuf 中频繁创建的 ByteBuffer 进行缓存,以避免由于频繁创建的对象导致频繁的GC
private final Deque<ByteBuffer> cachedNioBuffers;
// 空闲 byte
int freeBytes;
// 所有 PoolChunkList
PoolChunkList<T> parent;
// 前置节点
PoolChunk<T> prev;
// 后置节点
PoolChunk<T> next;
pageSize 默认 8KB,chunkSize 默认 4MB,由于版本的不同,可能会存在差异。
PoolChunk 内存中的结构如下:
加上上面两个数据结构,我们再对其丰富下。绿色达标已分配的页,白色则是没有分配,红色部分表示已分配给 subPage 的页。
这图现在不是很理解没有关系,大明哥在内存分配和内存释放模块会详细介绍的,到时候你就会一目了然了,我们先记住!
内存分配
源码分析
java
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
final long handle;
// sizeIdex <= 38, 表示当前分配的内存规格为 Small
if (sizeIdx <= arena.smallMaxSizeIdx) {
// 分配 Small 规格内存块
handle = allocateSubpage(sizeIdx);
if (handle < 0) {
return false;
}
assert isSubpage(handle);
} else {
// 分配 Normal 级别内存块
int runSize = arena.sizeIdx2size(sizeIdx);
handle = allocateRun(runSize);
if (handle < 0) {
return false;
}
assert !isSubpage(handle);
}
// 从缓存中获取 ByteBuf 对象
ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;
// 初始化 ByteBuf 对象
initBuf(buf, nioBuffer, handle, reqCapacity, cache);
return true;
}
根据 sizeIdx 来判断当前分配内存规格是 Small 还是 Normal,如果 sizeIdx ≤ 38
(size ≤ 28 KB
)时,则调用 allocateSubpage()
来分配 Small 规格的内存,否则调用 allocateRun()
分配 Normal 规格的内存,这里我们只看 Normal 级别的。
allocateRun()
runSize 为 pageSize 的整数倍,allocateRun()
如下:
ini
private long allocateRun(int runSize) {
// 根据 runSize 确认需要多少个 page
int pages = runSize >> pageShifts;
// 根据 pages 的数量确认 page 的起始索引,索引值对应 runsAvail[] 的索引
int pageIdx = arena.pages2pageIdx(pages);
synchronized (runsAvail) {
// 根据 pageIdx 获取第一个有足够内存分配的 queueIdx
int queueIdx = runFirstBestFit(pageIdx);
if (queueIdx == -1) {
return -1;
}
// 从 runsAvail 获取 LongPriorityQueue
LongPriorityQueue queue = runsAvail[queueIdx];
// 获取第一个 run
long handle = queue.poll();
assert handle != LongPriorityQueue.NO_VALUE && !isUsed(handle) : "invalid handle: " + handle;
// 将该 handle 从 LongPriorityQueue 中移除
removeAvailRun(queue, handle);
if (handle != -1) {
// 将 run 进行拆分
handle = splitLargeRun(handle, pages);
}
// 更新剩余空间
int pinnedSize = runSize(pageShifts, handle);
freeBytes -= pinnedSize;
return handle;
}
}
allocateRun()
整理还是比较简单的,流程如下:
- 根据 runSize 计算所需的 page 数量。
- 根据 page 的数量确认 page 的起始索引,注意这个索引值对应 runsAvail 的索引值,从哪里取呢?SizeClasses 中的
pageIdx2sizeTab
表格中获取,该表格维护着 pageSize 倍数的内存规格表。 - 根据起始索引值 pageIdx 从 runsAvail 中获取第一个能够进行此次内存分配请求的 LongPriorityQueue,该 LongPriorityQueue 中包含有若干个可用的 run。
- 从 LongPriorityQueue 获取可用的 run。一开始大明哥就说到 LongPriorityQueue 是一个小顶堆,
queue.poll()
保证了拿到 run 是当前可分配 run 的最低地址。 - 将该 handle 从 LongPriorityQueue 中移除。为什么要移出?因为我们可能需要对其进行修改,因为分配完成后它肯定不属于这个 LongPriorityQueue 了。
- 调用
splitLargeRun()
切割 run,这里可能会将 run 拆分两部分,一部分用于当前内存分配,一部分是剩余空闲内存块,这部分空闲的内存块会放到合适的 LongPriorityQueue 数组中,待下次分配。也有可能刚刚好够分配,就不用拆分了。 - 更新剩余空间。
runFirstBestFit()
runFirstBestFit()
是根据当前 queueIdx 获取第一个能够满足它内存申请 LongPriorityQueue:
arduino
private int runFirstBestFit(int pageIdx) {
if (freeBytes == chunkSize) {
return arena.nPSizes - 1;
}
for (int i = pageIdx; i < arena.nPSizes; i++) {
LongPriorityQueue queue = runsAvail[i];
if (queue != null && !queue.isEmpty()) {
return i;
}
}
return -1;
}
这个方法看似简单,其实内部包含了你对 runsAvail 结构的理解,它是 LongPriorityQueue 类型的数组,元素有 32 个,为什么是 32 呢?去看 SizeClasses 中的 pageIdx2sizeTab 就知道了。runsAvail 与 pageIdx2sizeTab 就是一一对应的关系,所以在这里我们就可以确认 runsAvail 整个结构了:
index | num of pages |
---|---|
0 | 1 |
1 | 2 |
2 | 3 |
..... | ..... |
8 | 9 ~ 10 |
9 | 10 ~ 11 |
..... | ..... |
31 | 449 ~ 512 |
怎么理解这个表格呢?index 是索引,num of pages 表示该 LongPriorityQueue 存储的 handle 能够管理空闲 page 的数量,在使用 runsAvail 这个数组的时候会涉及到向上取整合向下取整的关系,比如我申请内存为 1MB,从 pageIdx2sizeTab 表格中我们可以查到对应的 pageIdx 为 23,所以我们可以从 runsAvail 数组向上循环获取对应的 LongPriorityQueue ,这样就可以可以找到一个满足此次内存分配请求的 run。
splitLargeRun()
我们找到了合适的 run,则调用 splitLargeRun()
进行拆分,拆分就是将该 run 拆分为两部分,一部分为分配用于本次内存分配,另外一部分为空闲部分需要合并到合适的 LongPriorityQueue 中。
ini
private long splitLargeRun(long handle, int needPages) {
assert needPages > 0;
// 空闲 run 所管理的 page 总数
int totalPages = runPages(handle);
assert needPages <= totalPages;
// 剩余 page 数
int remPages = totalPages - needPages;
// 如果有剩余,则说明需要将该 run 进行拆分
if (remPages > 0) {
// 获取当前 run 的偏移量
int runOffset = runOffset(handle);
// 剩余空闲页的偏移量 = 旧的偏移量 + 分配页数
int availOffset = runOffset + needPages;
// 根据偏移量、剩余页数重新生成一个新的空闲的 run
long availRun = toRunHandle(availOffset, remPages, 0);
// 将这个全新的 run,更新到 runsAvailMap 和 runsAvail
insertAvailRun(availOffset, remPages, availRun);
// 生成本次用于分配的句柄
return toRunHandle(runOffset, needPages, 1);
}
// 没有剩余 page,该 run 更新为『已使用』
handle |= 1L << IS_USED_SHIFT;
return handle;
}
该方法没有很复杂的逻辑,就是判断 run 分配后是否还有剩余 page,如果有则对其进行拆分,否则标记该 run 为已使用。最重要的步骤在于insertAvailRun()
对新的空闲 run 的处理。
insertAvailRun()
scss
private void insertAvailRun(int runOffset, int pages, long handle) {
// 获取当前 page 数所对应的 pageIdx
int pageIdxFloor = arena.pages2pageIdxFloor(pages);
LongPriorityQueue queue = runsAvail[pageIdxFloor];
// 加入到 LongPriorityQueue 中
queue.offer(handle);
// 将首页插入到 runsAvailMap 中
insertAvailRun0(runOffset, handle);
if (pages > 1) {
// 将尾页插入到 runsAvailMap 中
insertAvailRun0(lastPage(runOffset, pages), handle);
}
}
到这里 Normal 规格的内存分配源码就分析完毕了,看着复杂,其实弄清楚 LongPriorityQueue[] runsAvail
和 LongLongHashMap runsAvailMap
这两个数据结构就不是那么复杂了,为了各位小伙伴更好地理解整个过程,大明哥将通过画图的方式来阐述整个过程。
图文讲解
在这里大明哥将连续分配4 次内存,分别为 30 * 8KB
、48 * 8KB
、64 * 8KB
、1MB
。再次申明,大明哥的 Netty 版本 PoolChunk 默认为 4MB。
初始状态
首先是初始状态,我们的句柄 handle 为 8796093022208,对应二进制为 00000000 00000000 00001000 00000000 00000000 00000000 00000000 00000000
。runsAvailMap 和 runsAvail 都是初始状态。如下:
分配 30 * 8KB 内存
分配 30 * 8KB
,需要 32 个 page,PoolChunk 剩余空间为 512 * 8 KB - 32 * 8KB = 480 * 8KB
。
分配 48 * 8KB 内存
分配 64 * 8KB
分配 1MB
各位小伙伴一定要理清楚
handle
、runsAvailMap
、runsAvail
在每个阶段的变化,如果明白了这个变化过程,你会发现 PoolChunk 的内存分配真的简单!
内存释放
源码分析
PoolChunk 提供了 free()
用来回收内存。
scss
void free(long handle, int normCapacity, ByteBuffer nioBuffer) {
// 计算该 handle 多大
int runSize = runSize(pageShifts, handle);
// 如果是 subpage,就回收 subpage
if (isSubpage(handle)) {
// 省略代码...
}
// 回收 run
synchronized (runsAvail) {
// 向前、向后合并当前 run
long finalRun = collapseRuns(handle);
// 更新当前 run 为未使用
finalRun &= ~(1L << IS_USED_SHIFT);
// 清理 subpage 的标志位
finalRun &= ~(1L << IS_SUBPAGE_SHIFT);
// 将该 run 更新到 runsAvailMap 和 runsAvail
insertAvailRun(runOffset(finalRun), runPages(finalRun), finalRun);
// 更新空闲内存块大小
freeBytes += runSize;
}
// 回收 ByteBuf 对象
if (nioBuffer != null && cachedNioBuffers != null &&
cachedNioBuffers.size() < PooledByteBufAllocator.DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
cachedNioBuffers.offer(nioBuffer);
}
}
这里只看回收 run,回收 subpage 部分我们下篇文章分析。
- 调用
collapseRuns()
向前、向后合并与当前 run 相邻的 run,将其合并成一个更大的 run - 将该 run 重新标记为未使用,同时清理到 subpage 标识
- 更新 runsAvailMap 和 runsAvail
- 回收 ByteBuf 对象
回收 run 的过程比分配 run 简单些,最主要就在于当前 run 与相邻 run 的合并。
collapsePast()
collapsePast()
向后合并连续的 run。
ini
private long collapsePast(long handle) {
// 不断向后,直到不能合并为止
for (;;) {
// 偏移量
int runOffset = runOffset(handle);
// 拥有的 page 数
int runPages = runPages(handle);
// 获取末尾的可用的 run
long pastRun = getAvailRunByOffset(runOffset - 1);
if (pastRun == -1) {
// -1 表示没有相邻的
return handle;
}
// 末尾 run 的偏移量
int pastOffset = runOffset(pastRun);
// 末尾 run 拥有的 page 数
int pastPages = runPages(pastRun);
// 判断是否连续
// past_run 的偏移量 + 页数量 = run 的偏移量
if (pastRun != handle && pastOffset + pastPages == runOffset) {
// 移出旧的 run(past run)
removeAvailRun(pastRun);
// 生成一个新的 run
handle = toRunHandle(pastOffset, pastPages + runPages, 0);
} else {
return handle;
}
}
}
获取 runOffset - 1 的 run ,然后合并它,这里是一个for(;;)
的过程,会不断向后合并,直到不能合并为止。
collapseNext()
collapseNext()
向前合并 run,原理和 collapsePast()
差不多。
ini
private long collapseNext(long handle) {
for (;;) {
int runOffset = runOffset(handle);
int runPages = runPages(handle);
// 这个 run 的偏移量 = 当前的偏移量 + 页数
long nextRun = getAvailRunByOffset(runOffset + runPages);
if (nextRun == -1) {
return handle;
}
int nextOffset = runOffset(nextRun);
int nextPages = runPages(nextRun);
if (nextRun != handle && runOffset + runPages == nextOffset) {
removeAvailRun(nextRun);
handle = toRunHandle(runOffset, runPages + nextPages, 0);
} else {
return handle;
}
}
}
向前的 run 的 offset = runOffset + runPages
。
图文讲解
我们还是基于上面那个图例来分析,我们知道现在一个 PoolChunk已经分配出去了 30 * 8KB
、48 * 8KB
、64 * 8KB
、1MB
四块内存,为了更好地演示合并过程和更好的理解 runsAvailMap
、runsAvail
的变化,我们这里释放 48 * 8KB
、64 * 8KB
两块内存。
释放 48 * 8KB
释放的 handle ,大明哥标记为红色。
我们释放了一块内存,就需要将这块内存添加到 runsAvailMap
、runsAvail
中去。
释放 64 * 8KB
这释放 64 * 8KB
内存,然后它恰好跟上面释放的 48 * 8KB
相邻,所以会将他们两个进行合并,生成一个新的大的 Handle。
那个新的 Handle 情况如下:
到这里整个 PoolChunk 的源码分析就结束了,整体上来说不是很难,理解了它两个核心数据结构,知道内存分配和内存释放过程是怎么变化的基本上就差不多了,相对于基于 jemalloc 3 的来说,这个版本的 PoolChunk 真的简单了蛮多。