# Netty 核心原理十八 内存池详细设计要领

前面我们说到,在JDK中使用ByteBuffer定义了四个变量:position、mark、capacity、limit来操作底层内存,底层内存类型包含:C-Heap 直接内存、Java-Heap 堆内内存,分别用不同的子类持有long (直接内存地址)、byte[](堆内内存地址)来使用上述四个变量操作两种不同的内存。

由于Netty为网络IO框架,其包装了NIO、BIO等等Java的基础网络IO通讯,基于事件驱动(EventLoop)实现了自身的高性能异步操作模型(通过事件循环组使一切操作均为异步),而对于网络IO来说,分配内存是必不可少的操作,而且这种操作将会非常频繁,此时,我们需要在Netty上构建一个内存池来加速内存分配,前面我们说到:Netty使用类似于ByteBuffer的方法,来将存储和操作位分离,该类取名为ByteBuf,内部定义:readerIndex、writeIndex来表示读写位,当然对于其具体类实现也有读、写标记位和capacity容量位,底层封装了:

  1. 堆内内存的byte数组
  2. 堆外内存的DirectByteBuffer
  3. 如果Unsafe存在,Netty倾向于使用Unsafe自身管理堆外内存而不是使用DirectByteBuffer,取决于具体实现

但不管怎样,内存类型始终只有两种:C-Heap 直接内存、Java-Heap 堆内内存。

由于笔者的《Netty 源码全解与架构思维 》一书,将在七月份售卖,所以本文将详细介绍Netty的内存池具体设计要领和推理过程,而不是源码的详解,读者可以到时关注下该书的发布。

内存池设计要求

在Netty中实现的内存池需要满足如下要求:

  1. 可以多线程并发分配
  2. 可以分配较大内存和较小内存,因为不同应用程序的需求不一样,有时可能发送大包,有时可能发送小包
  3. 可以支持C-Heap 直接内存、Java-Heap 堆内内存的管理和分配操作(这点使用ByteBuf类已经实现了,因为它将存储和操作内存分离,我们只需要在内存池中管理实际内存即可)

在满足上述需求的同时,还需要满足如下要求:

  1. 高性能
  2. 减少内存碎片发生(增加内存的利用率)
  3. 能够分析内存泄漏点(内存池的设计由于应用方水平不同,需要在发生内存泄露时,提供某种机制来识别该错误,提示应用方修复)

内碎片与外碎片

内存碎片即“碎片化的内存”,它分为外碎片和内碎片,内存碎片描述一个系统中所有不可用的空闲内存,这些碎片之所以不能被使用,是因为内存池在分配时由于自身算法原因导致的,由内存池分配的空闲内存大于应用方需求内存(内碎片)或者分配后剩余内存不连续(外碎片),从而导致了内存碎片。碎片类型如下:

  1. 外碎片:外部碎片指的是还没有被分配出去的空闲内存之间不连续,比如有三个内存域:A(10K)、B(已分配)、C(10K),现在应用程序需要20K,由于中间隔着B,此时A与C不连续,不能分配,这称之为外碎片
  2. 内碎片:内部碎片就是已经被分配出去内存,却不能被应用程序完全利用的内存空间。比如,应用程序需要3K,但是内存池分配算法分配了8K,此时应用程序没有使用的5K,称之为内存碎片

外碎片的产生

频繁的分配与回收内存会导致大量的、不连续的内存块夹杂在已分配的内存块中间,此时就会产生外碎片。假设有一块大小为100KB的数组连续空闲内存空间,每个数组项占用1KB。如果应用程序从中申请一块内存,假如为10K,那么申请出来的内存块就为0-9下标的内存。这时候应用程序继续申请一块内存,比如说5KB,第二块得到的内存块就应该为10-14下标的内存。如果应用程序把0-9下标的内存块释放,然后再申请一块大于10KB的内存块,比如说20KB,这时由于刚被释放的内存块不能满足新的请求(10KB小于20KB),所以只能从下标从15开始分配出20KB的内存块。现在整个内存空间的状态是下标0-9空闲,下标10-14被占用,下标15-34被占用,下标35-99空闲。其中0-9不能被应用程序分配的内存块,便称之为内存外碎片。如果下标10~14一直被占用,而以后申请的空间都大于10KB,那么下标0-9就永远用不上了,变成外部碎片。

