Netty源码之内存管理(二)(4.1.44)
前面做了很多铺垫(Netty源码之内存管理(一)),带着大家熟悉了与内存分配相关的类的定义和分配逻辑。但并没有真正落实到 jemalloc 思想在源码是如何体现的。本章就是对 PoolChunk
逐字解析,死扣细节。在分析源码之前我们需要对分配的内存级别有一个清晰的定位,当分配 Huge
级别对象,直接使用 PoolChunk
包装,并没有复杂的分配逻辑。而对于 tiny&small&normal
级别来说,进行精细化的内存管理十分有必要的。
开局一张图:PoolChunk
本质就是维护这一棵满二叉树,这棵树默认管理 16MB
内存(这个值是可以手动设置)。memoryMap[]
是可变的数组,Netty 在这个数组上逻辑构建一棵满二叉树(当然也可以用链表之类的数据结构,但是随机索引效率不高,我们可以根据数组索引快速定位到某一层的第一个节点。但链表是不能做到的),depthMap[]
表示每个节点对应的深度,这是不可变的。一个 Long
型被分成高、低两部分,高 32 位记录小内存分配信息,低 32 位记录节点下标值。当分配 normal
级别内存时,只有低 32 位信息有用,它的值表示节点序号(起始值为 1),当分配 tiny&normal
级别内存时,高、低两部分确定某个 page
下的某个 subpage
。
还有一个比较有意思的是任意节点所管理的内存大小都是 2 的次幂,因此 Netty 会对用户申请的内存大小进行规格化的原因就在这里。任意规格值(当然不能超过 PoolChunkSize
)都能找到合适的节点,除非没有节点可满足当前内存申请,那只能新创建一个 PoolChunk
。还有另外一个疑问就是如何更新 memoryMap[]
数组呢,请看大屏幕:
index 表示节点索引,value 表示对应 memoryMap[index] 。
用户第一次申请 4MB 大小内存,由于内存大小确定,因此所在的层的位置也可以通过 maxOrder - (log2(normCapacity) - pageShifts)
确定,4MB 对应层数(也可理解为深度)为 2。
内存分配过程如下,其实对应方法 allocateNode(int)
实现逻辑: 首先判断节点 1 的使用状态: memoryMap1 != unusable(12) 表示节点 1 可用,但由于层数不匹配,所以获取子树节点 2,同时判断使用状态,发现 != 12 且层数不匹配,那就继续获取子节点 4,发现 !=12 且层数匹配,所以节点 4 就用作此次内存分配的节点,并更新 memoryMap[4]=12 表示节点 4 已使用,变量 handle
的低 32 位记录子节点位置信息,同时循环更新父节点的 memoryMap,父节点的值是子节点的 memoryMap 的最小值,所以 memoryMap[2]=2,memoryMap[1] =1。这样,第一次申请 4MB 大小内存就算完成了。
第二次申请 4MB 大小内存,当在第 2 层判断节点 4 的 memoryMap 值等于 12,会判断兄弟节点 5 是否满足分配。大家好好休会。
PoolChunk 内存分配
PoolChunk 是 jemalloc3.x 算法思想的体现,里面以 allocate
开头的 API 就是内存分配算法的实现。入口方法是 allocate(PooledByteBuf, int, int)
。
allocate(PooledByteBuf, int, int)
这个方法做的事情有:
- 根据申请内存大小选择合适的分配策略。具体为如果 >=pageSize,使用
allocateRun()
方法分配,否则使用allocateSubpage()
分配,它们都会返回句柄值 handle。 - 初始化
ByteBuf
。
源码如下:
1 | java复制代码// io.netty.buffer.PoolChunk#allocate |
allocateRun(int)
方法 allocateRun(int) 做的事情也不多,主要有:
- 根据规格值计算所对应的深度
d
- 调用
allocateNode(d)
完成内存分配 - 更新剩余空闲值
1 | java复制代码// io.netty.buffer.PoolChunk#allocateRun |
allocateNode(int)
终于到了内存分配的重头戏,它属于节点粒度的分配逻辑。整体思路并不难,前面也通过图解讲述过,但由于采用了太多位运算所以看起来有点头晕。所以我们先熟悉一下部分位运算公式,规定
id^=1
: id 为奇数则 -1,id 为偶数则 +1。这里用来获取偶数的兄弟节点。比如 id=2,则其兄弟节点为 id^=1 = 3。id<<=1
: 相当于id=id*2
,目的是跳转到节点 id 的左子节点。比如 id = 2,它的左子节点值为 4。1<<d
: 表示 1*2 。对在任意深度为d的节点,节点的索引值在 2 到 2-1 范围内。比如深度为 1,则索引值在 [2, 3],当深度为 2,索引值在 [4, 7] 范围内。initial=-(1 << d)
: 对 2取反,目的是用来判断与目标深度值 d 是否匹配,可以把 initial 可以看成是掩码。当匹配目标深度,有 id & initial == initial,若当前深度<目标深度,有 id & initial ==0。比如目标深度为 2,那 initial=-4,当id=1时,id&initial=0,说明还没有到达目标深度,获取最左子节点(id=id*2)2,此时 2 & initial=0,说明还没有到达目标深度,继续获取最左子节点 4,此时 4&-4=4,此时就找到了目标深度。然后就可以从左到右找寻空闲节点并进行内存分配。
allocateNode(int depth)
目标是在深度 d 中找到空闲的节点并,如果存在按规则更新 memoryMap 相应节点的值并返回节点序号。思路也是比较清晰,从头结点开始判断,如果可分配但深度不匹配则获取左子节点,如果不可分配就返回 -1。继续判断左子节点是否可分配以及深度是否匹配,如果都不匹配,继续重复上面步骤。如果深度匹配但当前节点不可分配(val>d=true),那就获取兄弟节点继续重复上述步骤。如果深度匹配且当前节点可分配,则该节点就是此次申请的目标节点,并将它设置为 unusable 不可用状态,同时,按公式 memoryMap[父节点] = Min(子节点1,子节点2) 循环更新 memoryMap。
还有一个有意思的点需要注意,就是 memoryMap 存储的值,它是这棵树的核心。初始化的值以及后续更新也做得非常巧妙,我说不上来,大家慢慢休会吧。通过内存分配示意图再来休会一下上面的文字描述:
相关源码解析如下
1 | java复制代码// io.netty.buffer.PoolChunk#allocateNode |
allocateNode(int depth)
是分配 Normal
级别的核心方法,本质是维护 memoryMap[]
数组,遍历树查找空闲内存满足本次内存申请。
allocateSubpage(int)
这个方法是申请 Tiny&Small
级别内存。上一章节讲过对该内存分配的思想: 简单一句话,将某个空闲的 page 拆分成若干个 subpage,使用对象 PoolSubpage
对这些若干个 subpage 进行管理。
1 | java复制代码// io.netty.buffer.PoolChunk#allocateSubpage |
这里对 allocateSubpage(int)
源码做个小总结: 这个方法主要的目的是创建一个 PoolSubpage
对象,然后委托这个对象完成 tiny&small
级别内存分配。PoolChunk 在内部使用 PoolSubage[] 数组保存 PoolSubpage 对象引用。PoolSubage[] 长度和二叉树的叶子节点个数相同,它们一一对应。PoolSubpage 对象是 page 的化身,它拥有管理 pageSize 大小内存的能力。PoolChunk 只需管理 page,两者分工明确。
PoolSubpage 内存分配
PoolSubpage 内部相关变量之前已经解释过,在这里解释的。它管理内存的逻辑是将 pageSize 大小的内存块等分成若干个了块,子块个数是根据本次申请内存大小所决定,比如申请 1KB 内存,那么会找到一个空闲的 page 并将其拆分成 8 等份(8KB/1KB=8)。并使用位图记录每份子块的使用状态,1 表示已使用,0 表示未使用。最多可分为 512 等份,底层使用 long[] 数组存储位图信息。64 位的句柄值的高 32 位存储位信息,低 32 位存储储节点索引值。因此,核心的问题是如何使用 long[] 数组记录使用情况呢?
源码之下无秘密:
1 | java复制代码// io.netty.buffer.PoolSubpage#allocate |
bitmap 填充示意图
小结上面的源码: PoolSubpage 使用 8 个 long 值存储子块的使用情况,句柄高 32 位存储位图索引值,低 32 位存储 节点索引值。通过大量的位运算提高了性能,通过源码阅读,也提升了位编程应用技巧。
这有一个关于 PoolSubpage 的是如何和 PoolArena 配合使用,因为我们知道,PoolArena 也存有 PoolSubpage[] 数组对象,这些数组对象是怎么被添加的呢?答案在在初始化 PoolSubpage 时就添加到对应的 PoolArena#poolsubpage[] 中了。源码如下:
1 | java复制代码// io.netty.buffer.PoolSubpage |
以上,就是对 PoolSubpage 内存分析的源码解析,理清思路,功能拆解之后并不困难。
PoolChunk 如何回收内存
讲完了物理内存分配,还没有讲 PoolChunk 是如何回收内存。
1 | java复制代码// io.netty.buffer.PoolChunk#free |
从源码可看出,PoolChunk 回收一块内存十分简单。回收 PoolSubpage 稍微麻烦一点,因为还需要和 PoolArena 中的 PoolSubpage 保持同步。
PoolSubpage 如何回收内存
1 | java复制代码// io.netty.buffer.PoolSubpage#free |
PoolSubpage 需要照顾到 PoolArena 的 PoolSubpage[] 变量,所以稍微代码量多一点。但逻辑十分清楚。看代码就十分明白,我就不强行总结了。
如何回收整个 PoolChunk
核心代码在 PoolArena
,根据有无 Cleaner
释放内存 memory
对象即可。
1 | java复制代码// io.netty.buffer.PoolArena.DirectArena#destroyChunk |
小结
Netty 的内存回收是庞大的,两篇文章从 ByteBuf 体系结构讲到源码级的内存分配实现,似乎还是没有讲全讲透。这里只不过把最核心的代码拧出来给大家口味,最终还是希望看到这些文章的各位 DEBUG 调试走一遍。我深知自己的知识能力水平有限,文章部分地方的表达能力欠缺,有些简单的地方描述过于复杂,而恰恰需要讲清楚的地方一笔带过,敬请读者斧正。
我的公众号
搜索 小道一下
本文转载自: 掘金