如何理解netty的内存管理-PoolArena
最近花时间研究了下 Netty 的内存管理实现,感觉挺有意思的,但也明显地感觉到,这部分功能要比其他功能模块更难理解。在这个过程中,我一直在想,如果能有这么一张图,能够说明内存管理的模块组成、各个模块之间的关系以及其实现原理,那么我就可以以更短的时间读懂相关源码了。因此,我将自己的一些理解画成了图。如果你也对这块内容感兴趣,兴许这篇文章能有所帮助。
本文分析的 netty 版本是 4.1.51.Final。如果你是从 github 拉的最新源码,或者是 4.1.65.Final 之后的版本,由于已经同步了 jemalloc 4.x 的变更,有比较多的变化,请注意区分。
先来看下这张图,我将 PoolArena、PoolChunkList、PoolChunk、PoolSubpage 的关系画了出来。这张图有点大,你可以从上往下看,它就像一张地图,逐步放大到内部模块。通过这种方式,你可以更清楚地了解各个组件的作用以及它们之间的关系。
看到这张图的时候,你可能会对 PoolArena 是什么感兴趣。为什么我将它放在最上边,就是因为它是内存分配的入口,可以看做是内存池的 Facade。
netty 可以基于内存池创建
ByteBuf对象,也可以不使用内存池。PooledByteBufAllocator是基于内存池的分配器。
heapByteBuf是指的基于堆内存实现的ByteBuf。directByteBuf是指的基于directMemory(堆外内存)实现的ByteBuf
PooledByteBufAllocator 优先使用内存池创建 ByteBuf。如下是用于创建 heapByteBuf 的 newHeapBuffer 方法,它先获取用于分配 heapByteBuf 的 PoolArena,然后通过 PoolArena 创建 ByteBuf 对象。这里先不用管 threadCache 和 PoolThreadCache 是什么,我们后面再看。
1 | // PooledByteBufAllocator#newHeapBuffer |
PoolArena 是如何创建 ByteBuf 的?要搞清楚这个问题,我们先要知道 PoolArena 的内部组成。虽然 PoolArena 内部有很多属性,但是你只需要知道它有下图所示的几个属性即可,其他的等用到的时候自然会明白。
接下来我们来一个一个地分析下这几个属性。
subpagePools
当分配内存时,netty 并非仅分配请求大小的内存空间,而是会根据请求大小进行计算,得出一个符合要求且最接近的内存规格,然后按照该内存规格分配空间。例如请求 15B 的内存空间,实际分配的内存大小并不是 15B,而是经过计算得到一个最小的内存规格 —— 16B。
PoolArena 内部共有两个 subpagePools —— tinySubpagePools 和 smallSubpagePools,它们都是 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 链表。
从图中可见,下标为 2 的位置对应了一个 PoolSubpage 链表,用于分配大小为 32B 的内存空间。该链表为双向链表,尾结点的 next 指针指向了头结点。当链表只有一个节点时,它的 next 指针指向了自己。
smallSubpagePools 数组与 tinySubpagePools 数组的结构类似,就不再单独说明了。
PoolChunkList
PoolArena 中共有 6 个 PoolChunkList,分别是 qInit、q000、q025、q050、q075、q100,它们里面保存了不同内存占用率的 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 组成了一个双向链表,如下图所示:
若 PoolChunk 的内存使用率超过了所属 PoolChunkList 的上下限范围,则需要从其中移除,并尝试放入相邻的 PoolChunkList 中。随着使用率的变化,PoolChunk 会在不同的 PoolChunkList 间移动。
在 PoolChunkList 的内存使用率的定义上,相邻 PoolChunkList 的上下限有交叉。例如 q000 中 PoolChunk 内存使用率上限是 50%,q025 中 PoolChunk 内存使用率的下限则是 25%,在 25% - 50% 范围内存在重叠。这样当 q000 中 PoolChunk 的内存使用率超过 50% 后才会移入 q025,如果之后 PoolChunk 的内存使用率下降,它也并不会立刻移入到 q000,而是等到内存使用率小于 25% 时才会移入 q000,这样就可以避免因为内存使用率的波动导致 PoolChunk 在相邻的两个 PoolChunkList 之间频繁移动。
当从 PoolArena 的 subpagePools 中分配内存失败时,接下来就会尝试从这 6 个 PoolChunkList 中分配内存。需要注意的是 PoolChunkList 的优先级顺序,并不是从 qInit 或 q000开始,而是 q050 > q025 > q000 > qInit > q075,也就是说会优先从 q050 中查找 PoolChunk 并尝试分配内存,若分配失败再尝试 q025,以此类推。
PoolChunk
PoolChunkList 内部保存了一组 PoolChunk,它们通过双向链表的形式组织起来,如下图所示。
若要分配的内存大小超过了 16MB,则认为内存过大,不需要做缓存,直接向系统申请内存,否则会通过缓存池管理。针对不超过 16MB 的场景,netty 会一次向系统申请 16MB 内存,这些内存通过 PoolChunk 进行管理。相对于创建对象所需的内存,16MB 还是比较大,因此 netty 又将 PoolChunk 划分为多个 PoolSubpage,通过 PoolSubpage 分配小块内存。
PoolChunk 内部共划分了 2048 个 PoolSubpage,每个 PoolSubpage 的大小是 16MB / 2048 = 8KB。PoolChunk 使用伙伴算法管理 PoolSubpage 的分配,它会根据请求的内存大小,每次分配一个或多个相邻的 PoolSubpage。
除了 PoolSubpage 数组外, PoolChunk 内部还有 memoryMap 和 depthMap 两个 byte 数组,它们正是用来实现伙伴算法的关键。
memoryMap和depthMap
memoryMap 和 depthMap 数组的大小均为 4096,除了下标为 0 的元素外,其余元素用于表示一个完全平衡二叉树。
如上图所示,在初始化之后,memoryMap 和 depthMap 每个元素存储的是节点所在的层。例如最上层只有 1 个元素,它所在层的 depth = 0,所以元素中存储的值也是 0。第二层(depth=1)有两个元素,每个元素中存储的值均为 1。最后一层(depth=11)有 2048 个元素,每个元素中存储的值是 11。需要注意的是,上述完全平衡二叉树中共有 2047 个元素,而数组大小是 2048,其中下标为 0 的元素并未使用,故未在图中画出。如下是 memoryMap 和 depthMap 的初始化代码,可以对照着理解。
1 | // maxOrder=11 |
一个 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 | depth=0 1个节点 (chunkSize) |
第 0 层只有一个节点,代表整个 PoolChunk 的内存空间。第 1 层有两个节点,每个节点的大小为 1/2 个 PoolChunk 大小。以此类推,第 d 层共有 2^d 个节点,每个节点的大小为 chunkSize/2^d。据此可以得到,最后一层每个节点的大小为 chunkSize/2^maxOrder,因为最后一层是叶子节点,其大小为 pageSize,故 chunkSize/2^maxOrder=pageSize。将 pageSize=8KB、chunkSize=16MB 代入得到 maxOrder=11
到现在为止还有一点没搞清楚,那就是 memoryMap 是如何记录内存分配信息的。memoryMap 中记录的值符合以下规则:
memoryMap[id] = depth_of_id=> 表示该节点下所有的叶子节点均未分配。例如memoryMap[1024] = 10表示id=1024的两个子节点id=2048和id=2049对应的PoolSubpage均未分配,即subpages[0]和subpages[1]还未分配。memoryMap[id] > depth_of_id=> 表示至少有一个它的子节点已经分配出去。例如memoryMap[1024] = 11表示id=1024的某个子节点已经分配出去。memoryMap[id] = maxOrder + 1=> 表示该节点已经完全分配出去。例如memoryMap[1024] = 12表示id=1024的两个子节点均已分配出去。
PoolSubpage
一个 PoolSubpage 的大小为 8KB,可分配小于 8KB 的内存,此时 PoolSubpage 会按照请求的大小划分。例如当请求的大小为 32B 时,PoolSubpage 中会记录元素大小 elemSize = 32B、元素数量 maxNumElems = 8192 / 32 = 256、bitmapLength = 4。
你可能会有疑问:bitmapLength 是干什么的?实际上 PoolSubpage 内部使用位图记录内存的分配情况。PoolSubpage 能分配的最小内存大小为 16B,此时可分配的元素数量最多,为 8192 / 16 = 512 个,PoolSubpage 使用 long 数组作为位图,一个 long 类型的长度为 8B = 64 位,可记录 64 个元素的内存分配情况,故总共需要 8192 / 16 / 64 = 8 个 long 类型的空间,所以 bitmap 数组的长度为 8,可以确保能够保存所有情况下的内存分配情况。
- 2021-07-09
当多个线程使用同时同一个
PoolArena分配内存时,因为存在竞争关系,所以会导致内存分配性能下降。为了减少冲突,PooledByteBufAllocator会提供多个PoolArena,并通过PoolThreadCache分配给每个FastThread线程,同一个线程多次分配释放的过程中,还会使用到缓存,以降低多次内存分配的压力。 - 2021-06-16
Unpooled是 netty 提供的一个工具类,通过它可以方便地创建各种类型的ByteBuf。需要注意的是,通过Unpooled创建的是非池化的ByteBuf,在注重性能的场景需要使用各种PooledByteBuf。接下来我们就来看下Unpooled类是如何使用的。