9、Netty那些事 - 内存分配之PoolChunk

4,658 阅读11分钟

沾衣欲湿杏花雨,吹面不寒杨柳风。随着政策的放开,周围🐑了的朋友越来越多,大家也从开始的惶恐不安到现在的既得之,则治之,反正早晚的事,有新加坡的同事说那面有些人已经反复得了几次了,因此也不用惊慌,冬天已经来了,春天还会远吗?

一、PoolChunk介绍

Netty底层的内存分配和管理主要是由PoolChunk实现,大于16M的PoolChunk不放入内存中,今天主要给大家介绍下PoolChunk的两种实现以及当前正在用的版本是如何处理内存的。正片开始之前,先来一些开胃菜,有一些相关概念需要搞明白。

1.1. jemalloc3算法

在Netty 4.1.52版本之前,PoolChunk引入的是jemalloc3算法。内部维护了一颗平衡二叉树,默认由2048个page组成,一个page为8KB,整个Chunk默认为16MB,其二叉树结构如下图所示:

image.png

当分配的内存大于2^13B=8KB时,可以通过内存指计算对应的层级:int level = 11 - (log2(normCapacity)-13),其中normCapacity表示要分配的内存大小,大于等于8Kb并且为8KB的整数倍。比如要申请大小为16KB的内存,level = 11 - (log2(8196 * 2) - 13) = 11 - (log2(2^14) - 13 = 10,因此只能在小于等于10层上寻找还未分配的节点。

在PoolChunk中,用一个数组memoryMap为了所有节点及其对应的高度值。当节点被全部分配完成时,高度值会变成12,表示目前已经被占用,不可再分配,并且会循环递归更新其他所有父节点的高度值。

虽然jemalloc3对内存规格进行区域划分,目的也是为了减少内存碎片,但是最坏的情况会出现50%内存浪费,比如我想要申请17B的大小内存模块,通过计算后只能申请32B,即需要占用4个叶子结点,这种情况就很浪费内存。因此jemalloc4通过构造复杂的SizeClasses让每个size的跨度变的更小,从而减少内存浪费。

二、jemalloc4版本内存管理

2.1 几个重要属性

  • LongPriorityQueue
    • 该数据结构是Netty内部实现关于Long基本类型的优先级队列,属于小项堆,可以通过poll方法获取小项堆内部最小的handle值,表示我们每次申请内存都是从最低位置开始。PoolChunk内部维护了一个LongPriorityQueue[] runsAvail数组,所有存储在该数组的对象handle都表示一个可用的run,用来表示若干个page组成的内存块,默认长度是40
  • LongLongHashMap
    • 用来存储long类型的kv的hashMap,底层采用线性探测法,Netty使用该map用来存储某个run的首页偏移量和句柄值的映射关系
  • Long handle
    • 一共64位,高15位表示pageOffset,后面15位表示pageSize,通过pageOffset + pageSize就能定位一个run区域,由pageSize个page组成,起始地址是pageOffset,对于ByteBuffer对象来说,内部会有一个long型的memoryAddress绝对地址,因此可以通过绝地地址 + 偏移量定义任何page的实际地址 image.png
  • run
    • 由若干个连续的page组成的内存块地址,可以被long型的hand表示。PoolChunck会管理若干个不连续的run,存储到runAvail数组里。

2.2. 内存分配原理

上层实现整体没变,依然首先从本地缓存中分配,分配失败的则委托给PoolArea进行内存分配,最终委托给PoolChunk进行内存分配,Netty在jemalloc4算法中取消了Tiny规则,因此,只剩下Small、Normal以及Huge三种内存规格,整体内存分配逻辑如下

image.png

  • 首先申请16M内存,并划分成2048个page
  • 然后通过SizeClasses工具类,根据用户申请的内存大小计算出对应的index
  • 如果index<=37,则表示当前为small级别分配
  • 如果index<75,则表示当前是Normal级别分配
    • 对于Normal级别来说,所需要的内存大小为pageSize的整数倍,PoolChunk会从能满足分配的run数组中得到若干个page。当某一个 run 分配若干个 page 之后,可能还会有剩余空闲的 page,那么它根据剩余的空闲 pages 数量会从 LongPriorityQueue[] 数组选取一个合适的 LongPriorityQueue 存放全新的 run
  • 如果index>75,则表示当前是Huge级别分配
    • 对于 Small 级别的内存分配,经过 SizeClass 规格化后得到规格值 size,然后求得 size 和 pageSize 最小公倍数 j,j 一定是 pageSize 的整数倍。然后再按照 Normal 级别的内存分配方式从第一个适合的 run 中分配 (j/pageSize) 数量的 page。

2.3. 内存分配的入口

前文也分析过,PoolArena#allocate() 方法为内存分配的入口,实现如下:

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

        if (sizeIdx <= smallMaxSizeIdx) {
            tcacheAllocateSmall(cache, buf, reqCapacity, sizeIdx);
        } else if (sizeIdx < nSizes) {
            tcacheAllocateNormal(cache, buf, reqCapacity, sizeIdx);
        } else {
            int normCapacity = directMemoryCacheAlignment > 0
                    ? normalizeSize(reqCapacity) : reqCapacity;
            allocateHuge(buf, normCapacity);
        }
    }

