死磕 Netty 之内存篇:PoolChunk 源码分析

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。

本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经


如果有小伙伴以前看过基于 jemalloc3 的 PoolChunk 的源码,我相信小伙伴在这个过程是比较痛苦的,因为 jemalloc3 使用了二叉树,源码阅读非常吃力。相对于 jemalloc3,jemalloc4 算法思想足够简单,阅读起来还是稍微会轻松些。本篇文章大明哥将详细介绍基于 jemalloc4 算法的 PoolChunk,一起来目睹其风采吧!!

重要术语

在理解 PoolChunk 之前我们需要清楚 PoolChunk 中几个比较重要的术语。

  • Page:page 是 PoolChunk 中内存分配的最小单位。
  • run:由若干个连续的 Page 组成的内存块。
  • handle:句柄,用于表示 PoolChunk 中一块内存的基本信息,在 PoolChunk 中进行内存分配时我们首先需要知道这块内存的基本信息,这些信息就是从 handle 中获取。

handle 是一个 64 位的数据,各位含义如下:

  • 0~ 14 位表示这个句柄所在的位置。
  • 15 ~ 29 位表示这个句柄表示的是多少页。
  • 30 位表示这段内存是否被使用。
  • 31 位 表示这段内存是否用于 subPage 的分配。
  • 32 ~ 63 位表示的是这块内存在 subPage 中 bitMap 的第几个。

重要数据结构

在 PoolChunk 中有两个非常重要的数据结构 LongPriorityQueue[] runsAvailLongLongHashMap runsAvailMap ,要想掌握 PoolChunk,这两个数据结构是必须要了解的。

LongPriorityQueue[] runsAvail

LongPriorityQueue 是 Netty 内部实现的一个关于 Long 的优先队列,它是基于二叉堆实现,目前只在 PoolChunk 中使用,主要用于 long 型的 handle 值。

LongPriorityQueue 属于小顶堆,通过 LongPriorityQueue#poll() 我们可以每次获取小顶堆内部最小的 handle 值,这样我们在申请内存时都是从最低位的地址开始分配的。

数组 runsAvail,存储该 PoolChunk 的所有 run,这里需要注意数组 runsAvail 的索引是 Size 对应的 sizeIdx,在内存分配过程过程中,Netty 需要根据 sizeIdx 从 runsAvail 中向下检索可用的 LongPriorityQueue。

LongLongHashMap runsAvailMap

LongLongHashMap 也是 Netty 内部实现的一个特殊的存储 long 原型的 HashMap,底层采用线性探测法,目前也仅在 PoolChunk 中使用。

Netty 使用 LongLongHashMap 存储某个 run 的首页偏移量和句柄的映射关系,最后一页偏移量和句柄的映射关系,这样做的目的主要是为了在向前、向后合并过程中能够通过 pageOffset 来获取 handle,进而判断是否可以进行合并操作。

定义

我们再看 PoolChunk 的定义:

java 复制代码
final class PoolChunk<T> implements PoolChunkMetric {
    // 所属 PoolArena
    final PoolArena<T> arena;

    final Object base;
    // 维护的内存块
    final T memory;
    // 是否池化
    final boolean unpooled;
    
    // 存储所有 run 的第一个和最后一个 page 的句柄
    private final LongLongHashMap runsAvailMap;

    // 存储所有的 run
    private final LongPriorityQueue[] runsAvail;

    // 管理该 PoolChunk 中的所有 PoolSubpage
    private final PoolSubpage<T>[] subpages;
   
    // 页大小,默认 8kb
    private final int pageSize;
    // pageSize 的偏移量
    private final int pageShifts;
    // 一个 chunk 的大小
    private final int chunkSize;

    // 缓存
    // 主要是对 PooledByteBuf 中频繁创建的 ByteBuffer 进行缓存,以避免由于频繁创建的对象导致频繁的GC
    private final Deque<ByteBuffer> cachedNioBuffers;
   
