本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。
本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经
首先声明本篇文章是基于 Netty 的 jemalloc 3
来进行讲述的,因为 Netty 在 2020 年的时候将 jemalloc 3
升级到 jemalloc 4
了。虽然升级了但是整体架构和思路还是没有变化的,所以看 jemalloc 3
并不影响我们理解 Netty 的内存池架构。那 jemalloc 4
什么时候讲呢?源码篇。
Netty 内存池架构设计
首先我们先看 Netty 内存池的架构设计,先从宏观层面来目睹 Netty 内存池的整体架构,感受它的牛逼之处。
这图看起来有点儿复杂,但是它阐释了 Netty 的内存模型,里面有几个核心组件:PoolArena、PoolChunk、PoolChunkList、PoolSubpage,下面大明哥将一一来介绍这几个核心组件,看完后你就明白了。
内存规格
Netty 为了更好地管理内存,减少内存碎片的产生,它将内存规格进行了细致的划分。划分情况如下:
Netty 将整个内存划分为:Tiny、Small、Normal 和 Huge 四类。其中 Tiny 为 0 ~ 512 B 之间的内存块,Small 为 512B ~ 8KB 之间的内存块,Normal 为 8KB ~ 16M 之间的内存块,Huge 则是大于 16M 的。
Tiny、Small、Normal 采用池化技术来进行内存管理,而 Huge 则是直接分配,因为 Netty 认为大于 16M 的为大型对象,大型对象不做缓存、不池化,直接采用 Unpool 的形式分配,用完后直接回收。
Netty 默认向操作系统申请内存的大小为 16M,即一个 Chunk,Chunk 为 Netty 向操作系统申请内存的单位,而 Page 则是 Chunk 用于管理内存的基本单位,一个 Page 默认大小为 8K,所以一个 Chunk 则是有 2048 个 Page 组成。
Subpage 是 Page 的下属管理单位,如果我们申请的内存大大小于 8K,直接使用 Page 来进行分配则会非常浪费,所以 Netty 对 Page 进行了再一次的划分,划分的单元则是 Subpage。Subpage 没有固定的大小,需要根据申请内存的大小来决定,依据这个大小对 Page 进行恒等切分。比如申请内存为 30B,则将 Page 切分为 256(8Kb / 32B
) 个大小为 32B 的 Subpage。
PoolArena
PoolArena 是 Netty 内存管理最重要的一个类,它是进行池化内存分配的核心类。与 jemalloc 类似,Netty 也是采用固定数量的多个 Arena 进行内存分配,它是线程共享的对象,每个线程都会绑定一个 PoolArena。当线程首次申请内存分配时,会通过轮询的方式得到一个 PoolArena
Netty 与 jemalloc 的设计思想一致,采用固定数量的 Arena 进行内存分配,通过创建多个 Arena 来缓解资源竞争问题。线程在进行首次内存分配时,会通过轮询的方式选择一个 PoolArena 与之绑定,在该线程的整个生命周期内都只会该 PoolArena 打交道。
我们先看 PoolArena 的数据结构。
从上图我们可以看出 ,PoolArena 包含两个 PoolSubpage 数组,6 个 PoolChunkList,这 6 个 PoolChunkList 会组成一个双向链表。
两个 PoolSubpage 数组
PoolArena 中两个 PoolSubpage 类型的数组,分别是 tinySubpagePools 和 smallSubpagePools,他们分别用于负责小于 8KB 的 Tiny 和 Small 类型的内存分配。
Tiny 分配内存区间为 [16B,496B]
,每次以 16B 进行递增,一共 31 个不同的值。而 Small 类型的区间为 [512B,1KB,2KB,4KB]
,一共 4 个不同的值。
在分配小于 8KB 的内存时,首先是从 tinySubpagePools 和 smallSubpagePools 中找对应的位置,计算索引的算法如下:
arduino
static int tinyIdx(int normCapacity) {
return normCapacity >>> 4;
}
static int smallIdx(int normCapacity) {
int tableIdx = 0;
int i = normCapacity >>> 10;
while (i != 0) {
i >>>= 1;
tableIdx++;
}
return tableIdx;
}
找到后就进行内存分配,如果没有找到则从 PoolChunk 中分配,具体的过程,大明哥后面详细分析。
PoolChunkList 双向链表
PoolArena 中除了 2 个 PoolSubpage 数组外,还有 6 个 PoolChunkList,这 6 个 PoolChunkList 用于分配大于等于 8KB 的 Normal 类型内存,他们分别存储不同内存使用率的 Chunk,根据使用率的不同,他们构建了一个具有 6 个节点的双向链表,过程如下:
ini
q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, chunkSize);
q075 = new PoolChunkList<T>(this, q100, 75, 100, chunkSize);
q050 = new PoolChunkList<T>(this, q075, 50, 100, chunkSize);
q025 = new PoolChunkList<T>(this, q050, 25, 75, chunkSize);
q000 = new PoolChunkList<T>(this, q025, 1, 50, chunkSize);
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, chunkSize);
q100.prevList(q075);
q075.prevList(q050);
q050.prevList(q025);
q025.prevList(q000);
q000.prevList(null);
qInit.prevList(qInit);
构建的双向链表如下图:
6 个节点分别代表不同的内存使用率,如下:
- qInit,内存使用率为 0% ~ 25% 的 Chunk。
- q000,内存使用率为 1% ~ 50% 的 Chunk。
- q025,内存使用率为 25% ~ 75% 的 Chunk。
- q050,内存使用率为 50% ~ 100% 的 Chunk。
- q075,内存使用率为 75% ~ 100% 的 Chunk。
- q100,内存使用率为 100% 的 Chunk。
随着 Chunk 内存使用率的不同,它会在这两个节点之间移动,为什么要这么设计?看过大明哥前面两篇文章的小伙伴应该就清楚了。
在这里有两个问题要解答:
- qInit 和 q000 有什么区别?这样相似的两个节点为什么不设计成一个?
- 节点与节点之间的内存使用率重叠很大,为什么要这么设计?
- 第一个问题:qInit 和 q000 有什么区别?这样相似的两个节点为什么不设计成一个?
仔细观察这个 PoolChunkList 的双向链表,你会发现它并不是一个完全的双向链表,它与完全的双向链表有两个区别:
- qInit 的 前驱节点是自己。这就意味着在 qInit 节点中的 PoolChunk 使用率到达 0% 后,它并不会被回收。
- q000 则没有前驱节点,这样就导致一个问题,随着 PoolChunk 的内存使用率降低,直到小于 1% 后,它并不会退回到 qInit 节点,而是等待完全释放后被回收。
所以如果某个 PoolChunk 的内存使用率一直都在 0 ~ 25% 之间波动,那么它就可以一直停留在 qInit 中,这样就避免了重复的初始化工作,所以 qInit 的作用主要在于避免某 PoolChunk 的内存使用变化率不大的情况下的频繁初始化和释放,提高内存分配的效率。而 q000 则用于 PoolChunk 内存使用变化率较大,待完全释放后进行内存回收,防止永远驻留在内存中。
qInit 和 q000 的配合使用,使得 Netty 的内存分配和回收效率更高效了。
- 第二个问题:节点与节点之间的内存使用率重叠很大,为什么要这么设计?
我们先看下图:
从上图可以看出,这些节点几乎有一半空间是重叠的,为什么要这么设计呢?我们假定,q025 的范围为 [25%,50%),q050 的范围为 [50%,75%),如果有一个 PoolChunk 它的内存使用率变化情况为 40%、55%、45%、60%、48%,66%,这样就会导致这个 PoolChunk 会在 q025 、q050 这两个 PoolChunkList 不断移动,势必会造成性能损耗。如果范围是 [25%,75%) 和 [50%,100%),这样的内存使用率变化情况只会在 q025 中,只要当内存使用率超过了 75% 才会移动到 q050,而随着该 PoolChunk 的内存使用率降低,它也不是降到 75% 就回到 q025,而是要到 50%,这样可以调整的范围就大的多了。
PoolChunkList
PoolChunkList 负责管理多个 PoolChunk,多个内存使用率相同的 PoolChunk 以双向链表的的方式构建成一个 PoolChunkList。
每个 PoolChunkList 都有两个内存使用率的属性:minUsage 和 maxUsage。当 PoolChunk 进行内存分配时,如果内存使用率超过 maxUsage,则从当前的 PoolChunkList 中移除,并添加到下一个 PoolChunkList 中。同时,随着内存的释放,PoolChunk 的内存使用率就会减少,直到小于 minUsage ,则从当前的 PoolChunkList 中移除,并添加到上一个 PoolChunkList 中。PoolChunk 就是通过这种方式在 PoolChunkList 中来回移动,这种方式提高了 Netty 对内存的管理能力。
所以,六个 PoolCHunkList最终组成的数据结构如下:
PoolChunk
PoolChunk 是Netty 完成内存分配和回收的地方,它是真正存储数据的地方,每个 PoolChunk 默认大小为 16M。一个 PoolChunk 会均等分为 2048 个 Page,每个 Page 为 8KB,这里的 Page 是一个虚拟的概念。
Netty 会利用伙伴算法将这 2048 个 Page 组成一颗满二叉树,如下:
在 PoolChunk 中还有两个很重要的属性:depthMap 和 memoryMap。
- depthMap 用于存放节点锁对应的高度,例如 depthMap[2048] = 11,depthMap[1024] = 10
- memoryMap 用于记录二叉树节点的分配信息。
这两个数组在 Netty 进行内存分配和回收时发挥着重要重要。
PoolSubpage
大于 8KB 的内存用 PoolChunk 中的 Page 分配,小于 8KB 的内存则用 PoolSubPage 分配。在 PoolArena 中有两个用于分配 Tiny 和 Small 场景的数组,里面记录的就是 PoolSubPage。
PoolSubPage 由 PoolChunk 中的一个空闲 Page 按照第一次请求分配的内存大小(仅限于 Tiny 和 Small)均等切分而来,比第一次请求分配内存大小为 16B,则一个 Page 会切分为 512 块 16B 的 PoolSubpage。
- 首次分配内存时,PoolArena 中的 xxxSubpagePools的双向链表为空。这个时候 Netty 会将 PoolChunk 中的一个空闲 Page 进行均等切分并且加入到 PoolArena 中的 xxxSubpagePools中,完成内存分配。
- 如果后面请求分配同等大小的内存,只需要在 xxxSubpagePools中找到对应的空间直接分配即可,如果没有,重复 1。
- 如果请求分配不同内存大小 Tiny 或者 Small ,重复 1。
PoolThreadCache
PoolThreadCache 顾名思义就是本地线程缓存,当 Netty 释放内存时,它并没有将缓存归还给 PoolChunk,而是使用 PoolThreadCache 缓存起来,当下次申请相同大小的内存时,直接从 PoolThreadCache 取出来即可。PoolThreadCache 缓存了 Tiny、Small、Normal 三种类型的数据。
在前面我们知道 PoolArena 是使用 PoolChunk 和 PoolSubpage 来进行内存维护的,但是 PoolThreadCache 不同,PoolThreadCache 则是基于 MemoryRegionCache 来完成内存管理的。 MemoryRegionCache 是 PoolThreadCache 进行内存管理的基本单元。
在 PoolThreadCache 有三个这样的数组:
swift
private final MemoryRegionCache<ByteBuffer>[] tinySubPageDirectCaches;
private final MemoryRegionCache<ByteBuffer>[] smallSubPageDirectCaches;
private final MemoryRegionCache<ByteBuffer>[] normalDirectCaches;
这三个数组分别对应 Tiny、Small、Normal,所以PoolThreadCache 将不同规格大小的内存都使用单独的 MemoryRegionCache 维护,从这里我们就可以知道 tinySubPageDirectCaches 有 32 个元素,smallSubPageDirectCaches 有 4 个元素,但是对于 normalDirectCaches 只有 3 个元素,因为 normalDirectCaches 只分配 8KB、16KB、32KB,大于 32KB 还是去 PoolArena 中分配。
在 MemoryRegionCache 中有一个 Queue,当某个规格的内存释放后,就会加入到该规格的 Queue 中,下次再分配同规格的内存,直接从队列中取就可以了。
最后,PoolThreadCache 整体架构如下 :
到这里整个 Netty 的内存池架构就完毕了,整体来说也不是很复杂,这里推荐各位小伙伴去和 jemalloc 的架构做一个对比,也许思路会更加清晰些,最好拿张纸画画。
内存管理
从 Netty 的内存池架构中我们已经知道了,Netty 的内存管理分为两个部分,一个是 PoolThreadCache ,一个是 PoolArena,其中 PoolArena 为线程共享的,而 PoolThreadCache 则是线程私有的。PoolArena 的内存被释放后,并不会还给 PoolChunk,而是缓存在 PoolThreadCache 中,等到下次获取同样大小内存的时候,直接从 PoolThreadCache 查找匹配的内存块即可。
Netty 对不同规格的内存采用不同的管理方式,那么分配的策略也肯定不同,本篇文章主要介绍如下两个场景:
- 分配内存大于 8KB,由采用 PoolChunk 的 Page 负责管理的内存分配策略。
- 分配内存小于 8KB,则采用 PoolSubpage 负责管理的内存分配策略。
至于,PoolThreadCache 的内存分配策略,大明哥在讲述源码的时候再做详细说明。
Normal 场景内存分配
在前面我们知道,PoolChunk 默认大小为 16MB,它均等划分 2045 个 8KB 大小的 Page,通过伙伴算法将这 2048 个 Page 构建成一颗满二叉树,如下:
-
PoolChunk 中有两个数组来负责对 Page 的分配:
memoryMap[]
和depthMap[]
-
开始时
memoryMap[]
和depthMap[]
两个数组内容一样,都是等于树的高度,例如memoryMap[2048] = depthMap[2048] = 11
,memoryMap[1024] = depthMap[1024] = 10
。 -
depthMap[] 初始化完成后,就永远不会变了,它仅仅是用来通过节点编号快速获取树的高度。
-
memoryMap[] 初始化完成后,它随着节点的分配而发生改变,其中父节点数值等于两个子节点中较小的那个。我们可以根据memoryMap[] 的数值来判断节点是否已分配:
- memoryMap[i] = depthMap[i] ,节点没有被分配。
- depthMap[i] = memoryMap[i] < 12(最大高度),至少有一个子节点已分配,但是没有完全分配,该节点不能分配该高度对应的内存,只能分配它子节点对应的内存。
- memoryMap[i] = 12,该节点及其子节点已完全分配了,没有剩余空间。
现在我们来演示分配内存,我们主要分配三个内存尺寸:8KB,16KB,8KB。
- 分配 8KB
经过计算可以确认,在 11 层进行内存分配。在 11 层查找可用的 page,找到 i = 2048
的节点可以分配内存。此时赋值 memoryMap[2048] = 12
(原值为 11),然后递归更新它对应的父节点 memoryMap[1024] = 11
,一直到 memoryMap[1] = 1
,二叉树进入下图:
- 分配 16KB
16KB 确认在第 10 层,memoryMap[1024] 有一个子节点已经分配出去了,所以它不满足条件,memoryMap[1025] 符合条件,则分配 1025 节点,将 1025 的两个子节点 2050、2051 设置为 12, memoryMap[2050] = 12
、memoryMap[2051] = 12
,两个子节点都是 12,则 1025 节点也是 12,memoryMap[1025] = 12
,更新父节点 512 节点为 11(原值是 9) ,memoryMap[512] = 11
- 分配 8KB
继续分配 8KB,按照上面的逻辑可以找到 2049 节点,则将 2049 节点设置为 12,memoryMap[2049] = 12
,父节点 1024 以及 512 都设置为 12。
到这里,Normal 场景内存分配就介绍完毕了,熟悉伙伴算法的小伙伴,对这个应该会很熟悉。
Tiny&Small 场景内存分配
大于 8KB 的内存使用 PoolChunk 的 Page 来分配内存,而小于 8KB 的,则采用 PoolSubpage 来管理。PoolSubpage 的管理方式,大明哥在这篇文章里面说了不下于三次了,所以不在阐述,就用一张图来描述吧。
分配过程,大明哥就不再详细阐述了,就留给各位小伙伴了。
好了,到这里整个 Netty 的内存管理就已经介绍完毕了,至于内存回收部分,我们后面源码部分再来分析分析。这里简单做一个总结。
-
Netty 为了更好地管理内次,将内存的管理按照大小进行规格化管理,分别为 Tiny、Small、Normal 和 Huge。
-
Netty 内存管理有 5 个核心组件,分别为 PoolArena、PoolChunkList、PoolChunk、PoolSubpage 和 PoolThreadCache。5 个核心组件分为两套管理模式:PoolThreadCache 和 PoolArena
- PoolThreadCache 为线程私有。Netty 为了使得内存的分配更加高效,将内存小于 32KB 内存在回收时并没有归还给 PoolChunk,而是缓存在 PoolThreadCache 中,等到下次申请内存时,优先从 PoolThreadCache 中获取。
- PoolArena 为所有线程共享,内存分配的核心组件是 PoolChunk。内存小于 8KB 的采用 PoolSubpage 的管理方式,内存大于 8KB 的则是使用 PoolChunk 的 Page 管理方式。
-
PoolArena 是与线程绑定,当线程首次申请内存时会采用轮询的方式与某一个 PoolArena 进行绑定。PoolArena 包括两个核心部分:
- 两个 PoolSubpage 数组:一个 tinySubpagePools 数组,用于 tiny 内存分配,一个 smallSubpagePools 数组,用于 small 内存分配。
- 有 6 个 PoolChunk 构成的双向链表 PoolChunkList。6 个 PoolChunk 代表了 6 种内存使用率的 PoolChunk。
-
PoolChunkList 是有内存使用率相同的 PoolChunk 构成的双向链表,随着 PoolChunk 的内存分配和释放,PoolChunk 会在不同规格的 PoolChunkList 中移动。
-
PoolChunk 是 Netty 分配内存的核心所在,使用伙伴算法来管理 Page,尽可能地保证分配内存的连续性,Page 以满二叉树的方式实现,是整个内存池分配的核心所在。
-
PoolSubPage 用于分配小于 8KB 的内存申请,在线程申请内存时,它首先会从数组( tinySubpagePools 和 smallSubpagePools )中找对应规格的空闲内存,如果有,则分配,如果没有,则从 PoolChunk 的一个 page 中申请,page 根据申请内存的大小进行均等划分,然后加入到 tinySubpagePools 数组中。
完毕!!