Netty内存池设计之总体管理代码实现

引言

前文我们介绍了Netty中内存池的总体管理相关算法与思路。涉及到的内容包含有:小内存分配,内存块管理,线程缓存。

本文我们将进一步分析其代码实现,帮助读者彻底的掌握 Netty 中内存管理的实现方式。

初始化与内存分配器

内存池初始化

内存池PollArena是内存分配的主要切入点。我们先来看下其属性。PoolArena的属性主要分为三类:

  • 不可变的实例属性,用于存储一些基本信息,如下

    • 内存页大小pageSize
    • 内存页大小的log2pageShifts
    • 内存块大小chunkSize
    • 触发内存页等分的子页大小溢出掩码subpageOverflowMask
    • 存储不同的微小等分大小(从 16 至 512)的子页数组的长度numTinySubpagePools。该变量为不可变静态类变量,值为 32( 512 / 16 )。
    • 存储不同的小等分大小(从 512 至 pageSize)的子页数组的长度numSmallSubpagePools
  • 内存分配相关属性,如下

    • 用于存储不同等分大小的微小等分级别的子页的数组,tinySubpagePools。该数组上的元素实例仅用于充当链表头结点和通过头结点加锁进而保证链表操作的安全性场景。
    • 用于存储不同等分大小的小等分级别的子页的数组,smallSubpagePools。与tinySubpagePools作用类似,只不过其管理的大小不同。
    • 6个不同使用率区间的PoolChunkList对象。
  • 用于存储统计信息的属性,如下

    • 记录微小内存申请与释放次数的allocationsTinydeallocationsTiny
    • 记录小内存申请与释放次数的allocationsSmalldeallocationsSmall
    • 记录普通内存申请与释放次数的allocationsNormaldeallocationsNormal
    • 记录大内存申请与释放次数的allocationsHugedeallocationsHuge
    • 记录当前使用中的大内存分配消耗的字节数activeBytesHuge

比较重要的是第二类属性,第一类属性也是为了第二类属性来服务的。而第三类属性仅仅是用于监控信息展示。下面来看下初始化构造方法,如下。

protected PoolArena(PooledByteBufAllocator parent, int pageSize, int maxOrder, int pageShifts, int chunkSize, int cacheAlignment)
{
    this.parent = parent;
    this.pageSize = pageSize;
    this.maxOrder = maxOrder;
    this.pageShifts = pageShifts;
    this.chunkSize = chunkSize;
    directMemoryCacheAlignment = cacheAlignment;
    directMemoryCacheAlignmentMask = cacheAlignment - 1;
    subpageOverflowMask = ~(pageSize - 1);
    tinySubpagePools = newSubpagePoolArray(numTinySubpagePools);//以入参为数组长度创建PoolSubPage数组
    for(int i = 0; i < tinySubpagePools.length; i++)
    {
        tinySubpagePools[i] = newSubpagePoolHead(pageSize);//创建特殊PoolSubPage实例,该实例仅用作头结点
    }
    numSmallSubpagePools = pageShifts - 9;
    smallSubpagePools = newSubpagePoolArray(numSmallSubpagePools);
    for(int i = 0; i < smallSubpagePools.length; i++)
    {
        smallSubpagePools[i] = newSubpagePoolHead(pageSize);
    }
    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);
    List < PoolChunkListMetric > metrics = new ArrayList < PoolChunkListMetric > (6);
    metrics.add(qInit);
    metrics.add(q000);
    metrics.add(q025);
    metrics.add(q050);
    metrics.add(q075);
    metrics.add(q100);
    chunkListMetrics = Collections.unmodifiableList(metrics);
}

构造方法按照作用,划分了三个阶段:

  • 以构造方法的入参设置不可变的实例属性,诸如pageSizemaxOrder等。
  • 按照计算的微小等分大小个数创建tinySubpagePools数组,按照计算的小等分大小个数创建smallSubpagePools数组。并且初始化一个特殊的PoolSubPage对象作为数组中每一个元素的值。该PoolSubPage不指向某个内存页,仅仅用作链表的头结点。
  • 按照使用率划分创建6个不同的PoolChunkList并且将其连接起来形成一个链表。