    // 空闲 byte
    int freeBytes;
    
    // 所有 PoolChunkList
    PoolChunkList<T> parent;
    // 前置节点
    PoolChunk<T> prev;
    // 后置节点
    PoolChunk<T> next;

pageSize 默认 8KB,chunkSize 默认 4MB,由于版本的不同,可能会存在差异。

PoolChunk 内存中的结构如下:

加上上面两个数据结构,我们再对其丰富下。绿色达标已分配的页,白色则是没有分配,红色部分表示已分配给 subPage 的页。

这图现在不是很理解没有关系,大明哥在内存分配和内存释放模块会详细介绍的,到时候你就会一目了然了,我们先记住!

内存分配

源码分析

java 复制代码
   boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
        final long handle;
        // sizeIdex <= 38, 表示当前分配的内存规格为 Small
        if (sizeIdx <= arena.smallMaxSizeIdx) {
            // 分配 Small 规格内存块
            handle = allocateSubpage(sizeIdx);
            if (handle < 0) {
                return false;
            }
            assert isSubpage(handle);
        } else {
            // 分配 Normal 级别内存块
            int runSize = arena.sizeIdx2size(sizeIdx);
            handle = allocateRun(runSize);
            if (handle < 0) {
                return false;
            }
            assert !isSubpage(handle);
        }
        
        // 从缓存中获取 ByteBuf 对象
        ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;
        // 初始化 ByteBuf 对象
        initBuf(buf, nioBuffer, handle, reqCapacity, cache);
        return true;
    }

根据 sizeIdx 来判断当前分配内存规格是 Small 还是 Normal,如果 sizeIdx ≤ 38size ≤ 28 KB)时,则调用 allocateSubpage() 来分配 Small 规格的内存,否则调用 allocateRun() 分配 Normal 规格的内存,这里我们只看 Normal 级别的。

allocateRun()

runSize 为 pageSize 的整数倍,allocateRun() 如下:

ini 复制代码
    private long allocateRun(int runSize) {
        // 根据 runSize 确认需要多少个 page
        int pages = runSize >> pageShifts;
        // 根据 pages 的数量确认 page 的起始索引,索引值对应 runsAvail[] 的索引
        int pageIdx = arena.pages2pageIdx(pages);

        synchronized (runsAvail) {
            // 根据 pageIdx 获取第一个有足够内存分配的 queueIdx
            int queueIdx = runFirstBestFit(pageIdx);
            if (queueIdx == -1) {
                return -1;
            }

            // 从 runsAvail 获取 LongPriorityQueue 
            LongPriorityQueue queue = runsAvail[queueIdx];
            // 获取第一个 run
            long handle = queue.poll();

            assert handle != LongPriorityQueue.NO_VALUE && !isUsed(handle) : "invalid handle: " + handle;

            // 将该 handle 从 LongPriorityQueue 中移除
            removeAvailRun(queue, handle);

            if (handle != -1) {
                // 将 run 进行拆分
                handle = splitLargeRun(handle, pages);
            }
        
            // 更新剩余空间
            int pinnedSize = runSize(pageShifts, handle);
            freeBytes -= pinnedSize;
            return handle;
        }
    }