内碎片的产生

由于内存分配算法为了方便管理内存,此时将会以2的倍数来进行分配管理,此时如果分配的内存大于应用程序需要的内存,那么将会导致内存的内碎片。比如内存分配算法管理的最小内存分配为4KB,那么应用程序只需要512byte,那么剩下的内存将为内存内碎片。

Netty 如何保证多线程安全分配

我们知道保证线程安全的必经步骤:上锁,有读者可能会说:CAS?其实那就是底层CPU指令的上锁过程。当然,在Netty中自然也是通过上锁保证多线程安全分配。但是一旦涉及到上锁,必然会涉及到锁竞争,那么为了减少锁竞争,我们 通常会将锁进行分散,这称之为锁细粒度化。

Netty使用PoolArena类来表示一片内存域,多线程分配时将竞争该区域锁来分配内存。那么我们知道实际工作的线程通常是事件循环组的线程。所以为了最大程度减少竞争,通常初始化PoolArena的个数为EventLoopGroup中的线程数量。当然,我们知道内存类型分为两类,所以这里也将PoolArena分为两类。源码如下。

// 使用池化分配器來分配内存池

public class PooledByteBufAllocator extends AbstractByteBufAllocator {  

  private static final int DEFAULT_NUM_HEAP_ARENA; // 默认堆内存域大小

  private static final int DEFAULT_NUM_DIRECT_ARENA;// 默认直接内存内存域大小

  static { 

    final int defaultMinNumArena = runtime.availableProcessors() * 2; // 默认最小内存域,等于事件循环组的数量

    DEFAULT_NUM_HEAP_ARENA = Math.max(0,

        SystemPropertyUtil.getInt(

            "io.netty.allocator.numHeapArenas",

           (int) Math.min(

                defaultMinNumArena,

                runtime.maxMemory() / defaultChunkSize / 2 / 3))); // 最后计算时,除了事件循环组的大小,还需考虑实际内存的大小(这里了解下即可,实际上99%的可能性都是取defaultMinNumArena,这里的defaultChunkSize后面再进行推理,实际占用率不能超过内存三分之一,除2是把另一半空间留给DIRECT_ARENA)

    DEFAULT_NUM_DIRECT_ARENA = Math.max(0,

        SystemPropertyUtil.getInt(

            "io.netty.allocator.numDirectArenas",

           (int) Math.min(

                defaultMinNumArena,

                PlatformDependent.maxDirectMemory() / defaultChunkSize / 2 / 3)));

 }

  

  private final PoolArena<byte[]>[] heapArenas; // 堆内存域数组

  private final PoolArena<ByteBuffer>[] directArenas; // 直接内存域数组

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

那么,在考虑线程安全时,我们很容易的想到TLS,ThreadLocalStorage,该内存域专属于线程本身,不涉及到内存竞争,再联合CPU的缓存行,使用TLS将会极大增加分配时的性能。在Netty中同样使用该技巧来提高分配内存,此时使用PoolThreadCache类来完成该操作。此时分配流程为:

  1. 先尝试从PoolThreadLocalCache中分配,此时不需要上锁
  2. 如果分配失败,那么尝试上锁从PoolArena中分配
final class PoolThreadLocalCache extends FastThreadLocal<PoolThreadCache> { 
    // FastThreadLocal前面已经详细介绍,PoolThreadCache中包含了所有缓存的内存
  // 线程首次获取PoolThreadCache时,调用该方法绑定heapArena和directArena
  protected synchronized PoolThreadCache initialValue() {

    final PoolArena<byte[]> heapArena = leastUsedArena(heapArenas);

    final PoolArena<ByteBuffer> directArena = leastUsedArena(directArenas);


    return new PoolThreadCache(

      heapArena, directArena, tinyCacheSize, smallCacheSize, normalCacheSize,

      DEFAULT_MAX_CACHED_BUFFER_CAPACITY, DEFAULT_CACHE_TRIM_INTERVAL);

 }