内存池类PoolArena是一个泛型抽象类,其子类有两个:在堆上进行内存分配的HeapArena,在直接内存上进行分配的DirectArena。在堆上进行内存操作实际上就是针对byte[]对象,在给定的偏移量和区间范围内对其进行操作。在直接内存上进行操作则相对复杂一些,因为JDK本身并没有提供供开发者了解的API支持。但是查看DirectByteBuffer对象,却能找到一个用于读写直接内存的类Unsafe。它基于一个起始位置和给定的偏移量,就可以对特定内存位置的数据进行读写。申请一块直接内存也是通过Unsafe的 api 完成,其会返回申请的直接内存的起始位置,这是一个long整型。在这个起始位置上使用Unsafe提供的 api 就可以完成读写。具体的读写操作读者可以参看 JDK 中的 DirectByteBuffer

子页初始化

前文已经分析过子页的内在逻辑和对内存空间的管理方式,这里我们看下类的数据结构。PoolSubPage有如下属性:

final PoolChunk < T > chunk;//该子页对应的内存页归属的内存块对象
private final int memoryMapIdx;//该子页对应的内存页在内存块中的二叉树节点坐标
private final int runOffset;//该子页对应的内存页的偏移量
private final int pageSize;//子页大小
private final long[] bitmap;//用于管理子页中每一等分的使用信息
PoolSubpage < T > prev; //用于连接链表中的前向节点
PoolSubpage < T > next; //用于连接链表中的后继节点
boolean doNotDestroy; //该子页是否处于使用中;
int elemSize;//该子页的等分大小;或者说等分后,每一个区间的大小
private int maxNumElems; //该子页等分的区间个数
private int bitmapLength;//管理该子页等分信息的位图的有效长度
private int nextAvail;//下一个可用区间的下标
private int numAvail;//当前可用区间个数

子页PoolSubPage有两个构造方法,分别对应不同的场景。当这个子页是应用在PoolArena子页数组的元素时,此时作为头结点,并不持有内存空间,其作用是在对头结点所在的链表进行操作时,提供加锁对象,供synchronized关键字使用。

构造方法为

PoolSubpage(int pageSize)
{
    chunk = null;
    memoryMapIdx = -1;
    runOffset = -1;
    elemSize = -1;
    this.pageSize = pageSize;
    bitmap = null;
}

而当这个子页是用于进行内存分配时,在初始化的时候就需要给出更多的信息,包含有内存页信息等,代码如下

PoolSubpage(PoolSubpage < T > head, PoolChunk < T > chunk, int memoryMapIdx, int runOffset, int pageSize, int elemSize)
{
    this.chunk = chunk;
    this.memoryMapIdx = memoryMapIdx;
    this.runOffset = runOffset;
    this.pageSize = pageSize;
    bitmap = new long[pageSize >>> 10]; // pageSize / 16 / 64
    init(head, elemSize);
}
void init(PoolSubpage < T > head, int elemSize)
{
    doNotDestroy = true;
    this.elemSize = elemSize;
    if(elemSize != 0)
    {
        maxNumElems = numAvail = pageSize / elemSize;
        nextAvail = 0;
        bitmapLength = maxNumElems >>> 6;
        if((maxNumElems & 63) != 0)
        {
            bitmapLength++;
        }
        for(int i = 0; i < bitmapLength; i++)
        {
            bitmap[i] = 0;
        }
    }
    addToPool(head);
}

首先是对几个重要属性的赋值,而后通过init方法,将内存页按照给定的等分大小进行等分。init方法通过elemSize等分大小,计算出子页中的等分个数,也就是maxNumElems,而后再根据这个值,对用于管理每一个等分的使用信息的位图数组bitmap进行初始化值,也就是将每一个等分所对应的数组元素设置为0。

根据maxNumElems,可以计算得到位图实际有效的元素长度,也就是bitmapLength

位图数组bitmap不需要每次都创建,只需要在init方法中进行初始化即可。而bitmap的最大长度自然就是pageSize/16/64,因为在等分大小为16时,等分个数最多,对应的比特位也是最多的,此时位图数组长度最长。其他的等分大小都不会超过它,有效比特位数也会更少,这也就是bitmapLength属性的作用了,标识出有效比特位数对应到位图数组中的长度。

线程缓存初始化