allocateRun() 整理还是比较简单的,流程如下:

  1. 根据 runSize 计算所需的 page 数量。
  2. 根据 page 的数量确认 page 的起始索引,注意这个索引值对应 runsAvail 的索引值,从哪里取呢?SizeClasses 中的 pageIdx2sizeTab 表格中获取,该表格维护着 pageSize 倍数的内存规格表。
  3. 根据起始索引值 pageIdx 从 runsAvail 中获取第一个能够进行此次内存分配请求的 LongPriorityQueue,该 LongPriorityQueue 中包含有若干个可用的 run。
  4. 从 LongPriorityQueue 获取可用的 run。一开始大明哥就说到 LongPriorityQueue 是一个小顶堆,queue.poll() 保证了拿到 run 是当前可分配 run 的最低地址。
  5. 将该 handle 从 LongPriorityQueue 中移除。为什么要移出?因为我们可能需要对其进行修改,因为分配完成后它肯定不属于这个 LongPriorityQueue 了。
  6. 调用 splitLargeRun() 切割 run,这里可能会将 run 拆分两部分,一部分用于当前内存分配,一部分是剩余空闲内存块,这部分空闲的内存块会放到合适的 LongPriorityQueue 数组中,待下次分配。也有可能刚刚好够分配,就不用拆分了。
  7. 更新剩余空间。

runFirstBestFit()

runFirstBestFit() 是根据当前 queueIdx 获取第一个能够满足它内存申请 LongPriorityQueue:

arduino 复制代码
    private int runFirstBestFit(int pageIdx) {
        if (freeBytes == chunkSize) {
            return arena.nPSizes - 1;
        }
        for (int i = pageIdx; i < arena.nPSizes; i++) {
            LongPriorityQueue queue = runsAvail[i];
            if (queue != null && !queue.isEmpty()) {
                return i;
            }
        }
        return -1;
    }

这个方法看似简单,其实内部包含了你对 runsAvail 结构的理解,它是 LongPriorityQueue 类型的数组,元素有 32 个,为什么是 32 呢?去看 SizeClasses 中的 pageIdx2sizeTab 就知道了。runsAvail 与 pageIdx2sizeTab 就是一一对应的关系,所以在这里我们就可以确认 runsAvail 整个结构了:

index num of pages
0 1
1 2
2 3
..... .....
8 9 ~ 10
9 10 ~ 11
..... .....
31 449 ~ 512

怎么理解这个表格呢?index 是索引,num of pages 表示该 LongPriorityQueue 存储的 handle 能够管理空闲 page 的数量,在使用 runsAvail 这个数组的时候会涉及到向上取整合向下取整的关系,比如我申请内存为 1MB,从 pageIdx2sizeTab 表格中我们可以查到对应的 pageIdx 为 23,所以我们可以从 runsAvail 数组向上循环获取对应的 LongPriorityQueue ,这样就可以可以找到一个满足此次内存分配请求的 run。

splitLargeRun()

我们找到了合适的 run,则调用 splitLargeRun() 进行拆分,拆分就是将该 run 拆分为两部分,一部分为分配用于本次内存分配,另外一部分为空闲部分需要合并到合适的 LongPriorityQueue 中。

ini 复制代码
    private long splitLargeRun(long handle, int needPages) {
        assert needPages > 0;
        
        // 空闲 run 所管理的 page 总数
        int totalPages = runPages(handle);
        assert needPages <= totalPages;
        
        // 剩余 page 数
        int remPages = totalPages - needPages;
       
        // 如果有剩余,则说明需要将该 run 进行拆分
        if (remPages > 0) {
            // 获取当前 run 的偏移量
            int runOffset = runOffset(handle);

            // 剩余空闲页的偏移量 = 旧的偏移量 + 分配页数
            int availOffset = runOffset + needPages;
            // 根据偏移量、剩余页数重新生成一个新的空闲的 run
            long availRun = toRunHandle(availOffset, remPages, 0);
            // 将这个全新的 run,更新到 runsAvailMap 和 runsAvail
            insertAvailRun(availOffset, remPages, availRun);

            // 生成本次用于分配的句柄
            return toRunHandle(runOffset, needPages, 1);
        }

        // 没有剩余 page,该 run 更新为『已使用』
        handle |= 1L << IS_USED_SHIFT;
        return handle;
    }

该方法没有很复杂的逻辑,就是判断 run 分配后是否还有剩余 page,如果有则对其进行拆分,否则标记该 run 为已使用。最重要的步骤在于insertAvailRun() 对新的空闲 run 的处理。

