如何理解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
类是如何使用的。