看过了内存池PoolArena的初始化,接着我们看看线程缓存PoolThreadCache的构成。前文有分析过,线程缓存是一个存储于线程变量(FastThreadLocal,后文细讲,其作用与ThreadLocal相同)的数据结构。该数据结构由两类属性构成:

  • 本线程当前持有的堆内存池HeapArena,直接内存池DirectArena。在某个线程中运行的程序需要在内存池上进行内存分配时,则获取当前线程的PoolThreadCache对象,然后获取这两个属性执行其分配算法。不过需要明确,这两个内存池对象并非某一个线程独占,可能会被多个线程共享,也就是说,多个PoolThreadCache中的heapArena属性或directArena属性可能指向的是同一个堆内存池或直接内存池。

  • 本线程内用于存储不同规范大小的内存空间缓存MemoryRegionCachePoolThreadCache对象共有三种不同规范大小(对应内存池中的规范大小划分),2个不同内存类型(堆和直接内存)合计共6个MemoryRegionCache[]对象,分别如下

    • private final MemoryRegionCache<byte[]>[] tinySubPageHeapCaches;

      private final MemoryRegionCache<byte[]>[] smallSubPageHeapCaches;

      private final MemoryRegionCache<bytebuffer>[] tinySubPageDirectCaches;</bytebuffer>

      private final MemoryRegionCache<bytebuffer>[] smallSubPageDirectCaches;</bytebuffer>

      private final MemoryRegionCache<byte[]>[] normalHeapCaches;

      private final MemoryRegionCache<bytebuffer>[] normalDirectCaches;</bytebuffer>

    • MemoryRegionCache本质上是一个队列,其元素Entry存储着三个重要信息:缓存起来的内存空间所归属的PoolChunk,缓存起来的内存空间的坐标信息(long型变量,携带子页位图坐标和节点坐标信息),缓存起来的供复用的内存空间对应的ByteBuffer对象。

看过属性之后我们来看初始化方法,如下

PoolThreadCache(PoolArena < byte[] > heapArena, PoolArena < ByteBuffer > directArena, int tinyCacheSize, int smallCacheSize, int normalCacheSize, int maxCachedBufferCapacity, int freeSweepAllocationThreshold)
{
    checkPositiveOrZero(maxCachedBufferCapacity, "maxCachedBufferCapacity");
    this.freeSweepAllocationThreshold = freeSweepAllocationThreshold;
    this.heapArena = heapArena;
    this.directArena = directArena;
    if(directArena != null)
    {
        tinySubPageDirectCaches = createSubPageCaches(tinyCacheSize, PoolArena.numTinySubpagePools, SizeClass.Tiny);
        smallSubPageDirectCaches = createSubPageCaches(smallCacheSize, directArena.numSmallSubpagePools, SizeClass.Small);
        numShiftsNormalDirect = log2(directArena.pageSize);
        normalDirectCaches = createNormalCaches(normalCacheSize, maxCachedBufferCapacity, directArena);
        directArena.numThreadCaches.getAndIncrement();
    }
    else
    {
        //省略代码,不设置直接内存相关属性
    }
    if(heapArena != null)
    {
        tinySubPageHeapCaches = createSubPageCaches(tinyCacheSize, PoolArena.numTinySubpagePools, SizeClass.Tiny);
        smallSubPageHeapCaches = createSubPageCaches(smallCacheSize, heapArena.numSmallSubpagePools, SizeClass.Small);
        numShiftsNormalHeap = log2(heapArena.pageSize);
        normalHeapCaches = createNormalCaches(normalCacheSize, maxCachedBufferCapacity, heapArena);
        heapArena.numThreadCaches.getAndIncrement();
    }
    else
    {
        //省略代码,不设置堆内存相关属性
    }
    //省略代码,参数freeSweepAllocationThreshold是否有效且为正数检查
}

初始化代码内容虽然长,但是可以分为清晰的两段:赋值三个基本属性有:freeSweepAllocationThresholdheapArenadirectArena;根据directArenaheapArena是否为空设置对应的属性。

directArenaheapArena自不用说,前文已经介绍过了。freeSweepAllocationThreshold是一个新加入的属性,其作用在于当在PoolThreadCache执行了freeSweepAllocationThreshold次内存分配后,检查所有的MemoryRegionCache对象,执行trim(整理)操作,将在链表中但是未曾参与过内存申请的空间归还给内存池。

接下来的两个if else结构相似,逻辑相似,只不过是按照内存类型的不同对不同的属性进行赋值。这里以directArena为例说明。

  • 首先是按照微小等分的不同规范大小创建MemoryRegionCache[]对象,即tinySubPageDirectCaches
  • 然后是按照小等分的不同规范大小创建MemoryRegionCache[]对象,即smallSubPageDirectCaches
  • 最后是按照需要缓存的普通大小的上限创建MemoryRegionCache[],即normalDirectCaches

首先来看方法createSubPageCaches,如下