  // 最大限度减少锁竞争,每次线程绑定PoolArena时,选择最少线程占用的PoolArena
  private <T> PoolArena<T> leastUsedArena(PoolArena<T>[] arenas) {

    if (arenas == null || arenas.length == 0) {

      return null;

   }

    PoolArena<T> minArena = arenas[0];

    for (int i = 1; i < arenas.length; i++) {

      PoolArena<T> arena = arenas[i];

      if (arena.numThreadCaches.get() < minArena.numThreadCaches.get()) {

        minArena = arena;

     }

   }

    return minArena;

 }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

至此,Netty保证线程安全的线程分配策略就说明白了。稍微总结下:PoolThreadCache中缓存了heapArena和directArena,分配时先从TLS中分配,如果分配失败,那么从保存的heapArena和directArena中分配

Netty 如何减少外碎片发生

想要解决外碎片的发生,得从外碎片的发生原因来分析,我们主要看到外碎片的产生原因为:分配算法导致无法合并非连续内存空间。那么我们如何做呢?很简单。直接从算法本身入手。我们可以选择一种数据结构来表示分配的内存,因为没有什么比结构化的内存更容易分析的东西了。我们看到非连续空间可以分配和合并,此时很容易想到使用树形结构来表示内存,因为树形结构包含:父节点和子节点,正好用于表示分配和合并操作。

假如我们此时包含1M的内存页。那么我们可以这么做:

  1. 将1M切割为两个512K
  2. 将512K分割为两个128K
  3. 一直如此分裂到最小粒度:1byte?2byte?4byte?....1KB?2KB?4KB?

那么我们很容易看到这棵树将会变成:满二叉树,同时叶子节点就是我们可以分配的所有页。此时,我们可以在分配时找到可以满足分配内存的节点,从其子节点分配,比如:我们需要分配4KB,此时我们找到8KB所在的几点,从其左孩子节点或者右孩子节点分配,那么,当两个4KB的子节点都释放完毕后,将会自动合并为8KB(标识可用即可),那么此时就减少了外碎片的发生。当然可不可能存在外碎片,答案是有的,比如另外一个4KB和其他8KB的兄弟节点的4KB将不连续,所以会造成外碎片,但是我们一直说的是:减少,而不是避免,经过多年的认证,这种分配算法为最优分配算法。该算法名为:伙伴算法(满二叉树的兄弟节点彼此之间形成伙伴)。

那么现在出现一个问题:叶子节点的大小到底多少合适?也即最少管理的内存页设置多少呢?这其实很容易理解:如果叶子节点的内存太小,那么这颗满二叉树就越高,那么分配时查找的时间复杂度就越高,那么导致内碎片的几率就越小(因为管理的内存越小,那么越容易适配需求的内存),同时外碎片导致的非连续块就越高(层级越多,兄弟节点越多,那么更容易发生外碎片)。如果叶子节点内存越大,那么相反,这颗满二叉树就越低,那么分配时查找的时间复杂度就越小,那么导致内碎片的几率就越大,同时外碎片导致的非连续块就越小。那么,我们就需要做一个折中方案:最终Netty选择的是8KB一页。

在Netty中使用PoolChunk类来表示这样一棵满二叉树,实现了伙伴算法。源码如下。我们很容易看到,要给内存块大小为16MB,我们用T memory来表示实际内存,比如:byte[]数组,DirectByteBuffer直接内存。那么我们自然引入一个问题:如何来表示某个节点的内存是否已经被分配?在Netty中使用memoryMap和depthMap来解决,看如下描述。此时我们可以看到当分配一个节点,均需要修改memoryMap[id]的值,及其父节点memoryMap的值来表示父节点和当前节点的分配状态。

