1. 概述
老艿艿:如下阐释的内容,参考 Hypercube 《自顶向下深入分析Netty(十)–JEMalloc分配算法》 。
为了提高内存分配效率并减少内存碎片,Jemalloc 算法将每个 Arena 切分成多个小块 Chunk 。但是实际上,每个 Chunk 依然是相当大的内存块。因为在 Jemalloc 建议为 4MB ,Netty 默认使用为 16MB 。
为了进一步提供提高内存分配效率并减少内存碎片,Jemalloc 算法将每个 Chunk 切分成多个小块 Page 。一个典型的切分是将 Chunk 切分为 2048 块 Page ,Netty 也是如此,因此 Page 的大小为:16MB / 2048 = 8KB
。
一个好的内存分配算法,应使得已分配内存块尽可能保持连续,这将大大减少内部碎片,由此 Jemalloc 使用伙伴分配算法尽可能提高连续性。伙伴分配算法的示意图如下:
可能很多胖友不了解【伙伴分配算法】,感兴趣的话,可以看看 《伙伴分配器的一个极简实现》 了解了解。
当然,Netty PoolChunk 也是基于【伙伴分配算法】实现。
图中最底层表示一个被切分为 2048 个 Page 的 Chunk 块。自底向上,每一层节点作为上一层的子节点构造出一棵满二叉树,然后按层分配满足要求的内存块。以待分配序列 8KB、16KB、8KB 为例分析分配过程( 假设每个 Page 大小 8KB ):
- 8KB —— 需要一个 Page ,第 11 层满足要求,故分配 2048 节点即 Page0 。
- 16KB —— 需要两个Page ,故需要在第 10 层进行分配,而 1024 的子节点 2048 已分配,从左到右找到满足要求的 1025 节点,故分配节点 1025 即Page2 和 Page3 。
- 8KB —— 需要一个 Page ,第 11 层满足要求,但是 2048 已分配,从左到右找到 2049 节点即 Page1 进行分配。
总结来说:
- 分配结束后,已分配连续的 Page0 - Page3 。这样的连续内存块,大大减少内部碎片并提高内存使用率。
- 通过使用满二叉树这样的树结构,提升检索到可用 Page 的速度,从而提高内存分配效率。
2. PoolChunk
io.netty.buffer.PoolChunk
,实现 PoolChunkMetric 接口,Netty 对 Jemalloc Chunk 的实现类。
2.1 构造方法
/** |
arena
属性,所属 Arena 对象。详细解析,见 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(五)PoolArena》 。memory
属性,内存空间。即用于PooledByteBuf.memory
属性,有 Direct ByteBuffer 和byte[]
字节数组。unpooled
属性,是否非池化。unpooled = false
,池化,对应构造方法一。默认情况下,对于 分配 16M 以内的内存空间时,Netty 会分配一个 Normal 类型的 Chunk 块。并且,该 Chunk 块在使用完后,进行池化缓存,重复使用。unpooled = true
,非池化,对应构造方法二。默认情况下,对于分配 16M 以上的内存空间时,Netty 会分配一个 Huge 类型的特殊的 Chunk 块。并且,由于 Huge 类型的 Chunk 占用内存空间较大,比较特殊,所以该 Chunk 块在使用完后,立即释放,不进行重复使用。- 笔者对 Netty 对 Jemalloc 不同类型的内存块的整理,如下图所示:
内存块分类
Jemalloc 基于【伙伴分配算法】分配 Chunk 中的 Page 节点。Netty 实现的伙伴分配算法中,构造了两颗满二叉树。因为满二叉树非常适合数组存储,Netty 使用两个字节数组
memoryMap
和depthMap
来分别表示分配信息满二叉树、高度信息满二叉树。如下图所示:满二叉树
maxOrder
属性,满二叉树的高度。默认为 11 。注意,层高是从 0 开始。maxSubpageAllocs
属性,可分配的 Page 的数量。默认为 2048 ,在【第 17 行】的代码进行初始化。在第 11 层,可以看到 Page0 - Page2047 这 2048 个节点,也也符合1 << maxOrder = 11 << 11 = 2048
的计算。在【第 19 至 32 行】的代码,
memoryMap
和depthMap
进行满二叉树的初始化。- 数组大小为
maxSubpageAllocs << 1 = 2048 << 1 = 4096
。 - 数组下标为左图对应的节点编号。在【第 23 行】的代码,从
memoryMapIndex = 1
代码可以看出,满二叉树的节点编号是从 1 开始。省略 0 是因为这样更容易计算父子关系:子节点加倍,父节点减半,例如:512 的子节点为 1024(512 * 2
)和 1025(512 * 2 + 1
)。 初始时,
memoryMap
和depthMap
相等,值为节点高度。例如:memoryMap[1024] = depthMap[1024] = 10;
- 对应右图。
- 分配节点时,
depthMap
的值保持不变( 因为,节点的高度没发生变化 ),memoryMap
的值发生变化( 因为,节点的分配信息发生变化 )。当一个节点被分配后,该节点的值设为unusable
( 标记节点不可用。默认为maxOrder + 1 = 12
) 。并且,会更新祖先节点的值为其子节点较小的值( 因为,祖先节点共用该节点的 Page 内存;同时,一个父节点有两个子节点,一个节点不可用后,另一个子节点可能可用,所以更新为其子节点较小的值。 )。举个例子,下图表示随着节点 4 分配而更新祖先节点的过程,其中每个节点的第一个数字表示节点编号,第二个数字表示节点高度:例子
- 节点 4 被完全分配,将高度值设置为 12 表示不可用。
- 节点 4 的父节点 2,将高度值更新为两个子节点的较小值。其他祖先节点亦然,直到高度值更新至根节点。
memoryMap
数组的值,总结为 3 种情况:- 1、
memoryMap[id] = depthMap[id]
,该节点没有被分配。 - 2、
最大高度 >= memoryMap[id] > depthMap[id]
,至少有一个子节点被分配,不能再分配该高度满足的内存,但可以根据实际分配较小一些的内存。比如,上图中父节点 2 分配了子节点 4,值从 1 更新为 2,表示该节点不能再分配 8MB 的只能最大分配 4MB 内存,即只剩下节点 5 可用。 - 3、
memoryMap[id] = 最大高度 + 1
,该节点及其子节点已被完全分配,没有剩余空间。
- 1、
- 数组大小为
Chunk 相关字段
chunkSize
属性,Chunk 内存块占用大小。默认为16M = 16 * 1024KB
。log2ChunkSize
属性,log2(chunkSize)
的结果。默认为log2( 16M ) = 24
。 代码如下:private static final int INTEGER_SIZE_MINUS_ONE = Integer.SIZE - 1; // 32 - 1 = 31
private static int log2(int val) {
// compute the (0-based, with lsb = 0) position of highest set bit i.e, log2
return INTEGER_SIZE_MINUS_ONE - Integer.numberOfLeadingZeros(val);
}- x
freeBytes
属性,剩余可用字节数。
- Page 相关字段
pageSize
属性,每个 Page 的大小。默认为8KB = 8192B
。pageShifts
属性,从 1 开始左移到pageSize
的位数。默认 13 ,1 << 13 = 8192
。具体用于计算指定容量所在满二叉树的层级,详细解析,见 「2.2.1 allocateRun」 。
SubPage 相关字段
- 详细解析,见 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(三)PoolSubpage》 。
subpages
属性,PoolSubpage 数组。每个节点对应一个 PoolSubpage 对象。因为实际上,每个 Page 还是比较大的内存块,可以进一步切分成小块 SubPage 。在【第 35 行】的代码,调用#newSubpageArray(int size)
方法,进行初始化。代码如下:private PoolSubpage<T>[] newSubpageArray(int size) {
return new PoolSubpage[size];
}- 默认情况下,数组大小为
maxSubpageAllocs = 2048
。
- 默认情况下,数组大小为
subpageOverflowMask
属性,判断分配请求内存是否为 Tiny/Small ,即分配 Subpage 内存块。默认,-8192 。在【13 行】的代码进行初始化。对于 -8192 的二进制,除了首 bits 为 1 ,其它都为 0 。这样,对于小于 8K 字节的申请,求subpageOverflowMask & length
都等于 0 ;对于大于 8K 字节的申请,求subpageOverflowMask & length
都不等于 0 。相当于说,做了if ( length < pageSize )
的计算优化。
- ChunkList 相关字段
- 详细解析,见 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(四)PoolChunkList》 。
parent
属性,所属 PoolChunkList 对象。prev
属性,上一个 Chunk 对象。next
属性,下一个 Chunk 对象。
内容比较“厚实”( 😈 字比较多 ),建议胖友再读一遍,再看下面的代码具体实现。
2.2 allocate
#allocate(int normCapacity)
方法,分配内存空间。代码如下:
1: long allocate(int normCapacity) { |
- 第 2 至 4 行:当申请的
normCapacity
大于等于 Page 大小时,调用#allocateRun(int normCapacity)
方法,分配 Page 内存块。详细解析,见 「2.2.1 allocateRun」 中。 - 第 5 至 8 行:调用
#allocateSubpage(int normCapacity)
方法,分配 Subpage 内存块。详细解析,见 「2.2.1 allocateSubpage」 中。
2.2.1 allocateRun
#allocateRun(int normCapacity)
方法,分配 Page 内存块。代码如下:
/** |
- 第 3 行:获得层级。
- 第 5 行:调用
#allocateNode(int normCapacity)
方法,分配节点。详细解析,见 「2.2.3 allocateNode」 中。- 第 7 至 9 行:未获得到节点,直接返回。
第 11 行:调用
#runLength(int id)
方法,计算使用节点的字节数,并减少剩余可用字节数。代码如下:private int runLength(int id) {
// represents the size in #bytes supported by node 'id' in the tree
return 1 << log2ChunkSize - depth(id);
}
private byte depth(int id) {
return depthMap[id];
}
2.2.2 allocateSubpage
老艿艿:本小节,胖友先看完 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(三)PoolSubpage》 。
#allocateSubpage(int normCapacity)
方法,分配 Subpage 内存块。代码如下:
/** |
- 第 5 行:调用
PoolArena#findSubpagePoolHead(int normCapacity)
方法,获得对应内存规格的 Subpage 双向链表的head
节点。详细解析,见 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(五)PoolArena》 。 - 第 7 行:
synchronized
加锁,分配过程会修改双向链表的结构,会存在多线程的情况。 - 第 8 至 10 行:调用
#allocateNode(int d)
方法,获得最底层的一个节点。Subpage 只能使用二叉树的最底层的节点。- 第 11 至 14 行:获取失败,直接返回。
- 第 20 行:减少剩余可用字节数。
第 23 至 34 行:分配 PoolSubpage 内存块。
第 23 行:调用
#subpageIdx(int id)
方法,获得节点对应的subpages
数组的编号。代码如下:private int subpageIdx(int memoryMapIdx) {
return memoryMapIdx ^ maxSubpageAllocs; // remove highest set bit, to get offset
}- 去掉最高位( bit )。例如节点 2048 计算后的结果为 0 。
- 第 25 行:获得节点对应的
subpages
数组的 PoolSubpage 对象。 - 第 26 至 32 行:初始化 PoolSubpage 对象。
- 第 34 行:调用
PoolSubpage#allocate()
方法,分配 PoolSubpage 内存块。
2.2.3 allocateNode
#allocateNode(int normCapacity)
方法,分配节点。代码如下:
/** |
- 第 3 行:通过
- (1 << d)
计算,获得initial
。用于【第 12 行】的代码,id & initial
,来保证,高度小于d
会继续循环。 第 6 行:获得根节点(
id = 1
)的指值。代码如下:private byte value(int id) {
return memoryMap[id];
}- 第 7 至 9 行:如果根节点的值,大于
d
,说明,第d
层没有符合的节点,也就是说[1, d-1]
层也没有符合的节点。即,当前 Chunk 没有符合的节点。
- 第 7 至 9 行:如果根节点的值,大于
- 第 10 至 25 行:获得第
d
层,匹配的节点。因为val < d
难以保证是第d
层,[0, d-1]
层也可以满足val < d
,所以才有id & initial
来保证,高度小于d
会继续循环。- ← 第 15 行:
<< 1
操作,进入下一层。获得左节点的编号。 - ← 第 17 行:获得左节点的值。
- → 第 19 行:如果值大于
d
,说明,以左节点作为根节点形成虚拟的虚拟满二叉树,没有符合的节点。此时,需要跳到右节点。 - → 第 21 行:
^ 1
操作,获得右节点的编号。 - → 第 23 行:获得右节点的值。
- 【第 17 行】或者【第 23 行】的代码,会通过【第 12 行】的代码,结束循环。也就说,获得第
d
层,匹配的节点。
- ← 第 15 行:
第 33 行:调用
#setValue(int id, byte val)
方法,设置获得的节点的值为unusable
,表示不可用。代码如下:private void setValue(int id, byte val) {
memoryMap[id] = val;
}第 35 行:调用
#updateParentsAlloc(int id)
方法,更新获得的节点的祖先都不可用。详细解析,见 「2.4.1 updateParentsAlloc」 。- 第 38 行:返回节点编号。
2.3 free
老艿艿:本小节,胖友先看完 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(三)PoolSubpage》 。
#free(long handle)
方法,释放指定位置的内存块。根据情况,内存块可能是 SubPage ,也可能是 Page ,也可能是释放 SubPage 并且释放对应的 Page 。代码如下:
/** |
第 3 行:调用
#memoryMapIdx(handle)
方法,获得memoryMap
数组的编号( 下标 )。代码如下:private static int memoryMapIdx(long handle) {
return (int) handle;
}第 5 行:调用
#bitmapIdx(handle)
方法,获得bitmap
数组的编号( 下标 )。代码如下:private static int bitmapIdx(long handle) {
return (int) (handle >>> Integer.SIZE);
}- 注意,此时获得的还不是真正的 bitmapIdx 值,需要经过
bitmapIdx & 0x3FFFFFFF
运算,即【第 21 行】的代码。
- 注意,此时获得的还不是真正的 bitmapIdx 值,需要经过
- 第 9 至 26 行:释放 Subpage 内存块。
- 第 9 行:通过
bitmapIdx !=0
判断,说明释放的是 Subpage 内存块。 - 第 11 行:获得 PoolSubpage 对象。
- 第 17 行:调用
PoolArena#findSubpagePoolHead(int normCapacity)
方法,获得对应内存规格的 Subpage 双向链表的head
节点。详细解析,见 《精尽 Netty 源码解析 —— Buffer 之 Jemalloc(五)PoolArena》 。 - 第 19 行:
synchronized
加锁,分配过程会修改双向链表的结构,会存在多线程的情况。 - 第 21 行:调用
SubPage#free(PoolSubpage<T> head, int bitmapIdx)
方法,释放 Subpage 内存块。- 如果返回
false
,说明 Page 中无切分正在使用的 Subpage 内存块,所以可以继续向下执行,释放 Page 内存块。
- 如果返回
- 第 9 行:通过
- 第 30 至 35 行:释放 Page 内存块。
- 第 31 行:增加剩余可用字节数。
- 第 33 行:调用
#setValue(int id, byte val)
方法,设置 Page 对应的节点可用。 - 第 35 行:调用
#updateParentsAlloc(int id)
方法,更新获得的节点的祖先可用。
2.4 updateParents
2.4.1 updateParentsAlloc
#updateParentsAlloc(int id)
方法,更新获得的节点的祖先都不可用。代码如下:
/** |
- 😈 注意,调用此方法时,节点
id
已经更新为不可用。 - 第 2 行:循环,直到根节点。
- 第 4 行:
>>> 1
操作,获得父节点的编号。 - 第 5 至 11 行:获得子节点较小值,并调用
#setValue(int id, int value)
方法,设置到父节点。 - 第 13 行:跳到父节点。
2.4.2 updateParentsFree
#updateParentsAlloc(int id)
方法,更新获得的节点的祖先可用。代码如下:
/** |
- 😈 注意,调用此方法时,节点
id
已经更新为可用。 - 第 3 行:获得当前节点的子节点的层级。
- 第 4 行:循环,直到根节点。
- 第 6 行:
>>> 1
操作,获得父节点的编号。 - 第 7 至 10 行:获得两个子节点的值。
- 第 12 行:获得当前节点的层级。
- 第 14 至 16 行:两个子节点都可用,则调用
#setValue(id, value)
方法,直接设置父节点的层级( 注意,是logChild - 1
)。 - 第 17 至 21 行:两个子节点任一不可用,则
#setValue(id, value)
方法,取子节点较小值,并设置到父节点。 - 第 24 行:跳到父节点。
2.5 initBuf
#initBuf(PooledByteBuf<T> buf, long handle, int reqCapacity)
方法,初始化分配的内存块到 PooledByteBuf 中。代码如下:
1: void initBuf(PooledByteBuf<T> buf, long handle, int reqCapacity) { |
- 第 3 行:调用
#memoryMapIdx(handle)
方法,获得memoryMap
数组的编号( 下标 )。 - 第 5 行:调用
#bitmapIdx(handle)
方法,获得bitmap
数组的编号( 下标 )。 第 6 至 11 行:通过
bitmapIdx == 0
判断出,内存块是 Page 。所以,调用PooledByteBuf#init(PoolChunk<T> chunk, long handle, int offset, int length, int maxLength, PoolThreadCache cache)
方法,初始化 Page 内存块到 PooledByteBuf 中。其中,runOffset(memoryMapIdx) + offset
代码块,计算 Page 内存块在memory
中的开始位置。runOffset(int id)
方法,代码如下:private int runOffset(int id) {
// represents the 0-based offset in #bytes from start of the byte-array chunk
int shift = id ^ 1 << depth(id);
return shift * runLength(id);
}
private int runLength(int id) {
// represents the size in #bytes supported by node 'id' in the tree
return 1 << log2ChunkSize - depth(id);
}第12 至 16 行:通过
bitmapIdx != 0
判断出,内存块是 SubPage 。所以,调用#initBufWithSubpage(PooledByteBuf<T> buf, long handle, int reqCapacity)
方法,初始化 SubPage 内存块到 PooledByteBuf 中。详细解析,见 「2.5.1 initBufWithSubpage」 。
2.5.1 initBufWithSubpage
#initBufWithSubpage(PooledByteBuf<T> buf, long handle, int reqCapacity)
方法,初始化 SubPage 内存块到 PooledByteBuf 中。代码如下:
void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int reqCapacity) { |
- 第 3 至 7 行:获得 SubPage 对象。
- 第 11 至于 15 行:调用
PooledByteBuf#init(PoolChunk<T> chunk, long handle, int offset, int length, int maxLength, PoolThreadCache cache)
方法,初始化 SubPage 内存块到 PooledByteBuf 中。其中,runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize + offset
代码块,计算 SubPage 内存块在memory
中的开始位置。
2.6 destroy
#destroy()
方法,从 Arena 中销毁当前 Chunk 。代码如下:
void destroy() { |
2.7 PoolChunkMetric
io.netty.buffer.PoolChunkMetric
,PoolChunk Metric 接口。代码如下:
public interface PoolChunkMetric { |
PoolChunk 对 PoolChunkMetric 接口的实现,代码如下:
|
synchronized
的原因是,保证freeBytes
对其它线程的可见性。对应 Github 提交为 a7fe6c01539d3ad92d7cd94a25daff9e10851088 。Motivation:
As we may access the metrics exposed of PooledByteBufAllocator from another thread then the allocations happen we need to ensure we synchronize on the PoolArena to ensure correct visibility.
Modifications:
Synchronize on the PoolArena to ensure correct visibility.
Result:
Fix multi-thread issues on the metrics
666. 彩蛋
老艿艿有点二,在 #allocateNode(int normCapacity)
方法卡了很久。因为没看到 memoryMap
和 depthMap
数组,下标是从 1 开始的!!!我恨那。
参考如下文章:
- 占小狼 《深入浅出Netty内存管理 PoolChunk》
- Hypercube 《自顶向下深入分析Netty(十)–PoolChunk》