通过SizeClasses的size2SizeIdx方法计算出对应的sizeIdx,然后根据sizeIdx的不同值选择不同的处理方式。

2.4. 分配Normal级别内存块

分配Normal级别内存块整体时序图如下:

image.png

当分配的内存块大小在(28kB,16MB]时,则调用tcacheAllocateNormal方法进行Normal级别内存块分配。源码如下:

    private void tcacheAllocateNormal(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity,
                                      final int sizeIdx) {
        // 首先尝试从本地线程缓存(线程私有变量,不需要加锁)分配内存
        if (cache.allocateNormal(this, buf, reqCapacity, sizeIdx)) {
            return;
        }
        lock();
        try {
            //托给PoolChunk对象完成内存分配
            allocateNormal(buf, reqCapacity, sizeIdx, cache);
            ++allocationsNormal;
        } finally {
            unlock();
        }
    }

allocateNormal源码如下,首先从自己管理的几个不同内存利用率的双向链表中获取,获取到则直接返回,否则创建一个新的chunk对象用来进行内存分配。

    private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache threadCache) {
        // 首先尝试从PoolChunkList中分配,如果分配成功,则直接返回
        if (q050.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q025.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q000.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            qInit.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q075.allocate(buf, reqCapacity, sizeIdx, threadCache)) {
            return;
        }

        // 创建一个新PoolChunk对象
        PoolChunk<T> c = newChunk(pageSize, nPSizes, pageShifts, chunkSize);
        // 使用这个新对象进行内存分配
        boolean success = c.allocate(buf, reqCapacity, sizeIdx, threadCache);
        // 添加到init链表中去
        qInit.add(c);
    }

allocate源码如下:

    boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
        // 最终分配成功的内存块的句柄
        final long handle;
        // 当前分配的内存规格是small
        if (sizeIdx <= arena.smallMaxSizeIdx) {
            // small
            handle = allocateSubpage(sizeIdx);
            if (handle < 0) {
                return false;
            }
            assert isSubpage(handle);
        } else {
            // 分配Norma级别内存块,大小为pageSize的整数倍
            int runSize = arena.sizeIdx2size(sizeIdx);
            // 核心方法
            handle = allocateRun(runSize);
            if (handle < 0) {
                return false;
            }
            assert !isSubpage(handle);
        }
        
        // 尝试从cachedNioBuffers缓存中获取ByteBuffer对象并在ByteBuf对象初始化时使用

        ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;
        // 初始化ByteBuf对象
        initBuf(buf, nioBuffer, handle, reqCapacity, cache);
        return true;
    }