  1. memoryMap[id] = depth_of_id => memoryMap[id]的值与depthMap数组中标识的值相同,表明当前节点空闲,其子节点都没有分配

  2. memoryMap[id] > depth_of_id => memoryMap[id]的值大于depthMap数组中标识的值,那么表明存在叶子节点已经被分配

  3. memoryMap[id] = maxOrder + 1 => memoryMap[id]的值等于最大树高+1,表明当前节点的叶子节点均已经被分配完毕

final class PoolChunk<T> implements PoolChunkMetric {

  final T memory; // 实际管理内存

  private final byte[] memoryMap; // 内存当前层级值(每次分配将会导致对应页及其父节点的值发生变化)

  private final byte[] depthMap; // 内存页当前树高(一旦设置不再改变)

  private final int pageSize; // 叶子节点大小,默认:8KB

  private final int maxOrder; // 树高:默认11

  private final int chunkSize;// 总大小:pageSize * maxOrder = 2^11 * 8KB = 16MB

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Netty 如何减少内碎片发生

出现内碎片的原因是由于算法分配的内存大于需求内存,比如,我们上面最低分配的内存页为8KB,假如应用程序只需要1KB,如果直接将8KB交给应用程序使用将会是一种极大的浪费,此时内存页的利用率:1KB / 8KB。所以我们需要设计一种算法来解决这种问题。

如何解决呢?我们可以看到:8KB等于8个1KB,我们可以将分配过来的一页,切割为8个1KB不就行了,下次在需要1KB,那么直接从切割的子页中分配就行了。至此,我们将这种从1页切割为均等大小子页的算法为:slab算法。就是在伙伴算法上面再搭一个适配小内存分配的算法。

在Netty中使用PoolSubpage类来表示这种子页内存。源码如下所示,我们使用位图来表示切割的每个子页是否已经被分配。

final class PoolSubpage<T> implements PoolSubpageMetric { 
    // 实现了PoolSubpageMetric用于性能统计,了解即可

  final PoolChunk<T> chunk; // 所属内存块(毕竟是从正常页进行切割的,正常页又属于某个内存块)

  private final int pageSize; // 被分割的页大小,通常为8kb

  private final long[] bitmap; // 页被分割后N个子页后,用于标识当前已经被分配的子页

  int elemSize; // 子页大小

  private int maxNumElems; // 被切割的数量

  private int bitmapLength; // 需要的位图长度

  private int nextAvail; // 指向下一个可用的位图索引

  private int numAvail; // 当前可用的子页数

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

而这时我们需要了解:是不是所有内存都需要切割呢?答案并不是的,切割的内存同样需要正规化处理为2的N次方来分配,Netty将子叶的大小分为:

