👆 这是第 367 篇不掺水的原创,想要了解更多,请戳下方卡片关注我们吧~
Netty 中的内存管理的实现并不是一蹴而就的,它也是参考了 Jemalloc 内存分配器。而 Jemalloc 又借鉴了 Tcmalloc(出身于 Google,通过红黑树来管理内存快和分页,带有线程缓存。对于小的对象来说,直接由线程的局部缓存来完成,大对象那就由自旋锁来减少多线程下的竞争)的设计思路,但是 Jemalloc 设计的更复杂,虽然也有线程缓存的特性,但是 Jemalloc 将内存分配的粒度划分为 Small、Large、Huge 三个分类,在空间的占用上比较多,但是在大内存分配的场景,内存碎片就略少 。
虽然有众多的内存分配器,但是它们的核心都是一致的:
这边有个内存碎片的概念,可以介绍下,Linux 中物理内存会被分成若干个 4k 大小的内存页 Page,物理内存的分配和回收都是基于 Page 完成的,内部碎片就是 Page 内部产生的碎片,外部碎片就是各个 Page 之间产生的碎片。
内部碎片:
即使我们只需要很小的内存,系统也至少会分配 4k 大小的 Page
外部碎片:
当我们分配大内存的时候,此时一个 Page (4k) 显然不够,此时,系统会分配连续的 Page 才能够满足要求,但是,我们的程序在不断的运行,这些 Page 会被频繁的回收,然后重新分配,难免这些 Page 之间会出现空闲的内存块,这就形成了外部碎片
对于内存分配的肯定有内存分配的一些算法,本篇文章主要分析 Netty 的内存分配;
Netty 内存根据使用的内存位置(堆内 Heap 和堆外 Direct)和内存是否池化进行分类。
对于每个线程而言,Netty 会为之分配一个内存 Cache;而在多个线程之间可共享一个 Arena。Arena 管理着相关内存,包含不同使用率的 PoolChunkList、TinySubPagePools 及 SmallSubPagePools 来更好地分配内存。
内存根据大小可分为 Huge、Normal、Small、Tiny。
Huge
初次申请内存,都是按照 Chunk 来申请,但是为了更高效率的使用内存,在 Chunk 这个级别下,还定义了 Page 和 SubPage 的内存块。
Chunk : 是 Netty 向操作系统申请内存的单位,所有的内存分配操作都是基于 Chunk 完成的,默认大小是16M。在分配大小超过 8K的内存,会从 PoolChunkList 中分配内存,或新增 Chunk。一个 Chunk 会被分成 2048 个 Page,是一个完全二叉树。一般每层节点有一个标识,标识当前节点及以下节点是否还有可用节点。
Page:是 Chunk 用于管理内存的单位,Netty 中的 Page 的大小为 8k,假如需要分配 64K 的内存,需要在 Chunk 中选取4个 Page 进行分配。
SubPage:负责 Page 内的内存分配,假如我们分配的内存大小远小于 Page(8K),直接分配一个 Page 会造成严重的内存浪费,所以需要将 Page 划分为多个相同的子块来进行分配,这里的子块就相当于 SubPage。SubPage 也分为两种不同的规格,在 Tiny 场景下,最小的划分为 16B,然后按 16B 依次递增;在 Small 场景下,就分为 4 种规格,512B、1024B、2048B、4096B。
Netty 借鉴了 Jemalloc 中的 Arena 的设计思想,采用固定数量的多个 Arena 进行内存分配,Arena 的默认数量与 CPU 的核数有关,通过创建多个 Arena 来缓解资源竞争的问题,提高了内存分配的效率。线程在首次申请分配内存时,会轮询 Arena 数量,选择一个固定的Arena,在线程的生命周期内只与该 Arena 打交道,所以每个线程都保存了 Arena 的信息,从而提高访问的效率。
PoolArena 的数据结构包含了两个 PoolSubPage 数组,和六个 PoolChunkList,这两个 PoolSubPage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向链表。
// 内存使用率为100%的Chunk
q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, chunkSize);
// 内存使用率为75~100%的Chunk
q075 = new PoolChunkList<T>(this, q100, 75, 100, chunkSize);
// 内存使用率为50~100%的Chunk
q050 = new PoolChunkList<T>(this, q075, 50, 100, chunkSize);
// 内存使用率为25~75%的Chunk
q025 = new PoolChunkList<T>(this, q050, 25, 75, chunkSize);
// 内存使用率为1~50%的Chunk
q000 = new PoolChunkList<T>(this, q025, 1, 50, chunkSize);
// 内存使用率为0~25%的Chunk
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, chunkSize);
q100.prevList(q075);
q075.prevList(q050);
q050.prevList(q025);
q025.prevList(q000);
q000.prevList(null);
qInit.prevList(qInit);
六种类型的 PoolChunkList 除了 qInit,它们都形成了双向链表.
qInit 用于存储初始化分配的 PoolChunk,在第一次内存分配时,PoolChunkList 中并没有可用的 PoolChunk,所以需要新创建一个 PoolChunk 并添加到 qInit 列表中。qInit 中的 PoolChunk 即使内存被完全释放也不会被回收,避免了 PoolChunk 的重复初始化工作。
内存池的初始阶段线程是没有内存缓存的,所以最开始的内存分配都需要在全局分配区进行分配
无论是 TinySubpagePools 还是 SmallSubpagePools 成员在内存池初始化时是不会预置内存的,所以最开始内存分配阶段都会进入 PoolArena 的 allocateNormal 方法
private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
// 1.尝试从现有的 Chunk 进行分配
if (q050.allocate(buf, reqCapacity, normCapacity)
|| q025.allocate(buf, reqCapacity, normCapacity)
|| q000.allocate(buf, reqCapacity, normCapacity)
|| qInit.allocate(buf, reqCapacity, normCapacity)
|| q075.allocate(buf, reqCapacity, normCapacity)) {
return;
}
// Add a new chunk 2.尝试创建一个 Chuank 进行内存分配
PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
boolean success = c.allocate(buf, reqCapacity, normCapacity);
assert success;
// 4.将 PoolChunk 添加到 PoolChunkList 中
qInit.add(c);
}
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
final long handle;
// >= pageSize 通过位运算是否大于 512k
if ((normCapacity & subpageOverflowMask) != 0) {
handle = allocateRun(normCapacity);
} else {
handle = allocateSubpage(normCapacity);
}
if (handle < 0) {
return false;
}
ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null;
// 3.初始化 PooledByteBuf
initBuf(buf, nioBuffer, handle, reqCapacity);
return true;
}
分配内存时为什么选择从 q050 开始
1、qinit 的 Chunk 利用率低,但不会被回收。 2、q075 和 q100 由于内存利用率太高,导致内存分配的成功率大大降低,因此放到最后。 3、q050 保存的是内存利用率 50%~100% 的 Chunk,这应该是个折中的选择。这样能保证 Chunk 的利用率都会保持在一个较高水平提高整个应用的内存利用率,并且内存利用率在 50%~100% 的 Chunk 内存分配的成功率有保障。 4、当应用在实际运行过程中碰到访问高峰,这时需要分配的内存是平时的好几倍需要创建好几倍的 Chunk,如果先从 q000 开始,这些在高峰期创建的 Chunk 被回收的概率会大大降低,延缓了内存的回收进度,造成内存使用的浪费。
PoolChunkList 负责管理多个 PoolChunk 的生命周期,同一个 PoolChunkList 中存放了内存相近的 PoolChunk,通过双向链表的形式链接在一起,因为 PoolChunk 经常要从 PoolChunkList 中删除,而且需要在不同的 PoolChunkList 中移动,所以双向链表是管理 PoolChunk 时间复杂度较低的数据结构。
final class PoolChunkList<T> implements PoolChunkListMetric {
private static final Iterator<PoolChunkMetric> EMPTY_METRICS = Collections.<PoolChunkMetric>emptyList().iterator();
private final PoolArena<T> arena;
// 下一个PoolChunkList(使用率更高的)
private final PoolChunkList<T> nextList;
// 最低使用率,低于该值,会移除该chunk,放到preList中
private final int minUsage;
// 最高使用率,高于该值,会移除该chunk,放到nextList中
private final int maxUsage;
// 最大可分配的内存大小,就是用minUsage计算的
private final int maxCapacity;
private PoolChunk<T> head;
// This is only update once when create the linked
// like list of PoolChunkList in PoolArena constructor.
// 前一个PoolChunkList(使用率更低的)
private PoolChunkList<T> prevList;
每个 PoolChunkList 都有内存使用率的上下限:minUsage 和 maxUsage,当 PoolChunk 进行内存分配后,如果使用率超过 maxUsage,那么 PoolChunk 会从当前 PoolChunkList 中删除,并移动到下一个 PoolChunkList;同理,PoolChunk 中的内存发生释放后,使用率小于 minUsage,那么 PoolChunk 会从当前 PoolChunkList 中移除,移动到前一个 PoolChunk List。
再细看下上面的各个部分的内存使用率会有交叉重叠的部分,这样设计的原因是,因为 PoolChunk 需要在 PoolChunkList 中不断的移动,如果每个 PoolChunkList 的内存使用率的临界值都是恰好衔接的,例如 1%~50%,50%(51%)~70%,如果 PoolChunk 的使用率在 45%~55% 之间不停徘徊的话,那么就会导致 PoolChunk 在两个 PoolChunkList 不断移动,造成性能损耗。
Netty 内存的分配和回收都是基于 PoolChunk 完成的,PoolChunk 是真正存储内存数据的地方,每个 PoolChunk 的默认大小为 16M
final class PoolChunk<T> implements PoolChunkMetric {
final PoolArena<T> arena;
// 存储的数据
final T memory;
// 满二叉树中的节点是否被分配,数组大小为 4096
private final byte[] memoryMap;
// 满二叉树中的节点高度,数组大小为 4096
private final byte[] depthMap;
// PoolChunk 中管理的 2048 个 8K 内存块
private final PoolSubpage<T>[] subpages;
// 剩余的内存大小
private int freeBytes;
PoolChunkList<T> parent;
PoolChunk<T> prev;
PoolChunk<T> next;
// 省略其他代码
}
PoolChunk 我们可以理解为 Page(8K) 的集合 ,Page 只是一种抽象的概念,实际在 Netty 中 Page 指的是 PoolChunk 所管理的子内存块,每个子内存块采用 PoolSubpage 表示
maxOrder = 11;
maxSubpageAllocs = 1 << maxOrder;
// Generate the memory map.
memoryMap = new byte[maxSubpageAllocs << 1];
depthMap = new byte[memoryMap.length];
int memoryMapIndex = 1;
// move down the tree one level at a time
for (int d = 0; d <= maxOrder; ++ d) {
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 ++;
}
}
deptMap 用于存放节点所对应的高度。例如第 2048 个节点 depthMap[1025] = 10
memoryMap 用于记录二叉树节点分配的信息,初始值和 deptMap 是一样的,随着节点被分配,不仅节点的值会改变,而且会递归遍历更新其父节点的值,父节点的值取两个子节点中的最小值。
subpages 对应上图中 PoolChunk 内部的 Page0,Page1 等。Netty 中没有 Page 的定义,直接使用 PoolSubPage 表示。当分配的内存小于 8k 是,PoolChunk 中的每个 Page 节点会被划分成为更小的粒度的内存进行管理,小内存块同样以 PoolSubPage 管理。
private long allocateSubpage(int normCapacity) {
// Obtain the head of the PoolSubPage pool that is owned by the PoolArena and synchronize on it.
// This is need as we may add it back and so alter the linked-list structure.
PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
// subpages are only be allocated from pages i.e., leaves
int d = maxOrder;
synchronized (head) {
int id = allocateNode(d);
if (id < 0) {
return id;
}
final PoolSubpage<T>[] subpages = this.subpages;
final int pageSize = this.pageSize;
freeBytes -= pageSize;
int subpageIdx = subpageIdx(id);
PoolSubpage<T> subpage = subpages[subpageIdx];
if (subpage == null) {
subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
subpages[subpageIdx] = subpage;
} else {
subpage.init(head, normCapacity);
}
return subpage.allocate();
}
}
PoolSubpage<T> findSubpagePoolHead(int elemSize) {
int tableIdx;
PoolSubpage<T>[] table;
// < 512
if (isTiny(elemSize)) {
tableIdx = elemSize >>> 4;
table = tinySubpagePools;
} else {
tableIdx = 0;
elemSize >>>= 10;
while (elemSize != 0) {
elemSize >>>= 1;
tableIdx ++;
}
table = smallSubpagePools;
}
return table[tableIdx];
}
根据代码可以看出,小内存分配的场景下,会首先找到对应的 PoolArena,然后根据计算出对应的
TinySubpagePools 或者 SmallSubpagePools 数组对应的下标,如果对应数组元素所包含的 PoolSubpage 链表不存在任何节点,那么将创建新的 PoolSubpage 加入链表中
final class PoolSubpage<T> implements PoolSubpageMetric {
final PoolChunk<T> chunk;
// 对应满二叉树节点的下标
private final int memoryMapIdx;
// PoolSubpage 在 PoolChunk 中 memory 的偏移量
private final int runOffset;
// 记录每个小内存块的状态
private final long[] bitmap;
// 与 PoolArena 中 tinySubpagePools 或 smallSubpagePools 中元素连接成双向链表
PoolSubpage<T> prev;
PoolSubpage<T> next;
// 每个小内存块的大小
int elemSize;
// 最多可以存放多少小内存块:8K/elemSize
private int maxNumElems;
// 可用于分配的内存块个数
private int numAvail;
// 省略其他代码
}
PoolSubpage 是通过位图 bitmap 来记录子内存是否已经被使用,bit 的取值为 0 或 1
那 PoolSubPage 和 PoolArea 怎样联系起来的呢:
PoolArea 在创建是会初始化 TinySubpagePools 和 SmallSubpagePools 两个 PoolSubpage 数组,数组的大小分别是 32 和 4 加入我们分配 20B 大小的内存,会向上取整到 32B,从满二叉树的第 11 层找到一个 PoolSubpage 节点,并把它分为 8KB/32B = 256 个小内存块,然后找到这个 PoolSubpage 节点对应的 PoolArena,然后将 PoolSubPage 节点与 tinySubpagePools[1] 对应的 head 节点链接成双向链表。
如果后续再有 32B 规格的内存分配时,直接从 PoolArena 中 tinySubpagePools[1] 元素的next节点是否存在可用的 PoolSubpage,如果存在直接使用该 PoolSubpage 执行内存分配,提高内存分配的使用效率。
当我们内存释放时,Netty 并没有将缓存归还到 PoolChunk 中,而是使用 PoolThreadCache (本地线程缓存),当下次我们有同样规格的内存分配时,如果缓存有,直接从缓存里面取出当前符合规格的内存。
假设我们依次申请了 8k、16k、8k 的内存
private long allocateRun(int normCapacity) {
// 根据分配内存大小计算树对应的节点高度 maxOrder 为二叉树的最大高度 11. , pageShifts 默认为13
int d = maxOrder - (log2(normCapacity) - pageShifts);
// 查找对应高度中是否存在可用节点
int id = allocateNode(d);
if (id < 0) {
return id;
}
// 减去以分配的内存大小
freeBytes -= runLength(id);
return id;
}
第一次在分配 8k 大小的内存时,计算得到二叉树所在节点高度为 11,8k= 2^13. 然后从第 11 层查找可用的 Page,下标为 2048 的节点可以被用于分配内存,即 page[0] 被分配使用,此时赋值 memoryMap[2048] =12,表示该节点已经不可用,然后递归更新父节点的值,父节点的值取两个子节点的最小值,即 memoryMap[1024]=11,memory[512]=10。
第二次分配 16k 内存时,计算得到的节点高度是 10,此时 1024 节点已经分配了一个 8K 的内存,不满足条件,继续寻找 1025 节点,此节点并未使用过,满足分配的条件,就将 1025 的两个子节点分配出去,赋值,memoryMap[2050]=12,memoryMap[2051] = 12,然后在递归更新父节点的值。
第三次分配 8k 大小的内存时,依然从第 11 层开始查找,发现 2048 已经使用,2049 可以分配,赋值 memoryMap[2049] =12,然后递归更新父节点。
PoolChunk 不在分配单独的 Page,而是将 Page 划分为更小的内存块,由 PoolSubpage 进行管理
private long allocateSubpage(int normCapacity) {
// Obtain the head of the PoolSubPage pool
// that is owned by the PoolArena and synchronize on it.
// This is need as we may add it back and so alter the linked-list structure.
// 根据内存大小找到PoolArena中Subpage数组对应的头节点
PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
// 从最底层开始查找
// subpages are only be allocated from pages i.e., leaves
int d = maxOrder;
synchronized (head) {
//找到一个可用的节点
int id = allocateNode(d);
if (id < 0) {
return id;
}
//把转化为Subpage的Page给记录下来
final PoolSubpage<T>[] subpages = this.subpages;
final int pageSize = this.pageSize;
freeBytes -= pageSize;
//pageId 到subpageId的转化,pageId=2048 subpageId = 0
int subpageIdx = subpageIdx(id);
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 属于 Tiny 场景,按照内存规格的分类,20B 需要向上取整到 32B。在满二叉树中寻找可用的节点用于内存分配,假如 2049 节点时可用的,那么返回的 ID=2049,然后将 pageId 转换成了 subpageIdx, 2049 对应 1 ,如果 PoolChunk 中 subpages 数组的 subpageIdx 下标对应的 PoolSubpage 不存在,那么就新创建一个 PoolSubpage,并将 PoolSubpage 切分为相同大小的子内存块,这边对应的子内存块是32B,然后找到 PoolArena 中 tinySubpagePools 数组对应的头节点,32B 对应的tinySubpagePools[1] 的 head 节点连接成双向链表,最后执行内存分配返回内存地址。
PoolSubpage 通过位图 bitmap 记录每个内存块是否已经被使用。在上述的示例中,8K/32B = 256,因为每个 long 有 64 位,所以需要 256/64 = 4 个 long 类型的即可描述全部的内存块分配状态,因此 bitmap 数组的长度为 4,从 bitmap[0] 开始记录,每分配一个内存块,就会移动到 bitmap[0] 中的下一个二进制位,直至 bitmap[0] 的所有二进制位都赋值为 1,然后继续分配 bitmap[1]。
假如我们现在需要分配 32B 大小的堆外内存,会从 MemoryRegionCache 数组 tinySubPageDirectCaches[1] 中取出对应的 MemoryRegionCache 节点,尝试从 MemoryRegionCache 的队列中取出可用的内存块。
// 默认执行 8192 次 allocate(),就会调用 trim() 进行内存整理
boolean allocated = cache.allocate(buf, reqCapacity);
if (++ allocations >= freeSweepAllocationThreshold) {
allocations = 0;
trim();
}
void trim() {
trim(tinySubPageDirectCaches);
trim(smallSubPageDirectCaches);
trim(normalDirectCaches);
trim(tinySubPageHeapCaches);
trim(smallSubPageHeapCaches);
trim(normalHeapCaches);
}
private static void trim(MemoryRegionCache<?>[] caches) {
if (caches == null) {
return;
}
for (MemoryRegionCache<?> c: caches) {
trim(c);
}
}
public final void trim() {
/**
* 通过 size - allocations 衡量内存分配执行的频繁程度,
* 其中 size 为该 MemoryRegionCache 对应的内存规格大小,size 为固定值,
* 例如 Tiny 类型默认为 512。
* allocations 表示 MemoryRegionCache 距离上一次内存整理已经发生了多少次 allocate 调用,
* 当调用次数小于 size 时,
* 表示 MemoryRegionCache 中缓存的内存块并不常用,从队列中取出内存块依次释放。
*/
int free = size - allocations;
allocations = 0;
// We not even allocated all the number that are
if (free > 0) {
free(free, false);
}
}
// 最终会执行native方法 这是一个native方法
public native void freeMemory(long var1);
// 详见源码。PoolThreadCache # allocate
以上篇幅,主要是介绍了内存的分配的工作,以及其他的额外的特性;对内存管理有一定程度的认识;里面的内存释放,涉及到的操作细节非常多,例如内存合并操作;以及内存真正释放的时机;更多细节还是需要通过源码了解;这里列一下关键的 Netty 中的几个类: ServerChannelRecvByteBufAllocator:分配缓存大小的策略对象 PooledByteBufAllocator:字节缓存池分配器 PoolThreadCache:线程缓存对象 。
有兴趣的可以一起深入研究。
如果你觉得这篇内容对你挺有启发,我想邀请你帮我两件小事
1.点个「在看」,让更多人也能看到这篇内容(点了「在看」,bug -1 😊)
招贤纳士
政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。
如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com