PoolArena涉及到内存分配算法执行的主要属性如下:
private final PoolSubpage<T>[] tinySubpagePools;
private final PoolSubpage<T>[] smallSubpagePools;
private final PoolChunkList<T> q050;
private final PoolChunkList<T> q025;
private final PoolChunkList<T> q000;
private final PoolChunkList<T> qInit;
private final PoolChunkList<T> q075;
private final PoolChunkList<T> q100;
实例化之后,PoolArena对象的数据结构如下图所示:
上面的图,看着复杂,其实是简单而且有规律,就三部分:tiny、small、chunk列表。
(1)上图tinySubpagePools和smallSubpagePools数组的index列表示数组下标,size表示对应下标处存储的每个subpage大小。tiny最小为16,所以index=0处的数组元素没有使用。数组的每个元素,以空的Subpage head为链表头,存储着Subpage链表。
(2)PoolChunkList按chunk已使用内存的比例,划分为6个List。每个list都有指定的使用范围,如图中[25, 75)表示存放的是已使用内存比例为>=25%并且<75%的内存快(PoolChunk对象)。并且根据最小使用量minUsage,规定了每个list能分配的最大内存粒度大小maxCapacity,比如q050能分配的最大内存粒度是8M。
大于16M的内存粒度为huge内存粒度,huge ByteBuf不使用内存池技术,不会放到内存池中,所以当向分配器PooledByteBufAllocator申请大于16M的内存时,arena将直接从系统内存(指java堆或者直接内存)分配新的内存,然后将获取到的内存封装为PooledByteBuf对象并返回该PooledByteBuf对象。这是几个内存粒度中分配算法最简单的。
当PooledByteBuf对象的引用计数器为0,调用其deallocate()方法释放时,也比较简单。如果PooledByteBuf是堆内存缓冲,对内存本身啥也不做,依赖GC机制即可回收。如果是直接内存,则最终会调用Unsafe.freeMemory方法,释放直接内存。
normal内存粒度的分配,最核心的是buddy分配算法,不过在真正执行buddy分配算法进行分配之前,还有些步骤,整个流程是:
(1)先尝试从缓冲分配,分配成功,则直接返回;
(2)分配不成功,则从某个PoolChunkList找一个可用的chunk,执行buddy算法分配内存;
(3)如果PoolChunkList中没有可用的chunk,就从系统申请新的内存并用于创建新的chunk,然后根据buddy算法分配内存。同时,将新chunk加入到PoolChunkList qInit中,注意,新chunk不一定就链到qInit链上,PoolChunkList内部有判断,如果chunk已使用内存的比例在本PoolChunkList的范围内,则加入本PoolChunkList的chunk链表的表头,否则一直往下找到合适的。
从缓冲分配,后续单独写成文章。下面我们看核心的buddy分配算法。
buddy分配算法的思想,是将内存以内存页为单位,进行粒度分级,第0级的粒度大小是1个内存页,第1级的粒度是2个地址连续的内存页,第2级的粒度是4个地址连续的内存页…以此类推,第n级的粒度是2n个地址连续的内存页,每一级的粒度大小是前一级的2倍。
分配内存时,以每个分级的粒度大小为分配单位,比如操作系统的内存页大小是4k,那么如果从第0级分配内存,就只能以4k为单位进行分配。从第一级分配,就只能以两个内存页大小即8k为单位进行分配。
这里有个关键概念: 两个内存块,大小相同,地址连续,同属于一个大块区域,则称这两个内存块的关系为伙伴关系。所以,只有属于同一级,才可能为伙伴关系。这个关系非常重要,就是依靠这个关系定义,来尽量减少内存的外碎片的。
当申请分配内存时:
(1)先确定从哪个粒度分级分配内存,采用best fit方式。比如申请分配5k的内存,那么能刚好容下5k的是8k的粒度,也即需要2个内存页,所以从第1级分配内存。
(2)如果第1级里面有可用的内存,那么从第1级直接分配;如果第1级没有可用的内存,那么从其下一级即第2级获取一个粒度,并拆分,分配出去,把剩余的部分链到第1级里面。如果第2级也没有,就不断向上,依次类推…
当释放内存时:
(1)首先释放对应的内存块;
(2)检查它的buddy内存块,如果也是空闲的,就合并到一起,放到下一个粒度等级,以此来避免内存碎片。放到下一级时,同样做检查。
buddy算法这么分配与释放内存,两个明显的好处是:
(1)尽量避免了内存的外碎片;
(2)使得应用数据存储在尽量少的内存页中,从而避免过多的缺页异常,从而导致过多的内存换页,影响应用性能。
(3)分配出去的内存,地址都是连续的。
buddy分配算法,也有缺点,其中一个明显的缺点是内存浪费的问题。因为它只能分配大小为2n个页面的内存粒度,如上例子,如果申请分配的只是5k内存,分配出来的却是8k,造成了3k的浪费,此即内存的内碎片问题严重。在讲解小于1页的内存分配时,会讲解更合适的slab分配算法。
normal的大小范围是[8k, 16M],netty中对normal粒度的内存分配,是buddy分配算法的一种实现。buddy分配算法全部在PoolChunk类实现,内部使用一棵满二叉树来协助内存分配,二叉树总共有12层,层数编号从0开始,最后一层每个叶子节点代表一个page,每个page大小是8k,总共2048个page。先看下PoolChunk的主要属性:
final T memory;
private final byte[] memoryMap;
private final byte[] depthMap;
private final int chunkSize;
memoryMap表示的二叉树
这棵树有几个重要的特点:
(1)高度从0开始,每层的节点数、节点大小和层数的关系如下:
depth=0 1 node (16M)
depth=1 2 nodes (16M/2=8M)
..
..
depth=d 2^d nodes (16M/2^d)
..
depth=11 2^11 nodes (16M/2^11 = 8k = pageSize)
(2)每一层上是所有节点,都是对16M内存块的完整分割,这点性质在根据handle计算偏移量时很有用。handle是分配结果。
上图中,每个节点内的值是其在memoryMap数组的下标,记为id,而每个memoryMap数组元素的值是对应节点所在的高度,初始时其高度为节点的实际高度,分配一段时间后,会发生变化,这些值有如下性质:
(1)memoryMap[id] = 节点的实际高度,表示该节点未使用,可以被分配。
(2)memoryMap[id] > 节点的实际高度,表示该节点至少有一个子节点已经被分配,所以不能完整的分配该节点所代表的内存大小,但它有些子节点是空闲的。
假如memoryMap[id] = x,表示在以节点id为根的子树中,第一个空闲节点的高度是x。在[节点的实际高度, x)这个高度范围,没有空闲节点。
(3)memoryMap[id] = maxOrder + 1,即12,该节点的内存全部分配完毕,不能再被分配。
在内存分配和释放过程中,会不断更新memoryMap数组,以保持上述3点性质。
normal内存分配举例
在真正执行内存分配前,会以best fit方式进行内存规格化转换,比如,分配9000bytes内存,会转换为16k。下面描述分配16k内存的过程:
(1)先确定应该在哪个高度分配16k内存,由于高度d=10的节点代表的大小为16k,因此在高度d=10的层次上,从左向右寻找空闲的节点。
(2)因为id=1024的节点空闲,所以分配它,分配的结果是返回节点的编号1024,源码中叫handle。而节点1024所代表的内存段是(offset=0, length=16k),因此会把(offset=0, length=16k)这段内存分配给PooledByteBuf 对象。
(3)更新节点的高度值,因为1024节点已经被完全分配,所以设置memoryMap[1024]=12(maxOrder+1);其父节点(id=512)的高度值也得更新,取两个子节点中高度值小的那个节点作为父节点的高度,因为1024节点高度已经是12,1025节点的高度还是10,所以memoryMap[512]=10;因为编号512的节点的高度已经变更,512的父节点的高度值也得变更,一直往上,直到树根。
normal内存的释放
释放时,先尝试将PooledByteBuf对象放到线程缓冲PoolThreadCache里,方便下次再分配同样粒度的内存时,直接从缓冲分配。如果缓冲未满,会被成功放入缓存,那么释放动作到此为止。
如果缓存满了,存不下了,那么就进行真正的释放动作。释放的本质,是恢复之前分配到的节点在memoryMap的高度值,同时向上回溯不断恢复其父节点在memoryMap的高度值。
释放之后,还有一步检查,即如果PoolChunk的已使用内存比例已经是0,就把PoolChunk表示的整块内存都释放了:如果PoolChunk持有的是字节数组,那么什么也不做,依赖GC机制来释放即可,如果PoolChunk持有的是直接内存,最终会调用Unsafe.freeMemory方法,来释放掉直接内存。
这么做的意图,是为了保持系统层面(指java堆或者直接内存)空闲的内存尽可能连续,减缓内存碎片化。
Pool在哪里?
池化啰嗦了一大堆,那池到底在哪?netty的内存池技术的池,主要体现在向系统申请新的内存(PoolChunk)后,将PoolChunk通过PoolChunkList存到PoolArena,下次再分配内存时,不再从系统申请内存,而是直接从PoolArena拿出之前未用完的PoolChunk,继续分配。
如果把内存池技术定义为“分配内存时,不需要向系统申请新的内存,直接从内存池分配”,那么分配缓冲也算是内存池技术中提现池的思想的部分。分配缓冲在PoolThreadCache中实现,后续单独开文章讲解。
分配
small/tiny粒度的分配算法,首先通过buddy算法找到一个空闲的page,然后将page拆分为subpage。整个分配流程是:
(1)先尝试从当前线程的缓冲PoolThreadCache分配,如果分配到了,则直接返回,否则继续。
(2)从arena的SubpagePools分配,根据要分配的粒度大小,选取tinySubpagePools或者smallSubpagePools。如果能从SubpagePools分配,那直接返回。
从pools中的Subpage对象成功分配后,需要检查Subpage内是否还有空闲的subpage,如果没有了,需要从pools中移除掉。(一个Subpage对象是对一页内存的拆分,包含多个small/tiny粒度的连续内存块,内部通过一个long[] bitmap数据结构来记录每块内存是否已经被分配。)
(3)如果SubpagePools中还是没有空闲的内存,那么从PoolChunk中分配一个空闲的内存页,然后按本次申请的内存粒度大小拆分为多个small/tiny粒度的内存块,用PoolSubpage对象记录。拆分后,将PoolSubpage链接到arena的对应的SubpagePools中,再从PoolSubpage分配一块空闲的内存。
tiny/small内存粒度的分配算法讲完了,那slab分配算法体现在哪里?其实是提现在第#(2)步,回忆一下前文的PoolArena数据结构:
先预先定义内存粒度的分级,当需要分配小于pageSize的内存块时(以分配30bytes内存为例),使用best fit方式决定从哪个粒度分级上分配内存(30对应的best fit分级大小是32bytes)。然后在该粒度上寻找空闲的内存块。如果没有空闲的内存了,再申请一个空闲的内存页,将内存页拆了(按每小块大小为32bytes),链到对应的粒度大小上,然后分配。这正是slab分配算法。
释放
small/tiny粒度的内存块释放时,也先尝试将PooledByteBuf对象放到线程缓冲PoolThreadCache里,方便下次再分配同样粒度的内存时,直接从缓冲分配。如果缓冲未满,会被成功放入缓存,那么释放动作到此为止。
如果缓冲放不下了,做真正的释放动作,small/tiny粒度的释放动作的本质,是将PoolSubpage中对应的bitmap位置设置为0-未使用。
一个附加动作是,如果当前PooledByteBuf对象所在的PoolSubpage在本次释放后,已经没有在使用中的内存块(即该PoolSubpage已经完全空闲),并且arena中对应size的SubpagePools中,还有别的Subpage,那么会将当前PoolSubpage也释放掉。这样做,也是为了减缓内存的碎片化。
本文从PoolArena的数据结构开始,逐步深入地讲解了netty对几个内存粒度的分配和释放算法,并尝试说明算法背后的思想,不当之处,请指正~