insertAvailRun()

scss 复制代码
    private void insertAvailRun(int runOffset, int pages, long handle) {
        // 获取当前 page 数所对应的 pageIdx
        int pageIdxFloor = arena.pages2pageIdxFloor(pages);
        LongPriorityQueue queue = runsAvail[pageIdxFloor];
        // 加入到 LongPriorityQueue 中
        queue.offer(handle);

        // 将首页插入到 runsAvailMap 中
        insertAvailRun0(runOffset, handle);
        if (pages > 1) {
            // 将尾页插入到 runsAvailMap 中
            insertAvailRun0(lastPage(runOffset, pages), handle);
        }
    }

到这里 Normal 规格的内存分配源码就分析完毕了,看着复杂,其实弄清楚 LongPriorityQueue[] runsAvailLongLongHashMap runsAvailMap 这两个数据结构就不是那么复杂了,为了各位小伙伴更好地理解整个过程,大明哥将通过画图的方式来阐述整个过程。

图文讲解

在这里大明哥将连续分配4 次内存,分别为 30 * 8KB48 * 8KB64 * 8KB1MB再次申明,大明哥的 Netty 版本 PoolChunk 默认为 4MB

初始状态

首先是初始状态,我们的句柄 handle 为 8796093022208,对应二进制为 00000000 00000000 00001000 00000000 00000000 00000000 00000000 00000000。runsAvailMap 和 runsAvail 都是初始状态。如下:

分配 30 * 8KB 内存

分配 30 * 8KB,需要 32 个 page,PoolChunk 剩余空间为 512 * 8 KB - 32 * 8KB = 480 * 8KB

分配 48 * 8KB 内存

分配 64 * 8KB

分配 1MB

各位小伙伴一定要理清楚 handlerunsAvailMaprunsAvail 在每个阶段的变化,如果明白了这个变化过程,你会发现 PoolChunk 的内存分配真的简单!

内存释放

源码分析

PoolChunk 提供了 free() 用来回收内存。