  1. 微型类型(tiny)、小型类型(small)、正常类型(normal)
  2. PoolSubpage[] tinySubpagePools 微型子页数组用于保存从PoolChunk中分配的基础内存页 8KB 切割为的 16byte - 496byte 区间的微型子页
  3. PoolSubpage[] smallSubpagePools微型子页数组用于保存从PoolChunk中分配的基础内存页 8KB 切割为的 512byte - 4096byte 区间的小型子页

那么,这时我们需要分配的大小进行正规化到哪个区间时,就找到对应的数组下标获取到PoolSubpage,并从其中获取一个当前均等大小的一个子叶即可。

private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {

  final int normCapacity = normalizeCapacity(reqCapacity);

  if (isTinyOrSmall(normCapacity)) { // capacity < pageSize 分配子页

    int tableIdx;

    PoolSubpage<T>[] table;

    boolean tiny = isTiny(normCapacity);

    if (tiny) { // < 512

      

      // 微型页尝试从TLS中分配

      if (cache.allocateTiny(this, buf, reqCapacity, normCapacity)) {

        return;

     }

      tableIdx = tinyIdx(normCapacity);

      table = tinySubpagePools;

   } else {

      // 小页尝试从TLS中分配

      if (cache.allocateSmall(this, buf, reqCapacity, normCapacity)) {

        return;

     }

      tableIdx = smallIdx(normCapacity);

      table = smallSubpagePools;

   }

    

    // 分配失败,那么从对应的微型或者小页数组中找到下标

    final PoolSubpage<T> head = table[tableIdx];

    synchronized (head) {

      final PoolSubpage<T> s = head.next;

      if (s != head) { // 存在已经切割的小页内存,那么直接分配即可

        assert s.doNotDestroy && s.elemSize == normCapacity;

        long handle = s.allocate();

        assert handle >= 0;

        s.chunk.initBufWithSubpage(buf, handle, reqCapacity);

        if (tiny) {

          allocationsTiny.increment();

       } else {

          allocationsSmall.increment();

       }

        return;

     }

   }

    // 否则使用正常分配方法,该方法将分配完整一页,然后在内部进行切割

    allocateNormal(buf, reqCapacity, normCapacity);

    return;

 }

  if (normCapacity <= chunkSize) { // 分配正常页

    if (cache.allocateNormal(this, buf, reqCapacity, normCapacity)) {

      return;

   }

    allocateNormal(buf, reqCapacity, normCapacity);

 } else {

    // 分配大于伙伴系统管理的大小内存 >16MB的内存,此时不进行池化管理,默认调用Unpool来分配

    allocateHuge(buf, reqCapacity);

 }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107

Netty 如何保证内存池利用率

我们知道PoolArea内存域用于在多线程并发分配内存中减少锁粒度,而PoolArea中管理多个PoolChunk内存块,在PoolChunk中是实现了伙伴算法,在PoolArea中是实现了Slab算法,那么我们的目的是为了减少内存外碎片,那么这时就需要尽量将管理的每个PoolChunk尽量完全使用。而是这时就需要一种算法来完成这个目的,从而提高内存的利用率。

在Netty中使用PoolChunkList保存不同占用率的基本内存块(由基础内存页8KB组成)链表,这样有助于优先对不同占用率的内存进行分配,增加内存利用率,同时我们可以看到不同百分比占用率的PoolChunkList有重叠部分,而不是严格区分,这样设计可以减少PoolChunk在占用率提升或者减少后,在不同的PoolChunkList中移动。而为什么是这样一个顺序呢?这是平衡了分配时间与内存利用率后的结果,我们来看分配代码。从代码中我们看到分配顺序为:q050,然后再是q025,q000、qInit、q075,我们看到如果利用率越小的PoolChunk越靠后分配,优先分配q050是平衡了分配时间和利用率的选择:空间利用率越大,越不容易分配,但是利用率较高,而空间利用率越小,越容易分配,但是利用率较小,所以选了q050,随着使用率不断增加PoolChunk将会在这几个PoolChunkList链表中移动,为什么最后使用q075来分配呢?这是可能当前链表中只存在q075的PoolChunk,而不存在q025,q000、qInit,所以需要尝试在q075链表中的PoolChunk。

private synchronized void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {

  // 从已存在的链表中分配

  if (q050.allocate(buf, reqCapacity, normCapacity) || q025.allocate(buf, reqCapacity, normCapacity) ||

    q000.allocate(buf, reqCapacity, normCapacity) || qInit.allocate(buf, reqCapacity, normCapacity) ||

    q075.allocate(buf, reqCapacity, normCapacity)) {

    ++allocationsNormal;

    return;

 }

  // 如果没有合适的PoolChunk那么就创建一个新的

  PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);

  long handle = c.allocate(normCapacity);

  ++allocationsNormal;

  assert handle > 0;

  c.initBuf(buf, handle, reqCapacity);

  qInit.add(c);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

PoolChunkList链表如下图所示。

https://article-images.zsxq.com/Fg7eSF9oKXe7Pt6O3z6fCW1fJP58