likes
comments
collection
share

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

作者站长头像
站长
· 阅读数 56


死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

首先声明本篇文章是基于 Netty 的 jemalloc 3 来进行讲述的,因为 Netty 在 2020 年的时候将 jemalloc 3 升级到 jemalloc 4 了。虽然升级了但是整体架构和思路还是没有变化的,所以看 jemalloc 3 并不影响我们理解 Netty 的内存池架构。jemalloc 4 什么时候讲呢?源码篇

Netty 内存池架构设计

首先我们先看 Netty 内存池的架构设计,先从宏观层面来目睹 Netty 内存池的整体架构,感受它的牛逼之处。

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

这图看起来有点儿复杂,但是它阐释了 Netty 的内存模型,里面有几个核心组件:PoolArena、PoolChunk、PoolChunkList、PoolSubpage,下面大明哥将一一来介绍这几个核心组件,看完后你就明白了。

内存规格

Netty 为了更好地管理内存,减少内存碎片的产生,它将内存规格进行了细致的划分。划分情况如下:

死磕 Netty 之内存篇:深挖 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 的数据结构。

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

从上图我们可以看出 ,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 中找对应的位置,计算索引的算法如下:

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 个节点的双向链表,过程如下:

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);

构建的双向链表如下图:

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

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 内存使用率的不同,它会在这两个节点之间移动,为什么要这么设计?看过大明哥前面两篇文章的小伙伴应该就清楚了。

在这里有两个问题要解答:

  1. qInit 和 q000 有什么区别?这样相似的两个节点为什么不设计成一个?
  2. 节点与节点之间的内存使用率重叠很大,为什么要这么设计?
  • 第一个问题:qInit 和 q000 有什么区别?这样相似的两个节点为什么不设计成一个?

仔细观察这个 PoolChunkList 的双向链表,你会发现它并不是一个完全的双向链表,它与完全的双向链表有两个区别:

  1. qInit 的 前驱节点是自己。这就意味着在 qInit 节点中的 PoolChunk 使用率到达 0% 后,它并不会被回收。
  2. q000 则没有前驱节点,这样就导致一个问题,随着 PoolChunk 的内存使用率降低,直到小于 1% 后,它并不会退回到 qInit 节点,而是等待完全释放后被回收。

所以如果某个 PoolChunk 的内存使用率一直都在 0 ~ 25% 之间波动,那么它就可以一直停留在 qInit 中,这样就避免了重复的初始化工作,所以 qInit 的作用主要在于避免某 PoolChunk 的内存使用变化率不大的情况下的频繁初始化和释放,提高内存分配的效率。而 q000 则用于 PoolChunk 内存使用变化率较大,待完全释放后进行内存回收,防止永远驻留在内存中。

qInit 和 q000 的配合使用,使得 Netty 的内存分配和回收效率更高效了。

  • 第二个问题:节点与节点之间的内存使用率重叠很大,为什么要这么设计?

我们先看下图:

死磕 Netty 之内存篇:深挖 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。

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

每个 PoolChunkList 都有两个内存使用率的属性:minUsage 和 maxUsage。当 PoolChunk 进行内存分配时,如果内存使用率超过 maxUsage,则从当前的 PoolChunkList 中移除,并添加到下一个 PoolChunkList 中。同时,随着内存的释放,PoolChunk 的内存使用率就会减少,直到小于 minUsage ,则从当前的 PoolChunkList 中移除,并添加到上一个 PoolChunkList 中。PoolChunk 就是通过这种方式在 PoolChunkList 中来回移动,这种方式提高了 Netty 对内存的管理能力。

所以,六个 PoolCHunkList最终组成的数据结构如下:

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

PoolChunk

PoolChunk 是Netty 完成内存分配和回收的地方,它是真正存储数据的地方,每个 PoolChunk 默认大小为 16M。一个 PoolChunk 会均等分为 2048 个 Page,每个 Page 为 8KB,这里的 Page 是一个虚拟的概念。

Netty 会利用伙伴算法将这 2048 个 Page 组成一颗满二叉树,如下:

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

在 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。

  1. 首次分配内存时,PoolArena 中的 xxxSubpagePools的双向链表为空。这个时候 Netty 会将 PoolChunk 中的一个空闲 Page 进行均等切分并且加入到 PoolArena 中的 xxxSubpagePools中,完成内存分配。
  2. 如果后面请求分配同等大小的内存,只需要在 xxxSubpagePools中找到对应的空间直接分配即可,如果没有,重复 1。
  3. 如果请求分配不同内存大小 Tiny 或者 Small ,重复 1。

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

PoolThreadCache

PoolThreadCache 顾名思义就是本地线程缓存,当 Netty 释放内存时,它并没有将缓存归还给 PoolChunk,而是使用 PoolThreadCache 缓存起来,当下次申请相同大小的内存时,直接从 PoolThreadCache 取出来即可。PoolThreadCache 缓存了 Tiny、Small、Normal 三种类型的数据。

