Netty内存池整体设计解读

Netty内存池整体设计解读

背景

在网络IO中,会涉及到大量的数据读取和写出操作,背后是大量的内存申请和回收。这部分工作通过GC来完成,会带来比较大的GC压力,频繁的申请和释放内存对GC不友好,因此Netty开发了一个内存池模块用于内存的分配和回收来降低GC压力。核心思路就是事先申请一大块内存进行分配和回收,来避免JVM的GC操作。透过测试,内存池可以有效的平稳GC抖动。

名词定义

  • Chunk: 内存块,一个连续内存空间的管理者。具体有堆内Chunk和堆外Chunk。默认大小为16MB。
  • Page: 内存页,一个单位的内存大小。Chunk将自身申请的连续内存空间分割成相等大小的一堆Page。通过对Page的分配来完成内存分配功能。
  • PooledChunkList: 将有相同使用率区间的Chunk集中在一起形成的一个Chunk列表。目的在于高效的分配和管理。
  • Arena: 竞技场,作为内存分配的总体入口,所有的内存分配动作都在这个类中对外提供。

总体设计思路

为了降低JVM的GC压力,降低频繁申请堆外内存造成的性能损耗,设计一个内存池模块。该模块事先申请了一大块的连续内存用于分配。令后续系统中所有的内存空间请求均需要从内存池进行申请,使用完毕后归还给内存池。通过内存池,将这部分内存管理工作自己执行,也减少了这部分的GC损耗。

为了实现内存池的功能,设计一个内存结构Chunk,其内部管理着一个大块的连续内存区域,将这个内存区域切分成均等的大小,每一个大小称之为一个Page。将从内存池中申请内存的动作映射为从Chunk中申请一定数量Page。为了方便计算和申请Page,Chunk内部采用完全二叉树的方式对Page进行管理。

依靠Chunk,可以对大于等于PageSize的内存申请进行分配和管理。但是如果小于PageSize的申请也要消耗掉一个Page的话就太浪费。因此设计一个内存结构SubPage,该结构将一个Page的空间进行均分后分配,内部通过位图的方式管理分配信息。

有了Chunk和SubPage后,对于大于PageSize的申请走Chunk的分配,小于PageSize的申请通过Subpage分配。而为了进一步加快分配和释放的性能,设计一个线程变量PoolThreadCache,该线程变量以线程缓存的方式将一部分内存空间缓存起来,当需要申请的时候优先尝试线程缓存。通过减少并发冲突的方式提高性能。

单个Chunk的内存分配算法

分配内存

一个Chunk由 2 n 个Page组成。为了方便管理和分配,将这些Page以完全二叉树的形式组织起来。形成的逻辑视图如下:
mark
从图可以看到,初始情况下,每一个父节点的容量是子节点的2倍,每一个叶子节点代表一个Page。
其分配算法如下:
+ 叶子节点的初始容量是单位大小。其可分配的容量是0(被分配走后)或者初始容量。
+ 父节点的可分配容量是两个子节点可分配容量的较大值;如果2个子节点均为初始容量,则父节点可分配容量为子节点分配容量之和(也就是2倍);这条规则尤为重要,其是整个分配的核心。
+ 分配时首先需要将申请大小标准化为 2 n 大小,定义该大小为目标大小。判断根节点可分配容量是否大于目标大小,如果大于才可以执行分配。
+ 通过计算公式算出可允许分配的二叉树层级(该层级是节点容量大于等于目标大小的最深层级)。得到目标层级后,从根节点开始向下搜索,直到搜索到目标层级停止。搜索过程中如果左子节点可分配容量小于目标大小则切换到右子节点继续搜索。
+ 如果分配成功,由于本节点的可分配容量下降至0,因此需要更新父节点的可分配容量。并且该操作需要不断向父节点回溯执行,直到根节点为止。

Chunk中通过完全二叉树来管理Page,并且使用数组来代表完全二叉树(根节点从下标1开始,这样左子节点的下标就是父节点下标2倍,父节点下标就是当前节点下标除以2),该数组存储的是该下标节点当前可分配容量。当最终申请成功时,是寻找到了一个可以分配的节点,返回该节点的下标就算申请完成。

释放内存

释放内存就是申请内存的逆操作。流程基本是

  • 通过节点下标寻找申请节点。
  • 将申请节点的可分配容量从0恢复到初始可分配容量。
  • 如果当前节点和兄弟节点的可分配容量都是初始可分配容量,更新父节点的可分配容量为其初始可分配容量;否则更新父节点的可分配容量为2个子节点中的较大值。重复该动作直到根节点为止。

