learn-tech/专栏/Netty核心原理剖析与RPC实践-完/14举一反三:Netty高性能内存管理设计(下).md
2024-10-16 06:37:41 +08:00

443 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
14 举一反三Netty 高性能内存管理设计(下)
在上一节课,我们学习了 Netty 的内存规格分类以及内存管理的核心组件,今天这节课我们继续介绍 Netty 内存分配与回收的实现原理。有了上节课的基础,相信接下来的学习过程会事半功倍。
本节课会侧重于详细分析不同场景下 Netty 内存分配和回收的实现过程,让你对 Netty 内存池的整体设计有一个更加清晰的认识。
内存分配实现原理
Netty 中负责线程分配的组件有两个PoolArena和PoolThreadCache。PoolArena 是多个线程共享的,每个线程会固定绑定一个 PoolArenaPoolThreadCache 是每个线程私有的缓存空间,如下图所示。
在上节课中,我们介绍了 PoolChunk、PoolSubpage、PoolChunkList它们都是 PoolArena 中所用到的概念。PoolArena 中管理的内存单位为 PoolChunk每个 PoolChunk 会被划分为 2048 个 8K 的 Page。在申请的内存大于 8K 时PoolChunk 会以 Page 为单位进行内存分配。当申请的内存大小小于 8K 时,会由 PoolSubpage 管理更小粒度的内存分配。
PoolArena 分配的内存被释放后,不会立即会还给 PoolChunk而且会缓存在本地私有缓存 PoolThreadCache 中,在下一次进行内存分配时,会优先从 PoolThreadCache 中查找匹配的内存块。
由此可见Netty 中不同的内存规格采用的分配策略是不同的,我们主要分为以下三个场景逐一进行分析。
分配内存大于 8K 时PoolChunk 中采用的 Page 级别的内存分配策略。
分配内存小于 8K 时,由 PoolSubpage 负责管理的内存分配策略。
分配内存小于 8K 时,为了提高内存分配效率,由 PoolThreadCache 本地线程缓存提供的内存分配。
PoolChunk 中 Page 级别的内存分配
每个 PoolChunk 默认大小为 16MPoolChunk 是通过伙伴算法管理多个 Page每个 PoolChunk 被划分为 2048 个 Page最终通过一颗满二叉树实现我们再一起回顾下 PoolChunk 的二叉树结构,如下图所示。
假如用户需要依次申请 8K、16K、8K 的内存,通过这里例子我们详细描述下 PoolChunk 如何分配 Page 级别的内存,方便大家理解伙伴算法的原理。
首先看下分配逻辑 allocateRun 的源码如下所示。PoolChunk 分配 Page 主要分为三步:首先根据分配内存大小计算二叉树所在节点的高度,然后查找对应高度中是否存在可用节点,如果分配成功则减去已分配的内存大小得到剩余可用空间。
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;
}
结合 PoolChunk 的二叉树结构以及 allocateRun 源码我们开始分析模拟的示例
第一次分配 8K 大小的内存时通过 d = maxOrder - (log2(normCapacity) - pageShifts) 计算得到二叉树所在节点高度为 11其中 maxOrder 为二叉树的最大高度normCapacity 8KpageShifts 默认值为 13因为只有当申请内存大小大于 2^13 = 8K 时才会使用 allocateRun 分配内存然后从第 11 层查找可用的 Page下标为 2048 的节点可以被用于分配内存 Page[0] 被分配使用此时赋值 memoryMap[2048] = 12表示该节点已经不可用然后递归更新父节点的值父节点的值取两个子节点的最小值memoryMap[1024] = 11memoryMap[512] = 10以此类推直至 memoryMap[1] = 1更新后的二叉树分配结果如下图所示
第二次分配 16K 大小内存时计算得到所需节点的高度为 10此时 1024 节点已经分配了一个 8K 内存不再满足条件继续寻找到 1025 节点1025 节点并未使用过满足分配条件于是将 1025 节点的两个子节点 2050 2051 全部分配出去并赋值 memoryMap[2050] = 12memoryMap[2051] = 12再次递归更新父节点的值更新后的二叉树分配结果如下图所示
第三次再次分配 8K 大小的内存时依然从二叉树第 11 层开始查找2048 已经被使用2049 可以被分配赋值 memoryMap[2049] = 12并递归更新父节点值memoryMap[1024] = 12memoryMap[512] = 12以此类推直至 memoryMap[1] = 1最终的二叉树分配结果如下图所示
至此PoolChunk Page 级别的内存分配已经介绍完了可以看出伙伴算法尽可能保证了分配内存地址的连续性有效地降低了内存碎片
Subpage 级别的内存分配
为了提高内存分配的利用率在分配小于 8K 的内存时PoolChunk 不在分配单独的 Page而是将 Page 划分为更小的内存块 PoolSubpage 进行管理
首先我们看下 PoolSubpage 的创建过程由于分配的内存小于 8K所以走到了 allocateSubpage 源码中
private long allocateSubpage(int normCapacity) {
// 根据内存大小找到 PoolArena subpage 数组对应的头结点
PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
int d = maxOrder; // 因为分配内存小于 8K所以从满二叉树最底层开始查找
synchronized (head) {
int id = allocateNode(d); // 在满二叉树中找到一个可用的节点
if (id < 0) {
return id;
}
final PoolSubpage<T>[] subpages = this.subpages; // 记录哪些 Page 被转化为 Subpage
final int pageSize = this.pageSize;
freeBytes -= pageSize;
int subpageIdx = subpageIdx(id); // pageId 到 subpageId 的转化,例如 pageId=2048 对应的 subpageId=0
PoolSubpage<T> subpage = subpages[subpageIdx];
if (subpage == null) {
// 创建 PoolSubpage并切分为相同大小的子内存块然后加入 PoolArena 对应的双向链表中
subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
subpages[subpageIdx] = subpage;
} else {
subpage.init(head, normCapacity);
}
return subpage.allocate(); // 执行内存分配并返回内存地址
}
}
假如我们需要分配 20B 大小的内存,一起分析下上述源码的执行过程:
因为 20B 小于 512B属于 Tiny 场景,按照内存规格的分类 20B 需要向上取整到 32B。
根据内存规格的大小找到 PoolArena 中 tinySubpagePools 数组对应的头结点32B 对应的 tinySubpagePools[1]。
在满二叉树中寻找可用的节点用于内存分配,因为我们分配的内存小于 8K所以直接从二叉树的最底层开始查找。假如 2049 节点是可用的,那么返回的 id = 2049。
找到可用节点后,因为 pageIdx 是从叶子节点 2048 开始记录索引,而 subpageIdx 需要从 0 开始的,所以需要将 pageIdx 转化为 subpageIdx例如 2048 对应的 subpageIdx = 02049 对应的 subpageIdx = 1以此类推。
如果 PoolChunk 中 subpages 数组的 subpageIdx 下标对应的 PoolSubpage 不存在,那么将创建一个新的 PoolSubpage并将 PoolSubpage 切分为相同大小的子内存块,示例对应的子内存块大小为 32B最后将新创建的 PoolSubpage 节点与 tinySubpagePools[1] 对应的 head 节点连接成双向链表。
最后 PoolSubpage 执行内存分配并返回内存地址。
接下来我们跟进一下 subpage.allocate() 源码,看下 PoolSubpage 是如何执行内存分配的,源码如下:
long allocate() {
if (elemSize == 0) {
return toHandle(0);
}
if (numAvail == 0 || !doNotDestroy) {
return -1;
}
final int bitmapIdx = getNextAvail(); // 在 bitmap 中找到第一个索引段,然后将该 bit 置为 1
int q = bitmapIdx >>> 6; // 定位到 bitmap 的数组下标
int r = bitmapIdx & 63; // 取到节点对应一个 long 类型中的二进制位
assert (bitmap[q] >>> r & 1) == 0;
bitmap[q] |= 1L << r;
if (-- numAvail == 0) {
removeFromPool(); // 如果 PoolSubpage 没有可分配的内存块 PoolArena 双向链表中删除
}
return toHandle(bitmapIdx);
}
PoolSubpage 通过位图 bitmap 记录每个内存块是否已经被使用在上述的示例中8K/32B = 256因为每个 long 64 所以需要 25664 = 4 long 类型的即可描述全部的内存块分配状态因此 bitmap 数组的长度为 4 bitmap[0] 开始记录每分配一个内存块就会移动到 bitmap[0] 中的下一个二进制位直至 bitmap[0] 的所有二进制位都赋值为 1然后继续分配 bitmap[1]以此类推当我们使用 2049 节点进行内存分配时bitmap[0] 中的二进制位如下图所示
bitmap 分成成功后PoolSubpage 会将可用节点的个数 numAvail 1 numAvail 降为 0 表示 PoolSubpage 已经没有可分配的内存块此时需要从 PoolArena tinySubpagePools[1] 的双向链表中删除
至此整个 PoolChunk Subpage 的内存分配过程已经完成了可见 PoolChunk 的伙伴算法几乎贯穿了整个流程位图 bitmap 的设计也是非常巧妙的不仅节省了内存空间而且加快了定位内存块的速度
PoolThreadCache 的内存分配
上节课已经介绍了 PoolThreadCache 的基本概念我们知道 PoolArena 分配的内存被释放时Netty 并没有将缓存归还给 PoolChunk而是使用 PoolThreadCache 缓存起来当下次有同样规格的内存分配时直接从 PoolThreadCache 取出使用即可所以下面我们从 PoolArena#allocate() 的源码中看下 PoolThreadCache 是如何使用的
private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
final int normCapacity = normalizeCapacity(reqCapacity);
if (isTinyOrSmall(normCapacity)) { // capacity < pageSize
int tableIdx;
PoolSubpage<T>[] table;
boolean tiny = isTiny(normCapacity);
if (tiny) { // < 512
if (cache.allocateTiny(this, buf, reqCapacity, normCapacity)) {
return;
}
tableIdx = tinyIdx(normCapacity);
table = tinySubpagePools;
} else {
if (cache.allocateSmall(this, buf, reqCapacity, normCapacity)) {
return;
}
tableIdx = smallIdx(normCapacity);
table = smallSubpagePools;
}
// 省略其他代码
}
if (normCapacity <= chunkSize) {
if (cache.allocateNormal(this, buf, reqCapacity, normCapacity)) {
return;
}
synchronized (this) {
allocateNormal(buf, reqCapacity, normCapacity);
++allocationsNormal;
}
} else {
allocateHuge(buf, reqCapacity);
}
}
从源码中可以看出在分配 TinySmall Normal 类型的内存时都会尝试先从 PoolThreadCache 中进行分配源码结构比较清晰我们整体梳理一遍流程
对申请的内存大小做向上取整例如 20B 的内存大小会取整为 32B
当申请的内存大小小于 8K 分为 Tiny Small 两种情况分别都会优先尝试从 PoolThreadCache 分配内存如果 PoolThreadCache 分配失败才会走 PoolArena 的分配流程
当申请的内存大小大于 8K但是小于 Chunk 的默认大小 16M属于 Normal 的内存分配也会优先尝试从 PoolThreadCache 分配内存如果 PoolThreadCache 分配失败才会走 PoolArena 的分配流程
当申请的内存大小大于 Chunk 16M则不会经过 PoolThreadCache直接进行分配
PoolThreadCache 具体分配内存的过程使用到了一个重要的数据结构 MemoryRegionCache关于 MemoryRegionCache 的概念你可以回顾下上节课的内容在这里我就不再赘述了假如我们现在需要分配 32B 大小的堆外内存会从 MemoryRegionCache 数组 tinySubPageDirectCaches[1] 中取出对应的 MemoryRegionCache 节点尝试从 MemoryRegionCache 的队列中取出可用的内存块
内存回收实现原理
通过之前的介绍我们知道当用户线程释放内存时会将内存块缓存到本地线程的私有缓存 PoolThreadCache 这样在下次分配内存时会提高分配效率但是当内存块被用完一次后再没有分配需求那么一直驻留在内存中又会造成浪费接下来我们就看下 Netty 是如何实现内存释放的呢直接跟进下 PoolThreadCache 的源码
private boolean allocate(MemoryRegionCache<?> cache, PooledByteBuf buf, int reqCapacity) {
if (cache == null) {
return false;
}
// 默认每执行 8192 次 allocate(),就会调用一次 trim() 进行内存整理
boolean allocated = cache.allocate(buf, reqCapacity);
if (++ allocations >= freeSweepAllocationThreshold) {
allocations = 0;
trim();
}
return allocated;
}
void trim() {
trim(tinySubPageDirectCaches);
trim(smallSubPageDirectCaches);
trim(normalDirectCaches);
trim(tinySubPageHeapCaches);
trim(smallSubPageHeapCaches);
trim(normalHeapCaches);
}
从源码中可以看出Netty 记录了 allocate() 的执行次数,默认每执行 8192 次,就会触发 PoolThreadCache 调用一次 trim() 进行内存整理,会对 PoolThreadCache 中维护的六个 MemoryRegionCache 数组分别进行整理。我们继续跟进 trim 的源码,定位到核心逻辑。
public final void trim() {
int free = size - allocations;
allocations = 0;
// We not even allocated all the number that are
if (free > 0) {
free(free, false);
}
}
private int free(int max, boolean finalizer) {
int numFreed = 0;
for (; numFreed < max; numFreed++) {
Entry<T> entry = queue.poll();
if (entry != null) {
freeEntry(entry, finalizer);
} else {
// all cleared
return numFreed;
}
}
return numFreed;
}
通过 size - allocations 衡量内存分配执行的频繁程度,其中 size 为该 MemoryRegionCache 对应的内存规格大小size 为固定值,例如 Tiny 类型默认为 512。allocations 表示 MemoryRegionCache 距离上一次内存整理已经发生了多少次 allocate 调用,当调用次数小于 size 时,表示 MemoryRegionCache 中缓存的内存块并不常用,从队列中取出内存块依次释放。
此外 Netty 在线程退出的时候还会回收该线程的所有内存PoolThreadCache 重载了 finalize() 方法,在销毁前执行缓存回收的逻辑,对应源码如下:
@Override
protected void finalize() throws Throwable {
try {
super.finalize();
} finally {
free(true);
}
}
void free(boolean finalizer) {
if (freed.compareAndSet(false, true)) {
int numFreed = free(tinySubPageDirectCaches, finalizer) +
free(smallSubPageDirectCaches, finalizer) +
free(normalDirectCaches, finalizer) +
free(tinySubPageHeapCaches, finalizer) +
free(smallSubPageHeapCaches, finalizer) +
free(normalHeapCaches, finalizer);
if (numFreed > 0 && logger.isDebugEnabled()) {
logger.debug("Freed {} thread-local buffer(s) from thread: {}", numFreed,
Thread.currentThread().getName());
}
if (directArena != null) {
directArena.numThreadCaches.getAndDecrement();
}
if (heapArena != null) {
heapArena.numThreadCaches.getAndDecrement();
}
}
}
线程销毁时 PoolThreadCache 会依次释放所有 MemoryRegionCache 中的内存数据,其中 free 方法的核心逻辑与之前内存整理 trim 中释放内存的过程是一致的,有兴趣的同学可以自行翻阅源码。
到此为止,整个 Netty 内存池的分配和释放原理我们已经分析完了,其中巧妙的设计思路以及源码细节的实现,都是非常值得我们学习的宝贵资源。
总结
最后,我们对 Netty 内存池的设计思想做一个知识点总结:
分四种内存规格管理内存,分别为 Tiny、Samll、Normal、HugePoolChunk 负责管理 8K 以上的内存分配PoolSubpage 用于管理 8K 以下的内存分配。当申请内存大于 16M 时,不会经过内存池,直接分配。
设计了本地线程缓存机制 PoolThreadCache用于提升内存分配时的并发性能。用于申请 Tiny、Samll、Normal 三种类型的内存时,会优先尝试从 PoolThreadCache 中分配。
PoolChunk 使用伙伴算法管理 Page以二叉树的数据结构实现是整个内存池分配的核心所在。
每调用 PoolThreadCache 的 allocate() 方法到一定次数,会触发检查 PoolThreadCache 中缓存的使用频率,使用频率较低的内存块会被释放。
线程退出时Netty 会回收该线程对应的所有内存。
Netty 中引入类似 jemalloc 的内存池管理技术可以说是一大突破,将 Netty 的性能又提升了一个台阶,而这种思想不仅可以用于 Netty在很对缓存的场景下都可以借鉴学习希望这些优秀的设计思想能够对你有所帮助在实际工作中学以致用。