在前面我们知道 PoolArena 是使用 PoolChunk 和 PoolSubpage 来进行内存维护的,但是 PoolThreadCache 不同,PoolThreadCache 则是基于 MemoryRegionCache 来完成内存管理的。 MemoryRegionCache 是 PoolThreadCache 进行内存管理的基本单元。

在 PoolThreadCache 有三个这样的数组:

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 之内存篇:深挖 Netty 高性能内存管理

到这里整个 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 构建成一颗满二叉树,如下:

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

  • PoolChunk 中有两个数组来负责对 Page 的分配:memoryMap[]depthMap[]

  • 开始时 memoryMap[]depthMap[] 两个数组内容一样,都是等于树的高度,例如 memoryMap[2048] = depthMap[2048] = 11memoryMap[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 ,二叉树进入下图:

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

  • 分配 16KB

16KB 确认在第 10 层,memoryMap[1024] 有一个子节点已经分配出去了,所以它不满足条件,memoryMap[1025] 符合条件,则分配 1025 节点,将 1025 的两个子节点 2050、2051 设置为 12, memoryMap[2050] = 12memoryMap[2051] = 12 ,两个子节点都是 12,则 1025 节点也是 12,memoryMap[1025] = 12,更新父节点 512 节点为 11(原值是 9) ,memoryMap[512] = 11

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

  • 分配 8KB

继续分配 8KB,按照上面的逻辑可以找到 2049 节点,则将 2049 节点设置为 12,memoryMap[2049] = 12,父节点 1024 以及 512 都设置为 12。

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

到这里,Normal 场景内存分配就介绍完毕了,熟悉伙伴算法的小伙伴,对这个应该会很熟悉。

Tiny&Small 场景内存分配

大于 8KB 的内存使用 PoolChunk 的 Page 来分配内存,而小于 8KB 的,则采用 PoolSubpage 来管理。PoolSubpage 的管理方式,大明哥在这篇文章里面说了不下于三次了,所以不在阐述,就用一张图来描述吧。

死磕 Netty 之内存篇:深挖 Netty 高性能内存管理

分配过程,大明哥就不再详细阐述了,就留给各位小伙伴了。

好了,到这里整个 Netty 的内存管理就已经介绍完毕了,至于内存回收部分,我们后面源码部分再来分析分析。这里简单做一个总结。

  1. Netty 为了更好地管理内次,将内存的管理按照大小进行规格化管理,分别为 Tiny、Small、Normal 和 Huge。

  2. Netty 内存管理有 5 个核心组件,分别为 PoolArena、PoolChunkList、PoolChunk、PoolSubpage 和 PoolThreadCache。5 个核心组件分为两套管理模式:PoolThreadCache 和 PoolArena

    1. PoolThreadCache 为线程私有。Netty 为了使得内存的分配更加高效,将内存小于 32KB 内存在回收时并没有归还给 PoolChunk,而是缓存在 PoolThreadCache 中,等到下次申请内存时,优先从 PoolThreadCache 中获取。
    2. PoolArena 为所有线程共享,内存分配的核心组件是 PoolChunk。内存小于 8KB 的采用 PoolSubpage 的管理方式,内存大于 8KB 的则是使用 PoolChunk 的 Page 管理方式。
  3. PoolArena 是与线程绑定,当线程首次申请内存时会采用轮询的方式与某一个 PoolArena 进行绑定。PoolArena 包括两个核心部分:

    1. 两个 PoolSubpage 数组:一个 tinySubpagePools 数组,用于 tiny 内存分配,一个 smallSubpagePools 数组,用于 small 内存分配。
    2. 有 6 个 PoolChunk 构成的双向链表 PoolChunkList。6 个 PoolChunk 代表了 6 种内存使用率的 PoolChunk。
  4. PoolChunkList 是有内存使用率相同的 PoolChunk 构成的双向链表,随着 PoolChunk 的内存分配和释放,PoolChunk 会在不同规格的 PoolChunkList 中移动。

  5. PoolChunk 是 Netty 分配内存的核心所在,使用伙伴算法来管理 Page,尽可能地保证分配内存的连续性,Page 以满二叉树的方式实现,是整个内存池分配的核心所在。

  6. PoolSubPage 用于分配小于 8KB 的内存申请,在线程申请内存时,它首先会从数组( tinySubpagePools 和 smallSubpagePools )中找对应规格的空闲内存,如果有,则分配,如果没有,则从 PoolChunk 的一个 page 中申请,page 根据申请内存的大小进行均等划分,然后加入到 tinySubpagePools 数组中。

完毕!!

转载自:https://juejin.cn/post/7379772824905908251
评论
请登录