Chunk移动

随着内存的申请和释放,Chunk内部空间的使用率也在不断变化中。为了提高内存的使用率和分配效率。Arena内部使用ChunkList将chunk进行集中管理。每一个ChunkList都代表着不同的使用率区间。当Chunk的使用率变化时可能就会在不同的ChunkList中移动。具体的话,会在代码解析中做进一步的讲解。

小容量内存分配算法

Chunk中最小的分配单位是Page。一个Page通常不能太小,像Netty中默认是8K,最小不低于4k。如果申请的大小小于page,也需要分配一个就比较浪费了。Netty针对小容量申请设计了单独的分配算法。其核心思想就是针对小于Page大小的请求,申请一个Page,并且根据请求大小,将Page均分;每次分配均分后的一部分。
对于Page均分后的空间管理,采用位图的方式进行。

分配算法

分配算法主要如下:

  • 将申请大小标准化为 2 n 大小,定义为请求大小。
  • 选择合适的Chunk申请一个Page。将得到的Page初始化为SubPage,其内部按照请求大小均分为h份;并且将该SubPage放入Arena的SubPage列表中。
  • 从SubPage中选择一份没有使用的均分空间用于分配。设置其对应位图位置为true(true意味着使用)
  • 后续有类似的请求,优先从Arena的SubPage列表中获取符合需求(SubPage的切分大小等于请求大小)的SubPage进行分配。
  • 如果一个SubPage的内部空间全部被分配完毕,则从Arena的列表中删除。

释放内存

释放是分配的逆过程。基本上就是将Subpage对应位图区域设置为false。过程如下:

  • 将SubPage对应区域的位图设置为false。
  • 如果本SubPage的可用区域刚恢复为1,则将SubPage添加到Arena的SubPage列表中。

管理方式

Chunk本身是不进行SubPage的管理的,只负责SubPage的初始化和存储。具体的分配动作则移交给Arena负责。核心策略如下:

  • 按照请求大小区分为tiny(区间为[0,512)),small(区间为[512,pagesize)),normal(区间为[pagesize,chunkSize])
  • 在tiny区间,按照16B为大小增长幅度,创建长度为32的SubPage数组,数组中每一个Subpage代表一个固定大小。Subpage本身形成一个链表。数组中的SubPage作为链表的头,是一个特殊节点,不参与空间分配,只是起到标识作用。
  • 在small区间,按照每次倍增为增长服务,创建长度为n(n为从512倍增到pagesize的次数)的SubPage数组,数组的含义同tiny的SubPage数组。
  • 每次申请小于pagesize的空间时,根据标准化后的申请大小,从tiny区间或small区间寻找到对应大小的数组下标,并且在SubPage链表上寻找SubPage用于分配空间。
  • 如果SubPage链表上没有可用节点时,则从Chunk上申请一个Page,然后初始化一个SubPage用于分配。

优化改进

SubPage实际上只是定义了一些元信息,本身不做任何存储。其定义的元信息主要有:

  • 内部元素的切分大小elementSize
  • 管理元素使用状态的位图bitMap
  • SubPage申请的page在chunk中的下标index。

为了避免SubPage对象的反复生成,在Chunk内部定义了SubPage数组用于存储Subpage对象。由于一个SubPage实际上对应一个叶子节点,因此只需要根据申请的叶子节点的坐标即可从SubPage数组上定位对应的SubPage(叶子节点的坐标-最左侧叶子节点的坐标即为SubPage在数组中的坐标)。
如果一个SubPage需要再次使用,只需要初始化元信息即可。

线程缓存