scss 复制代码
    void free(long handle, int normCapacity, ByteBuffer nioBuffer) {
        // 计算该 handle 多大
        int runSize = runSize(pageShifts, handle);
        // 如果是 subpage,就回收 subpage
        if (isSubpage(handle)) {
            // 省略代码...
        }

        // 回收 run
        synchronized (runsAvail) {
            // 向前、向后合并当前 run
            long finalRun = collapseRuns(handle);

            // 更新当前 run 为未使用
            finalRun &= ~(1L << IS_USED_SHIFT);
            // 清理 subpage 的标志位
            finalRun &= ~(1L << IS_SUBPAGE_SHIFT);
            
            // 将该 run 更新到 runsAvailMap 和 runsAvail
            insertAvailRun(runOffset(finalRun), runPages(finalRun), finalRun);
            // 更新空闲内存块大小
            freeBytes += runSize;
        }

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

这里只看回收 run,回收 subpage 部分我们下篇文章分析。

  1. 调用 collapseRuns() 向前、向后合并与当前 run 相邻的 run,将其合并成一个更大的 run
  2. 将该 run 重新标记为未使用,同时清理到 subpage 标识
  3. 更新 runsAvailMap 和 runsAvail
  4. 回收 ByteBuf 对象

回收 run 的过程比分配 run 简单些,最主要就在于当前 run 与相邻 run 的合并。

collapsePast()

collapsePast() 向后合并连续的 run。

ini 复制代码
    private long collapsePast(long handle) {
        // 不断向后,直到不能合并为止
        for (;;) {
            // 偏移量
            int runOffset = runOffset(handle);
            // 拥有的 page 数
            int runPages = runPages(handle);
            
            // 获取末尾的可用的 run
            long pastRun = getAvailRunByOffset(runOffset - 1);
            if (pastRun == -1) {
                // -1 表示没有相邻的 
                return handle;
            }
            
            // 末尾 run 的偏移量
            int pastOffset = runOffset(pastRun);
            // 末尾 run 拥有的 page 数
            int pastPages = runPages(pastRun);

            // 判断是否连续
            // past_run 的偏移量 + 页数量 = run 的偏移量
            if (pastRun != handle && pastOffset + pastPages == runOffset) {
                // 移出旧的 run(past run)
                removeAvailRun(pastRun);
                  // 生成一个新的 run
                handle = toRunHandle(pastOffset, pastPages + runPages, 0);
            } else {
                return handle;
            }
        }
    }

获取 runOffset - 1 的 run ,然后合并它,这里是一个for(;;) 的过程,会不断向后合并,直到不能合并为止。

collapseNext()

collapseNext() 向前合并 run,原理和 collapsePast() 差不多。

ini 复制代码
    private long collapseNext(long handle) {
        for (;;) {
            int runOffset = runOffset(handle);
            int runPages = runPages(handle);
            
            // 这个 run 的偏移量 = 当前的偏移量 + 页数
            long nextRun = getAvailRunByOffset(runOffset + runPages);
            if (nextRun == -1) {
                return handle;
            }

            int nextOffset = runOffset(nextRun);
            int nextPages = runPages(nextRun);

            if (nextRun != handle && runOffset + runPages == nextOffset) {
                removeAvailRun(nextRun);
                handle = toRunHandle(runOffset, runPages + nextPages, 0);
            } else {
                return handle;
            }
        }
    }

向前的 run 的 offset = runOffset + runPages

图文讲解

我们还是基于上面那个图例来分析,我们知道现在一个 PoolChunk已经分配出去了 30 * 8KB48 * 8KB64 * 8KB1MB 四块内存,为了更好地演示合并过程和更好的理解 runsAvailMaprunsAvail 的变化,我们这里释放 48 * 8KB64 * 8KB 两块内存。

释放 48 * 8KB

释放的 handle ,大明哥标记为红色。

我们释放了一块内存,就需要将这块内存添加到 runsAvailMaprunsAvail 中去。

释放 64 * 8KB

这释放 64 * 8KB 内存,然后它恰好跟上面释放的 48 * 8KB 相邻,所以会将他们两个进行合并,生成一个新的大的 Handle。

那个新的 Handle 情况如下:

到这里整个 PoolChunk 的源码分析就结束了,整体上来说不是很难,理解了它两个核心数据结构,知道内存分配和内存释放过程是怎么变化的基本上就差不多了,相对于基于 jemalloc 3 的来说,这个版本的 PoolChunk 真的简单了蛮多。

相关推荐
程序猿小D15 分钟前
第二百六十九节 JPA教程 - JPA查询OrderBy两个属性示例
java·开发语言·数据库·windows·jpa
极客先躯1 小时前
高级java每日一道面试题-2024年10月3日-分布式篇-分布式系统中的容错策略都有哪些?
java·分布式·版本控制·共识算法·超时重试·心跳检测·容错策略
夜月行者1 小时前
如何使用ssm实现基于SSM的宠物服务平台的设计与实现+vue
java·后端·ssm
程序猿小D2 小时前
第二百六十七节 JPA教程 - JPA查询AND条件示例
java·开发语言·前端·数据库·windows·python·jpa
Yvemil72 小时前
RabbitMQ 入门到精通指南
开发语言·后端·ruby
sdg_advance2 小时前
Spring Cloud之OpenFeign的具体实践
后端·spring cloud·openfeign
潘多编程2 小时前
Java中的状态机实现:使用Spring State Machine管理复杂状态流转
java·开发语言·spring
_阿伟_2 小时前
SpringMVC
java·spring
代码在改了2 小时前
springboot厨房达人美食分享平台(源码+文档+调试+答疑)
java·spring boot
猿java2 小时前
使用 Kafka面临的挑战
java·后端·kafka