死磕 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 真的简单了蛮多。

相关推荐
qq_2518364575 分钟前
基于java Web 动漫视频网站毕业论文
java·开发语言·前端
LayZhangStrive11 分钟前
JUC相关的函数、注解、变量杂记
java·面试·多线程·juc
未秃头的程序猿16 分钟前
给公司做了个AI客服Agent,用的Spring AI 1.0,3天上线领导拍板了
java·后端·ai编程
曹牧19 分钟前
Eclipse 批量文本替换
java·ide·eclipse
Darren24525 分钟前
MySQL索引执行计划不走索引下推
后端
程序员清风29 分钟前
OpenAI官方发布最新提示词技巧!
java·后端·面试
梅头脑35 分钟前
写了3年CRUD,volatile和synchronized的区别还是答不清——直到我画出了这张三性两锁边界图
java
暮暮祈安38 分钟前
Celery 新手入门指南
java·数据库·python·flask·httpx
码事漫谈1 小时前
人机协同的三重范式:HITL、HOTL与HOOTL
后端
武子康1 小时前
Inkling 975B 说明“开放权重“与“普通开发者本地运行“已经分离,内容重点应是部署容量和运行时边界
前端·人工智能·后端