从Arena中申请或释放空间都需要与其他的线程进行竞争。很容易想到通过线程变量的方式减少竞争来加快速度。事实上Netty也采取了这种策略。Netty设计了一个类PoolThradCache,通过线程变量的方式存储。当程序使用完毕内存空间时,首先尝试添加到PoolThreadCache。申请空间也首先尝试从缓存中申请。
PoolThreadCache的设计思路较为简单,其核心就是事先创建不同内存大小的槽位。每一个槽位都是一个MPSCArrayQueue(采用这个结构一个是为了控制缓存空间的个数,一个也是为了性能。在缓存场景下,申请空间只有当前线程,而归还空间是可能多线程的)。当需要申请空间时,首先尝试找到大小匹配的槽位,然后从上面的MPSCArrayQueue上获取内存信息。如果获取成功则分配完成。
设计思路简述如下:

  • 定义类MemoryRegionCache,其核心为MPSCArrayQueueue,队列存储的是内存空间信息
  • PoolThreadCache创建三种不同的MemoryRegionCache数组。分别为tiny数组,small数组,normal数组。
  • tiny数组和small数组的长度与PoolArena中对应的SubPage数组长度相同。其内在含义就是所有的tiny大小和small大小的空间都可以在缓存中被缓存。
  • normal数组的大小根据需要缓存的最大空间大小设定。数组长度是从pageSize倍增到需缓存的最大空间大小所需的倍数。这也意味着超过一定大小的空间,线程缓存是不处理的,以避免在线程内堆积过多的内存。
  • 当需要从线程缓存中申请空间时,根据申请大小从MemoryRegionCache数组中找到合适的MemoryRegionCache。如果能找到,则从其MPSCArrayQueue中获取节点得到内存信息。
  • 当需要释放内存空间时,则优先尝试将内存空间加入到缓存中。也是一样先尝试找到合适的MemoryRegionCache。如果能找到则尝试将内存信息放入队列中。如果队列已满,则竞争Arena进行标准的释放流程。

流程解析

整个内存池的核心任务就是分配内存,回收内存。
分配的核心就是寻找到一个有足够分配空间的chunk,并且从该Chunk中寻找到可以分配的最合适节点,得到节点下标就算分配完成。特别的,如果分配的是小于PageSize的大小,不仅要获得正常大小分配后的chunk对象和节点下标,还需要获得代表均分Chunk叶子节点空间信息的位图坐标。
为了支撑足够的分配功能和性能,Netty设计了几个重要的数据结构来支撑。分别是:
+ Arena:整个内存分配的总入口,所有与内存池相关的操作均通过该类提供的接口完成
+ Chunk:内存块分配的入口,存储着一个整块的内存空间以及对应的节点分配信息。主要负责分配正常大小的节点;SubPage需要的叶子节点空间也是从Chunk中申请。
+ SubPage:小于PageSize的大小分配由SubPage负责。内部通过位图存储分配信息。
+ PoolThreadCache:线程内缓存。游离于整个体系之外,通过线程变量的方式,缓存一定的分配空间,进一步的提高性能

Chunk中节点的下标是一个32位的正整数。SubPage中位图的坐标也是一个32位的正整数。因此为了方便表达,将两个Int通过一个long来表示,定义为handle:高32位表达位图坐标,低32位表示在Chunk中的节点坐标。因此定位一个申请空间的信息就是一个二元组:{chunk对象实例,handle坐标}

下面分别来介绍这些数据结构以及其支持的分配和回收功能。

Arena

重要属性

Arena是整个内存分配的总入口。其内部有几个重要的属性。分别是
+ TinyPoolSubPage数组:用于存储不同tiny大小的SubPage。每一个槽位代表的大小比前一个槽位多1,槽位0的大小是0。其中SubPage自身构成链表,在数组中的值则为链表表头。
+ SmallPoolSubPage数组:用于存储不同Small大小的SubPage。每一个槽位代表的大小是前一个槽位的一倍,槽位0的大小是256。逻辑和TinyPoolSubPage相同。这两个数值本质上是相同的,只不过大小的增长规律不同。
+ 5个不同使用率区间的PoolChunkList:他们彼此之间也构成双向链表。Chunk随着使用率的变化在这几个ChunkList中移动

以上的内容通过构造方法看的更清晰一些。

 //在tiny区间,以16为大小,每一个16的倍数都占据一个槽位。槽位0大小为0,为了方便定位,实际上数组的0下标是不使用的
tinySubpagePools = newSubpagePoolArray(numTinySubpagePools);
for (int i = 0; i < tinySubpagePools.length; i ++) {
    tinySubpagePools[i] = newSubpagePoolHead(pageSize);
}
// 在small,从1<<9开始,每一次右移都占据一个槽位,直到pagesize大小。槽位0大小为512.
numSmallSubpagePools = pageShifts - 9;
smallSubpagePools = newSubpagePoolArray(numSmallSubpagePools);
for (int i = 0; i < smallSubpagePools.length; i ++) {
    smallSubpagePools[i] = newSubpagePoolHead(pageSize);
}
//PoolChunkList在介绍Chunk移动时提到过。为了避免chunk在多个chunkList频繁移动,因此每一个chunkList的区间有重叠的部分。
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);

分配

