Netty内存池笔记



简介

Netty网络模型和内存模型很重要,在4.0以后引进了类似JEMalloc算法的PooledByteBuf,内存管理能力大大提升,本文尽量用少量的代码,把整个内存池框架说清楚。

文中以下都是使用PooledByteAllocator类下的默认参数讲解

  1. 默认Page大小:defaultPageSize=8K
  2. 默认Page Tree层:defaultMaxOrder=11
  3. 默认Chunk大小:defaultChunkSize=defaultPageSize*defaultMaxOrder=16M
  4. 内存足够下PoolArena的个数:defaultMinNumArena==2*CPU
  5. 默认DEFAULT_TINY_CACHE_SIZE:512
  6. 默认DEFAULT_SMALL_CACHE_SIZE:256
  7. 默认DEFAULT_NORMAL_CACHE_SIZE:64
  8. 默认DEFAULT_MAX_CACHED_BUFFER_CAPACITY:32K

内存池的内存结构

Chunk

在内存池一个Chunk默认大小是16MB,是内存池申请内存的最小单位。

Page

申请一小内存不可能总是直接申请一个Chunk,这样会导致内存利用率低,所以在Chunk的基础上,进一步逻辑分区,Page就是比Chunk小的逻辑分区,把Chunk平均分为2048份,即每一份Page默认大小是8KB。

PoolSubPage

对于申请更小颗粒的内存而言,Page还是显得过大,所以还得进行逻辑分区。把一个Page进一步分为众多的PoolSubPage。这次的PoolSubPage是多类型的,一个类型对应一个Page。最小的类型为16B,最大为4KB。内存划分过大,对于小对象就会浪费很多空间。为了高效管理小内存,在[16,4K]这区间里面有,把[16,496]以公差为16的等差数列分为TinySubPage,把[512,4K]以公比为2的等比数列分为SmallSubPage。

如果直接逻辑分区成SmallSubPage和TinySubPage,略过Page就会导致内存过于碎片化,申请内存在大于Page而小于Chunk的情况就会效率低下。

内存池的内存划分

内存池按3种内存类型划分,分别是Normal、Small和Tiny。其实内存类型有4种,还有一种是Huge,大于一个Chunk的大小。因为大对象缓存起来占用内存空间并且内存碎片化很小,所以直接在池外分配,不涉及内存池。

以下暂时忽略缓存、PoolSubPage和ChunkList的影响,讲述Netty的内存池的内存类型以及代码逻辑上如何划分。

无论内存池哪种类型,在申请内存的时候,都需要normalize,在计算机里面即把内存大小扩展大于或等于申请值的最小2次幂。例如申请9KB内存,就normalize为16KB。这也是造成一部分内存浪费,但也是极大方便计算机管理内存(时间和空间的博弈)。

代码执行上很简单,先判断是否是Tiny或者Small,不是再判断Normal,最后就是Huge直接池外分配了。

Normal

介于Page和Chunk大小之间,大小数值是区域为[8K,16M]以公比为2的等比数列(8K,16K,32K…8M,16M)。

以下是Chunk逻辑上划分Page的结构图

Page结构图

这是一棵满二叉树,叶结点含义是“物理”的Page,共有4095-2048+1=2048份,这在上小结谈及过。每一个结点对应一个数值,其表示这个结点以及子结点对应的层数有可分配空间。结点对应的层数越高(数值越小),对应可分配空间越大。

  1. 空闲的Chunk每一个结点对应的数值等于树的层。如结点1->0,6->2,512->9,2049->11。
  2. 当对应的数值大于层数,则认为子结点对应的Page部分或完全被分配了。
    1. 若结点512->10,表明512结点在10层有可分配空间(子结点1025),其余子结点的空间均被分配完了。
    2. 若结点512->11,表明512结点在11层有可分配空间(叶结点2051),其余子结点的空间均被分配完了。
    3. 若结点512->12,表明6结点以下没有可分配的空间,没有12层。

申请内存流程图

PlantUML SVG diagram

总的来说主要做两件事情

  1. 向下遍历找到内存大小对应层数的结点,并更新设置为不可再分配。
  2. 向上遍历更新此结点所有父结点对应的值。

释放内存流程

已经拿到结点的序号,释放流程还是比较简单的,这里只是简单说一下流程。

  1. 根据结点的序号设置对应的值为树的层。
  2. 向上遍历更新此结点所有父结点对应的值。

Tiny和Small

Tiny和Small分配内存的逻辑是一样的。PoolSubPage对于一块Page而言,都是同一大小类型,如一个Page分配了16B的Tiny类型,那么这个Page只能继续分配16B的Tiny类型。

从数学上计算可以知道8k/16=512,如果每一等份都用一个byte做记录,消耗是巨大的,这里运用了位图。利用bit记录一份PoolSubPage内存。对于64位bit而言,512/64=8,只要8个类型就可以了。

对于分配了4KB的Small类型,用位图就显得浪费了,但为了程序的统一和编写便利,这点空间也是无所谓了。

申请内存流程

  1. 找到对应类型的Page(忽略了PoolSubPage不好拓展分析,姑且认为是可以很容易找到)。
  2. 找不到对应的Page就走Normal的流程,在Normal的基础上,把Page结点对应的序号和位图一起结合,返回一个handle,记录这个handle作为此类型的PoolSubPage。