allacateRun源码如下:

    private long allocateRun(int runSize) {
        // 根据规格计算所需page数量
        int pages = runSize >> pageShifts;
        // 根据page的数量确定page的起始索引值,索引值对应runAvail数组起始的位置
        int pageIdx = arena.pages2pageIdx(pages);
        // 针对runsAvail并发操作加锁
        runsAvailLock.lock();
        try {
            // 从LongPriorityQueue[] 数组中找到最合适的run用于当前的内存分配请求。
            int queueIdx = runFirstBestFit(pageIdx);
            if (queueIdx == -1) {
                return -1;
            }

            // 获取对应的LongPriorityQueue,包含若干个run
            LongPriorityQueue queue = runsAvail[queueIdx];
            // 从LongPriorityQueue小项堆中获取可用的run 句柄
            long handle = queue.poll();
            
            // 把handle从 小项堆中移除
            removeAvailRun(queue, handle);

            if (handle != -1) {
                // 拆分handle
                // 一部分用户当前内存的申请
                // 另一部分剩余的空闲内存块,会放入合适的LongPriorityQueue中,等待下次分配
                handle = splitLargeRun(handle, pages);
            }

            int pinnedSize = runSize(pageShifts, handle);
            // 更新剩余的空间值
            freeBytes -= pinnedSize;
            // 返回成功申请的句柄信息
            return handle;
        } finally {
            runsAvailLock.unlock();
        }
    }

以下图(图片地址)为例,详解下内存分配以及split的过程。 image.png

  • 当PoolChunk对象初始化,会向runsAvailMap和runsAvail写入一个句柄值,默认值是35184372088832L,它表示这么一个run:起始页偏移量为0,共有2048 个页,当前run 未被使用且不属于subpage。
  • runsAvailMap可以看成是一个HashMap,它的key对应pageOffset偏移量,value对应ru 的句柄值。它是用于存储首页和末页的句柄值。每次PoolChunk分配内存时会动态更新或删除,runsAvailMap用于run合并时通过偏移量快速定位到可用的run。
  • runsAvail则是一个小顶堆,但是PoolChunk是维护一个LongPriorityQueue[] 数组,用来存储空闲的run的 handle值。典型的空间换时间,如果所有的可用的run存储在一个LongPriorityQueue对象中,每次查询时就会非常慢(比较每一个handle值是否满足)。因此,根据handle所管理的page数量进行划分,比如某个run所管理的空闲 page数量为2048,那就把它放入索引值为39的LongPriorityQueue对象里。
  • 当 PoolChunk 为了满足用户申请的 32KB(4 个page)大小的内存,从初始的 handle 中拆分 4 个用于当前的内存分配,因此,原本只有一个 run 现在被拆分成两个。
    • 其中一个处于已使用状态的 run 的句柄值为 77309411328L,它表示起始页偏移量为 0,包含 4 个可用的 page,当前使用状态为已使用
    • 而另一个 run 的句柄值为 2286915466297344L,它表示起始页偏移量为 4,包含 2024 个可用的 page,当前使用状态为未使用。而它的信息也会同步更新 runsAvailMap 和 runsAvail 两个数据结构中,以供后续分配时使用

2.5. 分配Small级别内存块

在jemalloc3中,Small级别的内存块大小是不能超过8KB的,方便内存管理,也能减少内存碎片,但是只能等分一个Page;而jemalloc4中,内存范围为[16Byte,28Kb],同时改成了等分N个page。

N的取值是拆分规格值和 pageSize 的最小公倍数再除以 pageSize。

例如:申请内存大小为 28KB,需要等分 7 个 page,因为它 28KB=28672 和 pageSize=8192 的最小公倍数为 57344(57344/8192=7)。这样就能满足类型 28KB 这种不是 2 的次幂的内存大小。从而减少给非2的次幂大小的内存分配造成的空间浪费。

内存管理方面jemalloc3和jemalloc4没什么区别,都是通过构建一个PoolSubpage对象,内部维护一个long[]数组来记录每一等分的使用情况。

Small级别内存分配整体时序图如下:

image.png

核心方法为allocateSubpage,源码如下: private long allocateSubpage(int sizeIdx) {

// 从「PoolArena」获取索引对应的「PoolSubpage」。
// 在「SizeClasses」中划分为 Small 级别的一共有 39 个,
// 所以在 PoolArena#smallSubpagePools数组长度也为39,数组索引与sizeIdx一一对应
PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);

