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

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

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

  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 的内存分配和回收效率更高效了。

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

我们先看下图:

从上图可以看出,这些节点几乎有一半空间是重叠的,为什么要这么设计呢?我们假定,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。

  1. 首次分配内存时,PoolArena 中的 xxxSubpagePools的双向链表为空。这个时候 Netty 会将 PoolChunk 中的一个空闲 Page 进行均等切分并且加入到 PoolArena 中的 xxxSubpagePools中,完成内存分配。
  2. 如果后面请求分配同等大小的内存,只需要在 xxxSubpagePools中找到对应的空间直接分配即可,如果没有,重复 1。
  3. 如果请求分配不同内存大小 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] = 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 ,二叉树进入下图:

  • 分配 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

  • 分配 8KB

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

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

Tiny&Small 场景内存分配

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

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

好了,到这里整个 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 数组中。

完毕!!

相关推荐
球球King5 分钟前
工厂模式之简单工厂模式
java·jvm·简单工厂模式
续亮~11 分钟前
6、Redis系统-数据结构-06-跳表
java·数据结构·数据库·redis·后端·缓存
不决问春风18 分钟前
102.二叉树的层序遍历——二叉树专题复习
java·算法·leetcode
哎呦没19 分钟前
MOJO编程语言的编译与执行:深入编译器与解释器的工作原理
java·开发语言·mojo
得不到的更加爱1 小时前
Java多线程不会?一文解决——
java·开发语言
五敷有你1 小时前
Go:hello world
开发语言·后端·golang
拔剑纵狂歌1 小时前
Golang异常处理机制
开发语言·后端·golang·go
缘友一世1 小时前
Armbian 1panel面板工具箱中FTP服务无法正常启动的解决方法
linux·运维·后端·1panel
ffyyhh9955111 小时前
java进行音视频的拆分和拼接
java·音视频
weixin_419349791 小时前
flask使用定时任务flask_apscheduler(APScheduler)
后端·python·flask