Netty源码阅读7——内存管理

Posted by 皮皮潘 on 12-06,2021

概述

Netty需要自己管理内存的原因:1. 在Netty中往往会有大量的ByteBuf被创建出来,在jvm中new一个对象的开销挺大的——需要依次检查类,链接,分配内存,初始化初始值,记录对象头等,其中主要是分配内存(当Heap不够时要调用malloc,另外要寻找内存分配以及并发处理)和初始化初始值比较费时 2. 大量的对象的创建也代表了大量对象的回收也即gc这也会造成性能上的影响 3. 本身的内存分配是普适的,针对特定的场景(多线程并发分配大量的大小不同的内存)性能并不好,因此需要针对性处理实现内存管理

另外在Netty中也大量使用了堆外内存,它的好处如下:

  1. 堆外的内存不由JVM管理,因此可以降低JVM的GC压力

  2. 当进行网络IO或者文件读写时,因为操作系统并不感知 JVM 的堆内存,堆内内存都要转化为堆外内存,然后再与底层设备进行交互,直接使用堆外内存可以减少一次内存拷贝

在Java中只能通过DirectByteBuffer或者Unsafe#allocateMemory来获得对应的堆外内存,但是用于Unsafe只能由JDK源码使用而不允许开发者使用,因此需要通过反射来获得Unsafe:

private static Unsafe unsafe = null;
static {
    try {
        Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        getUnsafe.setAccessible(true);
        unsafe = (Unsafe) getUnsafe.get(null);
    } catch (NoSuchFieldException | IllegalAccessException e) {
        e.printStackTrace();
    }
}

常用的内存分配器算法包括:

动态内存分配

  1. 首次适应算法:闲分区链以地址递增的顺序将空闲分区以双向链表的形式连接在一起,从空闲分区链中找到第一个满足分配条件的空闲分区,然后从空闲分区中划分出一块可用内存给请求进程

  2. 循环首次适应算法:该算法是由首次适应算法的变种,它不再是每次从链表的开始进行查找,而是从上次找到的空闲分区的下⼀个空闲分区开始查找

  3. 最佳适应算法:空闲分区链以空闲分区大小递增的顺序将空闲分区以双向链表的形式连接在一起,每次从空闲分区链的开头进行查找,这样第一个满足分配条件的空间分区就是最优解

伙伴算法

伙伴算法把内存划分为 11 组不同的 2 次幂大小的内存块集合,每组内存块集合都用双向链表连接,不同的组对应的内存块大小不同,比如index为2的组的内存块大小为2^2页,在分配内存时也会给它分配对应的2次幂大小的内存块,并进行块的重分组,比如我们需要分配10K大小的内存块,其分配过程如下:

  1. 首先需要找到存储 2^2页(4 * 4K = 16K)所对应的链表,即数组下标为 2;

  2. 查找 2^2 链表中是否有空闲的内存块,如果有则分配成功;

  3. 如果 22 链表不存在空闲的内存块,则继续沿数组向上查找,即定位到数组下标为 3 的链表,链表中每个节点存储 23 的连续 Page;

  4. 如果 23 链表中存在空闲的内存块,则取出该内存块并将它分割为 2 个 22 大小的内存块,其中一块分配给进程使用,剩余的一块链接到 2^2 链表中。

Jemalloc算法

在另外一篇源码阅读中给出

Netty实现

分配类型:小于512B(tiny),大于512B小于8KB(small),大于8KB小于16MB(chunk),大于16MB(不由内存池管理)

PoolArena 的数据结构包含两个 PoolSubpage 数组和六个 PoolChunkList,两个 PoolSubpage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向循环链表,其中各个ChunkList的含义如下:

  • qInit,内存使用率为 0 ~ 25% 的 Chunk。
  • q000,内存使用率为 1 ~ 50% 的 Chunk。
  • q025,内存使用率为 25% ~ 75% 的 Chunk。
  • q050,内存使用率为 50% ~ 100% 的 Chunk。
  • q075,内存使用率为 75% ~ 100% 的 Chunk。
  • q100,内存使用率为 100% 的 Chunk。

在分配大于 8K 的内存时,其链表的访问顺序是 q050->q025->q000->qInit->q075,每个PoolChunkList有对应的min和max值,当超出或低于区间后PollChunk会在不同PoolChunkList中移动,为了防止PoolChunk一直处在某个临界值而导致其在两个List中不断移动造成性能损耗,因此不同List的区间有所重叠

PoolChunk是真正存储内存数据的地方,每个PoolChunk的默认大小为16M,PoolChunk可以理解为Page(大小为8K)的集合,在Netty中PoolChunk基于伙伴算法分配成2048个Page形成一颗满二叉树(叶子节点对应真正的Page,中间节点拥有其下所有节点剩余的空间,共4095个节点),在PoolChunk中有三个核心的属性:1. depthMap用于存放二叉树节点所对应的高度,节点id即为对应的索引 2. memoryMap用于记录二叉树节点的分配信息,节点id即为对应的索引 3. subpages对应树中的2048个叶子节点,也即真正的内存所在的地方

