如何理解netty的内存管理-PoolArena

最近花时间研究了下 Netty 的内存管理实现,感觉挺有意思的,但也明显地感觉到,这部分功能要比其他功能模块更难理解。在这个过程中,我一直在想,如果能有这么一张图,能够说明内存管理的模块组成、各个模块之间的关系以及其实现原理,那么我就可以以更短的时间读懂相关源码了。因此,我将自己的一些理解画成了图。如果你也对这块内容感兴趣,兴许这篇文章能有所帮助。

本文分析的 netty 版本是 4.1.51.Final。如果你是从 github 拉的最新源码,或者是 4.1.65.Final 之后的版本,由于已经同步了 jemalloc 4.x 的变更,有比较多的变化,请注意区分。

先来看下这张图,我将 PoolArenaPoolChunkListPoolChunkPoolSubpage 的关系画了出来。这张图有点大,你可以从上往下看,它就像一张地图,逐步放大到内部模块。通过这种方式,你可以更清楚地了解各个组件的作用以及它们之间的关系。

PoolArena

看到这张图的时候,你可能会对 PoolArena 是什么感兴趣。为什么我将它放在最上边,就是因为它是内存分配的入口,可以看做是内存池的 Facade

netty 可以基于内存池创建 ByteBuf 对象,也可以不使用内存池。PooledByteBufAllocator 是基于内存池的分配器。

heapByteBuf 是指的基于堆内存实现的 ByteBufdirectByteBuf 是指的基于 directMemory(堆外内存)实现的 ByteBuf

PooledByteBufAllocator 优先使用内存池创建 ByteBuf。如下是用于创建 heapByteBufnewHeapBuffer 方法,它先获取用于分配 heapByteBufPoolArena,然后通过 PoolArena 创建 ByteBuf 对象。这里先不用管 threadCachePoolThreadCache 是什么,我们后面再看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// PooledByteBufAllocator#newHeapBuffer
protected ByteBuf newHeapBuffer(int initialCapacity, int maxCapacity) {
PoolThreadCache cache = threadCache.get();
// 获取用于分配heapByteBuf的PoolArena
PoolArena<byte[]> heapArena = cache.heapArena;

final ByteBuf buf;
if (heapArena != null) {
// 通过PoolArena分配内存
buf = heapArena.allocate(cache, initialCapacity, maxCapacity);
} else {
buf = PlatformDependent.hasUnsafe() ?
new UnpooledUnsafeHeapByteBuf(this, initialCapacity, maxCapacity) :
new UnpooledHeapByteBuf(this, initialCapacity, maxCapacity);
}

return toLeakAwareBuffer(buf);
}

PoolArena 是如何创建 ByteBuf 的?要搞清楚这个问题,我们先要知道 PoolArena 的内部组成。虽然 PoolArena 内部有很多属性,但是你只需要知道它有下图所示的几个属性即可,其他的等用到的时候自然会明白。

PoolArena

接下来我们来一个一个地分析下这几个属性。

subpagePools

当分配内存时,netty 并非仅分配请求大小的内存空间,而是会根据请求大小进行计算,得出一个符合要求且最接近的内存规格,然后按照该内存规格分配空间。例如请求 15B 的内存空间,实际分配的内存大小并不是 15B,而是经过计算得到一个最小的内存规格 —— 16B。

PoolArena 内部共有两个 subpagePools —— tinySubpagePoolssmallSubpagePools,它们都是 PoolSubpage 类型的数组,分别用来创建 tiny 和 small 大小的 ByteBuf。tiny 是指的大小小于 512B,small 是指的大小大于等于 512B 且小于 8K

其中 tiny 分成了 31 种(16B、32B、48B、…、496B)、small 分成了 4 种(512B、1024B、2048B、4096B)。

tinySubpagePools 数组大小为 32,tinySubpagePools[0] 不用于内存分配,下标为 1、2、3、…、31的位置分别存储用于分配 16B、32B、48B、…、496B 大小内存的 PoolSubpage 链表。

subpagepools

从图中可见,下标为 2 的位置对应了一个 PoolSubpage 链表,用于分配大小为 32B 的内存空间。该链表为双向链表,尾结点的 next 指针指向了头结点。当链表只有一个节点时,它的 next 指针指向了自己。