整个分配过程如下
mark
这里面有几个细节点需要依次展开,分别是超大容量分配Subpage分配正常大小分配以及线程缓存分配

超大容量分配

首先来看下超大容量分配的流程。
对于超出了ChunkSize的大容量请求,这种内存申请不会缓存起来也不会从Chunk分配。是一个一次性消耗品。做法也很简单,申请一个对应大小的内存空间,初始化PooledBuffer即可。这里有一个细节需要注意,内存空间的释放是以Chunk为单位,通过子类Arena来实现的。因此这里实际上是新建了一个特殊的Chunk实例,该实例内部没有二叉树等等一系列用于分配释放的数据结构,仅仅简单包装了一段内存空间。同时标记自身为非池化。使得Arena可以识别出这个特殊的Chunk,可以快速为其执行释放动作。

正常大小分配

正常大小分配流程中,请求的容量可能大于PageSize,此时实际上分配了一个在Chunk中的节点作为空间标识;也可能小于PageSize,此时实际上是在申请一个SubPage。下面先看在Arena中的整体流程,如下
mark
PoolChunkList中的分配是比较简单的,核心思路就是遍历在List中的Chunk,每一个chunk都尝试一次分配。如果有一个chunk分配成功则成功。否则认为失败。
PoolChunkList的尝试顺序也是较为关键。优先从50使用率开始,然后不断下降,最后一个才尝试75使用率的。这样分配成功所需要的尝试次数比较少一些。
整个分配过程,需要对Arena对象进行加锁
具体Chunk中如何分配是一个相对复杂的流程,放在专门的Chunk章节中。

SubPage分配

SubPage分配的思路就是寻找到合适大小匹配的SubPage,因为在SubPage列表中的SubPage都是有剩余空间,所以必然可以分配成功。
计算得到Subpage的流程如下
mark
得到SubPage后就可以从SubPage中开始分配空间。SubPage的分配是一个相对复杂的流程,放在专门的SubPage章节中。
这边有一个需要注意的是,开始进行分配前,需要对头节点的SubPage进行加锁,因为无论是分配或者回收,都可能会变动到SubPage的内容或者是整个链表的结构。通过对头结点加锁来避免并发冲突。

回收

上面的章节讲述了分配的流程。下面来说分配的逆流程:回收。流程如下:
mark
其中Chunk内部的回收动作在Chunk章节描述。总结下,回收动作实际上核心就是两点:

  • 执行Chunk的回收动作
  • Chunk回收空间后使用率变化,有需要时移动其到合适的前向ChunkList或者没有前向ChunkList时执行释放动作

Chunk

Chunk负责维持一个整块的内存的分配和回收。其内部通过二叉树来管理,这个在核心设计思路中已经讲述过了。首先来看下一些重要的属性。

重要属性

//Chunk是一个抽象类。其内存空间可能是byte[]也可能是DirectByteBuffer
T memory;
//这是Chunk最重要的属性。用于存储节点分配信息的以数组形式表达的二叉树。每一个槽位的值都是该节点可以分配的最大容量。为了方便计算,槽位0不使用。
int[] allocationsCapacity;
//单页的大小
int pagesize;
//该chunk的最大层级。最小层级为层级0
int maxLevel;
//由于SubPage实例本身是可以复用的,因此将这个对象实例存储在Chunk内部。每次需要重新申请的时候只需要初始化下参数即可
SubPage[] subpages;

分配

Chunk分配的过程如下:
mark
流程中的SubPage分配动作,是寻找一个可用的叶子节点作为SubPage进行初始化,而后再从这个初始化完毕的SubPage中进行第一次分配。
从流程图也可以看出,分配过程也可以看成两个相对独立的流程,分别是节点分配与SubPage分配。

节点分配

节点分配的流程如下

mark

当分配成功时,需要从index节点开始对父节点的最大可分配容量进行更新。更新的流程如下

mark

完成上面的工作后,节点分配的工作就完成了。

SubPage分配

在Chunk中执行SubPage分配意味着两个含义。

  • 首先,在Arena对应大小的SubPage链表中没有可以分配的SubPage,需要从Chunk初始化一个SubPage,放入到Arena的链表中
  • 从这个初始化的SubPage中执行对应大小的申请分配。

下面来看具体的SubPage分配流程
mark
具体在Subpage中如何进行分配,放在SubPage的章节中说明。

回收