PoolSubpage其实就是一个大小为8K的Page,它对应PoolChunk中的满二叉树的叶子节点,只是它会被等分成n个大小为elemSize的小内存块,并且通过bitmap记录每个小内存块的状态,假如分配的内存大小远小于 Page,直接分配一个 Page 会造成严重的内存浪费,所以需要将 Page 划分为多个相同的子块进行分配,这里的子块就相当于 Subpage。按照 Tiny 和 Small 两种内存规格,SubPage 的大小也会分为两种情况。在 Tiny 场景下,最小的划分单位为16B,按 16B 依次递增,16B、32B、48B ...... 496B,每种类型对应tinySubpagePools中的一个索引(同类型的Subpage之间通过双向链表连接);在 Small 场景下,总共可以划分为 512B、1024B、2048B、4096B 四种情况,每种类型对应smallSubpagePools中的一个索引(同类型的Subpage之间通过双向链表连接)。Subpage 没有固定的大小,需要根据用户分配的缓冲区大小决定,例如分配 1K 的内存时,Netty 会把一个 Page 等分为 8 个 1K 的 Subpage。

当Netty释放内存时,不是直接归还到PoolChunk中,而是缓存到PoolThreadCache里,在PoolThreadCache中缓存Tiny(同tinySubpagePools)、Small(同smallSubpagePools)以及Normal(存8K,16K以及32K三种类型,具体实现同pools类似)三种类型数据

整体架构如下:
Ciqc1F_DohmABGJKAA4YPK4ef2s293.png

PoolChunk架构如下:
Image.png

Netty内存分配

Netty 中负责线程分配的组件有两个:PoolArena和PoolThreadCache。PoolArena 是多个线程共享的,每个线程会固定绑定一个 PoolArena,PoolThreadCache 是每个线程私有的缓存空间,在申请的内存大于 8K 时,PoolChunk 会以 Page 为单位进行内存分配。当申请的内存大小小于 8K 时,会由 PoolSubpage 管理更小粒度的内存分配,PoolArena 分配的内存被释放后,不会立即会还给 PoolChunk,而且会缓存在本地私有缓存 PoolThreadCache 中,在下一次进行内存分配时,会优先从 PoolThreadCache 中查找匹配的内存块

Page级别内存分配

在满二叉树中,节点可分配的内存只和其高度有关而与层级(节点id)无关(高度会随着内存的分配而改变,而层级不会),节点的高度越低代表其可以分配的内存越多,mem = 8K * (2 ^ (11 - height)),当height为12时,代表无可分配内存。

在具体实现中首先可以根据分配内存大小计算二叉树对应的节点高度—— d = maxOrder - (log2(normCapacity) - pageShifts),其中maxOrder为二叉树最大高度11,normCapacity为需要分配的内存,pageShifts为13,然后查找对应高度中是否存在可用节点,每次找到可用节点就更新其自身所有子节点高度为12,同时递归更新父节点的值为各自子节点中较小的高度

Subpage级别内存分配

为了提高内存分配的利用率,在分配小于 8K 的内存时,PoolChunk 不在分配单独的 Page,而是将 Page 划分为更小的内存块,由 PoolSubpage 进行管理

PoolThreadCache的内存分配

  • 对申请的内存大小做向上取整,例如 20B 的内存大小会取整为 32B。

  • 当申请的内存大小小于 8K 时,分为 Tiny 和 Small 两种情况,分别都会优先尝试从 PoolThreadCache 分配内存,如果 PoolThreadCache 分配失败,才会走 PoolArena 的分配流程。

  • 当申请的内存大小大于 8K,但是小于 Chunk 的默认大小 16M,属于 Normal 的内存分配,也会优先尝试从 PoolThreadCache 分配内存,如果 PoolThreadCache 分配失败,才会走 PoolArena 的分配流程。

  • 当申请的内存大小大于 Chunk 的 16M,则不会经过 PoolThreadCache,直接进行分配。

Netty内存管理核心思想

  • 分四种内存规格管理内存,分别为 Tiny、Samll、Normal、Huge,PoolChunk 负责管理 8K 以上的内存分配,PoolSubpage 用于管理 8K 以下的内存分配。当申请内存大于 16M 时,不会经过内存池,直接分配。

  • 设计了本地线程缓存机制 PoolThreadCache,用于提升内存分配时的并发性能。用于申请 Tiny、Samll、Normal 三种类型的内存时,会优先尝试从 PoolThreadCache 中分配。

  • PoolChunk 使用伙伴算法管理 Page,以二叉树的数据结构实现,是整个内存池分配的核心所在。