smallSubpagePools 数组与 tinySubpagePools 数组的结构类似,就不再单独说明了。

PoolChunkList

PoolArena 中共有 6PoolChunkList,分别是 qInitq000q025q050q075q100,它们里面保存了不同内存占用率的 PoolChunk

  • qInit,保存了内存使用率为 0 ~ 25% 的 PoolChunk
  • q000,保存了内存使用率为 1 ~ 50% 的 PoolChunk
  • q025,保存了内存使用率为 25% ~ 75% 的 PoolChunk
  • q050,保存了内存使用率为 50% ~ 100% 的 PoolChunk
  • q075,保存了内存使用率为 75% ~ 100% 的 PoolChunk
  • q100,保存了内存使用率为 100% 的 PoolChunk

这 6 个 PoolChunkList 组成了一个双向链表,如下图所示:

PoolChunkList

PoolChunk 的内存使用率超过了所属 PoolChunkList 的上下限范围,则需要从其中移除,并尝试放入相邻的 PoolChunkList 中。随着使用率的变化,PoolChunk 会在不同的 PoolChunkList 间移动。

PoolChunkList 的内存使用率的定义上,相邻 PoolChunkList 的上下限有交叉。例如 q000PoolChunk 内存使用率上限是 50%q025PoolChunk 内存使用率的下限则是 25%,在 25% - 50% 范围内存在重叠。这样当 q000PoolChunk 的内存使用率超过 50% 后才会移入 q025,如果之后 PoolChunk 的内存使用率下降,它也并不会立刻移入到 q000,而是等到内存使用率小于 25% 时才会移入 q000,这样就可以避免因为内存使用率的波动导致 PoolChunk 在相邻的两个 PoolChunkList 之间频繁移动。

当从 PoolArenasubpagePools 中分配内存失败时,接下来就会尝试从这 6PoolChunkList 中分配内存。需要注意的是 PoolChunkList 的优先级顺序,并不是从 qInitq000开始,而是 q050 > q025 > q000 > qInit > q075,也就是说会优先从 q050 中查找 PoolChunk 并尝试分配内存,若分配失败再尝试 q025,以此类推。

PoolChunk

PoolChunkList 内部保存了一组 PoolChunk,它们通过双向链表的形式组织起来,如下图所示。

PoolChunkList1

若要分配的内存大小超过了 16MB,则认为内存过大,不需要做缓存,直接向系统申请内存,否则会通过缓存池管理。针对不超过 16MB 的场景,netty 会一次向系统申请 16MB 内存,这些内存通过 PoolChunk 进行管理。相对于创建对象所需的内存,16MB 还是比较大,因此 netty 又将 PoolChunk 划分为多个 PoolSubpage,通过 PoolSubpage 分配小块内存。

PoolChunk 内部共划分了 2048PoolSubpage,每个 PoolSubpage 的大小是 16MB / 2048 = 8KBPoolChunk 使用伙伴算法管理 PoolSubpage 的分配,它会根据请求的内存大小,每次分配一个或多个相邻的 PoolSubpage

除了 PoolSubpage 数组外, PoolChunk 内部还有 memoryMapdepthMap 两个 byte 数组,它们正是用来实现伙伴算法的关键。

PoolArena-PoolChunk

memoryMap和depthMap

memoryMapdepthMap 数组的大小均为 4096,除了下标为 0 的元素外,其余元素用于表示一个完全平衡二叉树。

如上图所示,在初始化之后,memoryMapdepthMap 每个元素存储的是节点所在的层。例如最上层只有 1 个元素,它所在层的 depth = 0,所以元素中存储的值也是 0。第二层(depth=1)有两个元素,每个元素中存储的值均为 1。最后一层(depth=11)有 2048 个元素,每个元素中存储的值是 11。需要注意的是,上述完全平衡二叉树中共有 2047 个元素,而数组大小是 2048,其中下标为 0 的元素并未使用,故未在图中画出。如下是 memoryMapdepthMap 的初始化代码,可以对照着理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// maxOrder=11
// maxSubpageAllocs=2048
maxSubpageAllocs = 1 << maxOrder;