与分配相同,Chunk中的回收也涉及到节点回收以及SubPage回收。下面来看下总体流程
mark
这里面有2个独立的子流程,分别是递归更新父节点的最大可分配大小以及在SubPage中执行回收。后者放在SubPage的章节中说明。下面来看递归更新父节点的最大可分配大小流程,如下
mark

SubPage

SubPage负责小于PageSize大小的空间分配。首先来看下内部的重要属性

重要属性

//该SubPage所属的Chunk
Chunk chunk;
//该Subpage对应的叶子节点的坐标
final int memoryMapIdx;
//该Subpage对应的空间在Chunk申请的空间中的偏移量
final int runOffset;
final int pageSize;
//代表整个SubPage使用情况的位图信息
final long[] bitmap;
PoolSubpage<T> prev;
PoolSubpage<T> next;
//本次SubPage内的元素大小
int elemSize;
//本次最大可分配元素个数
int maxNumElems;
// 本次位图的实际长度
int bitmapLength;
// 下一个可用的元素下标
int nextAvail;
// 当前剩余可用的元素个数
int numAvail;

SubPage内元素的大小是一样的,因此通过位图的方式进行管理。但是在具体算法上,netty采用了通过long数组来实现位图功能而不是jdk的原生Bitset类。SubPage实例本身是可以重复利用的,因为每次不同的仅仅是元素的大小。数组bitmap以元素大小16B为情况初始化为最长情况的数组。通过bitmapLength参数来控制每次位图数组真正的可用长度。

SubPage要投入使用,首先要执行初始化。初始化的作用主要是

  • 设定elementSize为均分的元素大小
  • 通过elementSize确定本次的maxNumElements
  • 通过maxNumElements确定位图的可用长度
  • 将初始化完毕的SubPage实例添加到Arena的SubPage列表中

初始化完毕后的SubPage就可以投入本次分配了。

分配

mark

在SubPage执行分配之前,首先需要对Arena中Subpage链表的表头进行加锁。而如果该Subpage没有可分配空间后,则会从链表中删除。所以对于Arena来说,能够定位到SubPage实例,则必然分配成功。

回收

回收的流程如下
mark

PoolThreadCache

透过上面的章节,已经阐述了内存分配的完整体系。而为了进一步的提高性能,Netty设计了一个线程变量,该线程变量缓存一定的分配空间,使得一部分分配回收不走流程算法体系,且排除了其他线程的竞争。下面首先来看下该类的重要属性

重要属性

PoolTghreadCache不是泛型抽象类,因此其内部具备两套相似的属性,分别用于缓存堆类型和非堆类型的内存空间

final PoolArena<byte[]> heapArena;
final PoolArena<ByteBuffer> directArena;
//MemoryRegionCache这个类可以理解为一个MPSC的队列,队列中每一个节点存储着可以进行一次分配的Chunk实例和对应的handle数值
private final MemoryRegionCache<byte[]>[] tinySubPageHeapCaches;
private final MemoryRegionCache<byte[]>[] smallSubPageHeapCaches;
private final MemoryRegionCache<ByteBuffer>[] tinySubPageDirectCaches;
private final MemoryRegionCache<ByteBuffer>[] smallSubPageDirectCaches;
private final MemoryRegionCache<byte[]>[] normalHeapCaches;
private final MemoryRegionCache<ByteBuffer>[] normalDirectCaches;
private int allocations;

这些数组的长度确定规则如下:

  • tinySubPage**XX**Caches : 长度与PoolArena.numTinySubpagePools相同。这是为了保证对于Arena中的每一个tiny大小均有缓存槽位。
  • smallSubPage**XX**Caches : 长度与PoolArena实例中的numSmallSubpagePools参数相同。这是为了保证对于Arena中的每一个Small大小均有缓存槽位。
  • normal**XX**Caches : 长度与最大可缓存空间大小maxCachedBufferCapacity相关。确保从PageSize开始到maxCachedBufferCapacity均有缓存槽位对应。

当执行分配或者回收时,会优先从缓存中尝试,如果缓存中操作成功,则不必走整个内存池的算法体系。缓存由于是线程变量,使得这部分的操作没有竞争,能提高一定的性能。下面分别看下具体的分配和添加到缓存流程

分配

mark

添加到缓存

分配的逆过程就是添加到缓存。当PooledBuffer使用完毕进行回收时,首先尝试添加到缓存中。如果可以添加成功,则不必走内存池的回收流程。下面是具体的添加到缓存的流程。

mark

猜你喜欢

转载自blog.csdn.net/kuangzhanshatian/article/details/80881904