private static < T > MemoryRegionCache < T > [] createSubPageCaches(int cacheSize, int numCaches, SizeClass sizeClass)
{
    if(cacheSize > 0 && numCaches > 0)
    {
        MemoryRegionCache < T > [] cache = new MemoryRegionCache[numCaches];
        for(int i = 0; i < cache.length; i++)
        {
            cache[i] = new SubPageMemoryRegionCache < T > (cacheSize, sizeClass);
        }
        return cache;
    }
    else
    {
        return null;
    }
}

cacheSize意味着MemoryRegionCache中队列的长度,也就是其最大缓存的内存空间个数。numCaches是创建的MemoryRegionCache[]的长度,也就意味着存在numCaches个规范大小需要首先从PoolThreadcache中分配。

对于微小,小,普通三种大小的cacheSize默认情况下由类变量PooledByteBufAllocator#DEFAULT_TINY_CACHE_SIZEPooledByteBufAllocator#DEFAULT_SMALL_CACHE_SIZEPooledByteBufAllocator#DEFAULT_NORMAL_CACHE_SIZE决定,这三个变量的取值也可以被环境参数所改变,具体可以参看对应的定义代码。

numCaches实际上就对应需要缓存处理的规范化大小的个数。这意味着对tinySubPageDirectCaches而言,数组长度也就是规范化大小的个数和PoolArena.numTinySubpagePools相等,是一个定值。对smallSubPageDirectCaches而言,数组长度和directArena.numSmallSubpagePools相等。也就是说能够在内存池中子页数组中寻找到的分配大小,都可以在PoolThreadCache找到对应的缓存空间,进而在其中的链表里寻找可以使用的复用内存空间。

而对于normalDirectCaches,情况就会变得复杂一些。来看方法createNormalCaches,如下

private static < T > MemoryRegionCache < T > [] createNormalCaches(int cacheSize, int maxCachedBufferCapacity, PoolArena < T > area)
{
    if(cacheSize > 0 && maxCachedBufferCapacity > 0)
    {
        int max = Math.min(area.chunkSize, maxCachedBufferCapacity);
        int arraySize = Math.max(1, log2(max / area.pageSize) + 1);
        MemoryRegionCache < T > [] cache = new MemoryRegionCache[arraySize];
        for(int i = 0; i < cache.length; i++)
        {
            cache[i] = new NormalMemoryRegionCache < T > (cacheSize);
        }
        return cache;
    }
    else
    {
        return null;
    }
}

可以看到,数组的长度是由最大缓存内存空间大小maxCachedBufferCapacity决定的。normalDirectCaches数组的长度,是从pageSizemaxCachedBufferCapacity(包含),按照 2 倍递增的规范化大小的个数。默认情况下,maxCachedBufferCapacity的取值由类属性PooledByteBufAllocator#DEFAULT_MAX_CACHED_BUFFER_CAPACITY决定,默认为 32k 。pageSize默认为 8k 。这意味着normalDirectCaches默认情况下长度为 3 。

需要注意的是,如果PoolThreadCache在构造方法中传入的tinyCacheSizesmallCacheSizenormalCacheSize为0,则意味着实际上关闭了线程缓存的功能。此时PoolThreadCache的作用仅仅就是提供本线程进行内存分配需要使用的堆内存池heapArena和直接内存池directArena对象。

内存分配器

前文有分析过在内存块上的内存分配算法,但是内存分配的入口并不是内存块,而是内存分配器,也就是接口ByteBufAllocator。不同的接口实现有着不同的分配策略,先来看下接口的类图,如下

之前已经分析过了在内存块PoolChunk中,如何进行内存分配的。但是内存分配的入口并不是内存块,而是内存分配器,也就是接口ByteBufAllocator。不同的接口实现有着不同的分配策略,先来看下接口的类图,如下

从实现类的类名就可以很清楚的看到分配策略有两种:池化就是指的是从内存池中进行分配,非池化就是每次分配时都创建新的内存空间。内存空间也完全依靠GC来实现回收,也就是早期的传统做法。

这里我们主要关注池化的分配器PooledByteBufAllocator,该类定义了一系列内存池实现需要用的默认参数,主要的有:

  • 内存页大小DEFAULT_PAGE_SIZE
  • 内存池二叉树最大深度DEFAULT_MAX_ORDER
  • 线程缓存中微小内存缓存个数DEFAULT_TINY_CACHE_SIZE
  • 线程缓存中小内存缓存个数DEFAULT_SMALL_CACHE_SIZE
  • 线程缓存中普通内存缓存个数DEFAULT_NORMAL_CACHE_SIZE
  • 线程缓存中普通内存最大的缓存内存大小DEFAULT_MAX_CACHED_BUFFER_CAPACITY
  • 线程缓存中触发缓存整理的分配阀值DEFAULT_CACHE_TRIM_INTERVAL
  • 线程缓存中周期触发缓存整理的时间间隔DEFAULT_CACHE_TRIM_INTERVAL_MILLIS,为0时意味着关闭该功能,默认为0。
  • PoolChunk默认缓存的供复用ByteBuffer对象个数DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK

除此之外,最为重要的就是定义了类型为PoolArena<byte[]>[]的堆内存池数组heapArneas属性,以及类型为PoolArena<ByteBuffer>[]的直接内存数组directArenas数组。这两个数组的长度默认都是2倍的cpu核数。与NioEventLoopGroup的默认大小相同。这样做的目的就是为了在默认情况下,让一个NioEventLoop线程拥有一个实际上没有竞争的PoolArena对象,以减少在该对象上的同步开销。具体而言,就是线程缓存中使用的heapArenadirectArena是从这两个数组中分配的,当有新的线程产生时,就会有新的线程缓存PoolThreadCache对象被创建,该对象会从heapArenas数组和directArenas数组中寻找分配给PoolThreadCache次数最少的PoolArena。这种分配方式,可以让PoolArena尽可能均匀的分配到PoolThreadCache上。

内存申请

获取本线程持有的PoolArena执行分配

介绍过了内存池,线程缓存和内存分配器。下面我们从内存分配的入口,ByteBufAllocator来看下内存分配的完整流程。来看看其对buffer方法的实现,如下

public ByteBuf buffer()
{
    if(directByDefault)//当创建ByteBuf时,是否优先使用直接内存。默认为true。
    {
        return directBuffer();
    }
    return heapBuffer();
}
public ByteBuf directBuffer()
{
    return directBuffer(DEFAULT_INITIAL_CAPACITY, DEFAULT_MAX_CAPACITY);
}
public ByteBuf directBuffer(int initialCapacity, int maxCapacity)
{
    if(initialCapacity == 0 && maxCapacity == 0)
    {
        return emptyBuf;
    }
    validate(initialCapacity, maxCapacity);
    return newDirectBuffer(initialCapacity, maxCapacity);
}

buffer方法中会根据是否优先直接内存属性,来判断需要创建的是堆内存ByteBuf还是直接内存ByteBuf。之前我们曾经提到过,对于直接内存的操作,需要 SUN 内部的API才能进行,因此 Netty 会判断是否存在这一 API 的可使用情况。只有在 API 能够使用时,才会优先使用直接内存进行内存分配。使用直接内存的好处也很明显,在 Socket 通道上进行数据操作时,如果是堆内存,则系统的 API 在内部会自行申请一个同等大小的直接内存空间,并且进行数据复制,而后使用这个直接内存进行真正的数据操作。这意味着使用堆内存会带来两个开销,一个是数据拷贝的开销,一个是一次性的直接内存申请创建开销。为此,在能够使用直接内存的场合,优先使用直接内存,有利于系统的整体性能表现。

在这里我们选择默认情况,也就是直接内存ByteBuf进行分析。方法随后委托给了无参directBuffer方法,该方法主要通过默认属性给出了有参directBuffer所需要的ByteBuf容量的初始大小和上限大小。前文我们提到过,ByteBuf是可以在使用中按照需要自动扩容的,但是扩容也需要给出一个上限,避免无限制的膨胀。之后委托到了真正的创建ByteBuf实例的方法newDirectByteBuffer。其内容如下

protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity)
{
    PoolThreadCache cache = threadCache.get();
    PoolArena < ByteBuffer > directArena = cache.directArena;
    final ByteBuf buf;
    if(directArena != null)
    {
        buf = directArena.a

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

<p> 通过本专刊的学习,对网络开发所需掌握的基础理论知识会更加牢固,对网络应用涉及的线程模型,设计模式,高性能架构等更加明确。通过对Netty的源码深入讲解,使得读者对Netty达到“知其然更之所以然”的程度。在遇到一些线上的问题时,具备了扎实理论功底的情况,可以有的放矢而不会显得盲目。 本专刊购买后即可解锁所有章节,故不可以退换哦~ </p> <p> <br /> </p>

全部评论

相关推荐

hwwhwh:同双非,有大厂实习其实也没啥用,主要看运气,等就行了
点赞 评论 收藏
分享
牛客36400893...:我不是这个专业的,但是简历确实没有吸引我的亮点,而且废话太多没耐心看
0offer是寒冬太冷还...
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务