// Generate the memory map.
// memoryMap.length=4096
memoryMap = new byte[maxSubpageAllocs << 1];
depthMap = new byte[memoryMap.length];
int memoryMapIndex = 1;

// 共4096个节点,不使用下标为0的元素,其余的按层设置order,如上图所示
for (int d = 0; d <= maxOrder; ++ d) { // move down the tree one level at a time
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 ++;
}
}

一个 PoolChunk 中共有 2048 个 PoolSubpage,它们可以与上述完全平衡二叉树的叶子节点一一对应,例如 subpages[0] 对应的是第一个叶子节点,即 depthMap[2048]memoryMap[2048]。当 subpages[0] 被分配出去时,就需要修改 memoryMap[2048] 的值以记录内存分配信息。

有时需要分配的内存超过了一个 PoolSubpage 的大小(8KB),这时就需要分配多个连续的叶子节点。例如一次请求的内存规格大小为 16KB,则需要两个 PoolSubpage 的空间,如果是 PoolChunk 第一次分配,则会返回 id=1024(即完全二叉树中下标为 1024 的节点),将内存分配信息记录到 memoryMap[1024],表示该节点下的所有叶子节点(subpages[0] 和 subpages[1])均已分配出去。

根据上面的分析,可以发现:在拿到所需的内存规格大小后,只需要到对应层查看是否有未分配的节点,然后进行内存分配。层数 depth 与可分配内存大小的关系如下:

1
2
3
4
5
6
7
depth=0        1个节点 (chunkSize)
depth=1 2个节点 (chunkSize/2)
..
..
depth=d 2^d个节点 (chunkSize/2^d)
..
depth=maxOrder 2^maxOrder个节点 (chunkSize/2^{maxOrder} = pageSize)

第 0 层只有一个节点,代表整个 PoolChunk 的内存空间。第 1 层有两个节点,每个节点的大小为 1/2PoolChunk 大小。以此类推,第 d 层共有 2^d 个节点,每个节点的大小为 chunkSize/2^d。据此可以得到,最后一层每个节点的大小为 chunkSize/2^maxOrder,因为最后一层是叶子节点,其大小为 pageSize,故 chunkSize/2^maxOrder=pageSize。将 pageSize=8KBchunkSize=16MB 代入得到 maxOrder=11

到现在为止还有一点没搞清楚,那就是 memoryMap 是如何记录内存分配信息的。memoryMap 中记录的值符合以下规则:

  1. memoryMap[id] = depth_of_id => 表示该节点下所有的叶子节点均未分配。例如 memoryMap[1024] = 10 表示 id=1024 的两个子节点 id=2048id=2049 对应的 PoolSubpage 均未分配,即 subpages[0]subpages[1] 还未分配。
  2. memoryMap[id] > depth_of_id => 表示至少有一个它的子节点已经分配出去。例如 memoryMap[1024] = 11 表示 id=1024 的某个子节点已经分配出去。
  3. memoryMap[id] = maxOrder + 1 => 表示该节点已经完全分配出去。例如 memoryMap[1024] = 12 表示 id=1024 的两个子节点均已分配出去。

PoolSubpage

一个 PoolSubpage 的大小为 8KB,可分配小于 8KB 的内存,此时 PoolSubpage 会按照请求的大小划分。例如当请求的大小为 32B 时,PoolSubpage 中会记录元素大小 elemSize = 32B、元素数量 maxNumElems = 8192 / 32 = 256bitmapLength = 4

你可能会有疑问:bitmapLength 是干什么的?实际上 PoolSubpage 内部使用位图记录内存的分配情况。PoolSubpage 能分配的最小内存大小为 16B,此时可分配的元素数量最多,为 8192 / 16 = 512 个,PoolSubpage 使用 long 数组作为位图,一个 long 类型的长度为 8B = 64 位,可记录 64 个元素的内存分配情况,故总共需要 8192 / 16 / 64 = 8 个 long 类型的空间,所以 bitmap 数组的长度为 8,可以确保能够保存所有情况下的内存分配情况。

PoolSubpage