以上都是忽略缓存、PoolSubPage和ChunkList的影响,所以逻辑上还是比较简单。

释放内存流程

  1. 在释放Normal的基础上,释放对应位图。

内存池的管理

为了让结构的层次分明,以下忽略了缓存的影响。

从上一节可以知道分析的Tiny和Small的时候略过了PoolSubPage,以至于无法清晰回答如果找到对应的Page。这里分析内存池是怎么管理和优化的。

PoolArena可以看作是一个内存池,默认情况下是2*CPU个数,也就是2*CPU个内存池。至于为什么要多个内存池,其实是跟Netty的网络模型和锁粒度有关系。

Netty默认情况下也会有2*CPU个Worker线程工作,多个内存池可以细化锁的粒度(锁的key为PoolArena对象),在多个内存池的加持下,无论申请还是释放,效率都能大大提高。

Netty会选择使用率最少的PoolArena绑定到当前线程作为Arena资源。

PoolArena结构

以下是PoolArena的比较重要的结构图。

PoolArena结构图

管理Chunk

qInit、q00、q25、q50、q75和q100是针对Normal内存管理的ChunkList,每个ChunkList是一个双向链表。每一个结点代表是一个Chunk。每次添加新的结点都插入到head的后继结点。

q00的管理的是Chunk的使用率区间在[1,50),其他的ChunkList使用率区间类比结构图。

基于Normal的申请内存流程

  1. 依次从q50、q25、q00、qInit、q75这5个ChunkList选择符合条件(Chunk存在且容量符合)的Chunk并分配空间。
  2. 若没有一个ChunkList可以提供符合要求的Chunk的时候,就会创建一个Chunk并add到qInit上。
  3. 走Normal的申请内存流程。
  4. 分配空间执行add方法的时候,若Chunk使用率大于或等于上限值,则会转移到下一个ChunkList。

基于Normal的释放内存流程

  1. 走Normal的释放内存流程。
  2. 释放空间执行free方法的时候,若Chunk使用率小于下限制,则会转移到上一个ChunkList。

这里使用率有两个特殊值就是0%和100%。代码里面不能直接利用使用的字节/Chunk的容量,涉及到小数转化为整数的过程。Netty源代码是这样处理:

1
2
3
4
5
6
7
8
9
10
11
private int usage(int freeBytes) {
    if (freeBytes == 0) {
        return 100;
    }

    int freePercentage = (int) (freeBytes * 100L / chunkSize);
    if (freePercentage == 0) {
        return 99;
    }
    return 100 - freePercentage;
}

若释放Chunk的所有空间,即没有字节使用的时候返回0%,则从q00删除对应的Chunk,也就是彻底删除了曾经分配过的Chunk。

管理PoolSubPage

tinySubPagePools和smallSubPagePools都是PoolSubPage类型的数组引用,二者没有本质差别,针对Tiny和Small内存管理的数组。数组存储了表头head,是一个双向链表。每一个结点代表是一个PoolSubPage。每次添加新的结点都插入到head的后继结点。

tinySubPagePools和smallSubPagePools以大小划分。tinySubPagePools有31个分区。虽然代码里面数组容量为32,但index=0并没有使用。

基于Tiny和Small的申请内存流程

  1. 通过Tiny和Small的大小映射index,通过tinySubPagePools[index]和smallSubPagePool[index]找出SubPagePool对应的head结点开始遍历后续的结点。
  2. 因为PoolSubPage包含了Chunk和位图就能找到对应的Page,所以找到PoolSubPage就能找到Chunk对应的Page。从而进行内存的分配。
  3. 通过tinySubPagePools和smallSubPagePool找不到对应的Page就走Normal的流程,在Normal的基础上,把Page结点对应的序号和位图一起结合,返回一个handle,记录这个handle作为此类型的PoolSubPage。

基于Tiny和Small的释放内存流程

  1. 在释放Normal的基础上,释放对应位图。
  2. 若根据位图释放内存后,PoolSubPage的使用率为0%,则把这块PoolSubPage放到对应的tinySubPagePools[index]和smallSubPagePool[index]。

内存申请与释放在多线程上的优化

虽然说默认情况下,PoolArena的个数和的Worker线程个数一致,但处理业务的时候都会在新的线程池重新运行业务线程。这将会加大同一个PoolArena申请与释放内存的竞争。这里Netty利用PoolThreadCache优化从而进一步减少资源的竞争。

PoolThreadCache是线程相关的类,每一个线程均有各自的PoolThreadCache。内部包含arena、tinySubPageCache、smallSubPageCache和normalCache。

cache是MemoryRegionCache类,包含一个Multi-Producer-Single-Consumer(MPSC)队列。

tiny、small和normal的cache数组默认大小是512、256和64。normal的其他规格因为容量较大,所以normalCache只缓存8k、16k、和32k三种大小的容量。

申请内存

  1. 无论是Tiny、Small和Normal类型,若找到对应的缓存规格,从MPSC队列为其分配PooledByteBuf。

释放内存

  1. 若发现释放内存对应的缓存规格,则把本该释放的内存放到MPSC队列上,而不直接释放。