// PoolSubpage 链表是共享变量,需要加锁
synchronized (head) {
    // 获取拆分规格值和pageSize的最小公倍数
    int runSize = calculateRunSize(sizeIdx);

    // 申请若干个page,runSize是pageSize的整数倍
    long runHandle = allocateRun(runSize);
    if (runHandle < 0) {
        // 分配失败
        return -1;
    }

    // 实例化「PoolSubpage」对象
    int runOffset = runOffset(runHandle);
    assert subpages[runOffset] == null;
    int elemSize = arena.sizeIdx2size(sizeIdx);

    PoolSubpage<T> subpage = new PoolSubpage<T>(head, this, pageShifts, runOffset,
            runSize(pageShifts, runHandle), elemSize);

    // 由PoolChunk记录新创建的PoolSubpage,数组索引值是首页的偏移量,这个值是唯一的,也是记录在句柄值中
    // 因此,在归还内存时会通过句柄值找到对应的PoolSubpge对象
    subpages[runOffset] = subpage;

    // 委托PoolSubpage分配内存
    return subpage.allocate();
}

}

2.6. 内存回收

  • 对于subPage的回收
    • 对 subpage 回收是先回收到 PoolArena 对象的 subpage pool 池中,如果发现此时的 PoolSubpage 已经没有被任何对象使用(numAvail == maxNumElems),它首先会从 subpage pool 池中移出,然后再按照 run 策略回收(因为此刻的 handle 记录着偏移量和 page 数量,所以完全有足够的回收信息)
  • 对于run的回收
    • 第一步会尝试不断向前合并相邻的空闲的 run,这一步会利用 runAvailMap 快速定位合适的 run,若合并成功,会重新生成 handle 句柄值,接着再向后不断合并相邻的空闲的 run 并得到新的 handle,最后再更新 {@link PoolChunk#runsAvail} 和 {@link PoolChunk#runsAvailMap} 两个数据结构,这样就完成了一次 run 的回收。

核心源码都在PoolChunk.free方法中,核心逻辑如下:

void free(long handle, int normCapacity, ByteBuffer nioBuffer) {

    // 当前回收的是subpage
    if (isSubpage(handle)) {
        // 根据容量大小获取index
        int sizeIdx = arena.size2SizeIdx(normCapacity);

        // 获取subpage pool索引对应的链表的头结
        PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);

        // 获取偏移量
        int sIdx = runOffset(handle);

        // 通过偏移量定位PoolSubpage
        PoolSubpage<T> subpage = subpages[sIdx];
        assert subpage != null && subpage.doNotDestroy;

        synchronized (head) {
            // 调用PoolSubpage.free方法释放内存
            if (subpage.free(head, bitmapIdx(handle))) {
                // 返回true表示当前PoolSubpage对象还在使用不需要回收
                return;
            }
            assert !subpage.doNotDestroy;

            // 返回flase表示PoolSubpage已经没有被任何地方引用,需要回收
            subpages[sIdx] = null;
        }
    }

    // 回收run类型
    // 获取page数量
    int pages = runPages(handle); 
    synchronized (runsAvail) {
        // 向前、后合并与当前run的pageOffset连续的run
        long finalRun = collapseRuns(handle);

        // 更新「isUsed」标志位为 0
        finalRun &= ~(1L << IS_USED_SHIFT);

        // 如果先前handle表示的是subpage,则需要清除标志位
        finalRun &= ~(1L << IS_SUBPAGE_SHIFT);

        //更新PoolChunk
        // 更新PoolChunk#runsAvailMap
        insertAvailRun(runOffset(finalRun), runPages(finalRun), finalRun);

        // 更新剩余空闲内存块大小
        freeBytes += pages << pageShifts;
    }

    // 回收ByteBuffer对象
    if (nioBuffer != null && cachedNioBuffers != null &&
            cachedNioBuffers.size() < PooledByteBufAllocator.DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
        cachedNioBuffers.offer(nioBuffer);
    }
}

三、小节

这篇文章主要简单为大家介绍了下Netty内存管理相关实现。距离上一次更新已经过去了一个月,做为还未完全恢复的“杨康”,略微有些懒惰,不过新年新气象,终究还是要不断学习的。看了很多netty内存管理的资料,参考了很多文章(贴在了最后,感谢各位大佬分享),不过我觉得能吸收,就会变成自己的,与君共勉吧。