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

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


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

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


数据结构

PoolSubpage 用于分配内存规格小于 28KB(1<<(pageshift+LOG22_SIZE_CLASS_GROUP) 的内存,数据结构如下:

在 PoolArena 中有一个 PoolSubpage 类型的数组:PoolSubpage<T>[] smallSubpagePools,它每一个元素都是一个 PoolSubpage 的双向链表,在 PoolArena 初始化时会初始化该数组,同时会新建一个 PoolSubpage 的 head 节点,注意 PoolSubpage 内部属性全为 null,详细情况见:PoolArena

在第一次申请内存时,PoolArena 会先判断双向链表中是否有空闲空间的 PoolSubpage ,如果没有就向 PoolChunk "借"一块内存块(run)过来,然后根据本次申请内存的大小将该 run 切分为大小相同的多个存储块,比如我们申请的 run 为 4MB,本次申请的内存大小为 1KB,那么该 run 会被分为 4 MB / 1KB = 4096 块内存块,在 PoolSubpage 中这些内存块并不是通过什么链表来串连起来的,而是通过下一个可用内存块坐标 nextAvail 来标记的。直接看 PoolSubpage 的定义就知道了:

java 复制代码
final class PoolSubpage<T> implements PoolSubpageMetric {
    
    // 所属 PoolChunk
    final PoolChunk<T> chunk;
    // 一块内存的大小
    final int elemSize;
    // page 偏移量
    private final int pageShifts;
    // 在 PoolChunk 中所处的位置
    private final int runOffset;
    // runSize
    private final int runSize;
    // bitMap
    private final long[] bitmap;
 
    // 前置节点
    PoolSubpage<T> prev;
    // 后置节点
    PoolSubpage<T> next;
    
    // 标记是否已被释放
    boolean doNotDestroy;
    // 最大内存块数
    private int maxNumElems;
    // bitmap 长度
    private int bitmapLength;
    // 下一个可用的内存块坐标
    private int nextAvail;
    // 可用的内存块数量
    private int numAvail;
     
    // ...   
}

在 PoolSubpage 中,Netty 使用 bitmap 来记录该 PoolSubpage 中每一块内存的使用情况,数组中每个 long 的每一位表示一个内存块的占用情况,0 表示未占用,1 表示已占用。在看内存分配和内存释放的源码时我们需要注意 bitmap 的变化情况。bitmap 这个标志位比较难理解,各位小伙伴一定要对它有清晰的认识,否则后面的源码部分你会莫名其妙,看不懂为什么要这么做,不过有大明哥在,一切都不是问题。

构造函数

PoolSubpage 的构造函数主要是初始化 bitmap 数组,同时将其添加到对一个的双向链表中。

ini 复制代码
    PoolSubpage(PoolSubpage<T> head, PoolChunk<T> chunk, int pageShifts, int runOffset, int runSize, int elemSize) {
        this.chunk = chunk;
        this.pageShifts = pageShifts;
        this.runOffset = runOffset;
        this.runSize = runSize;
        this.elemSize = elemSize;
        bitmap = new long[runSize >>> 6 + LOG2_QUANTUM]; // runSize / 64 / QUANTUM

        doNotDestroy = true;
        if (elemSize != 0) {
            // 可用内存块 = runSize / elemSize
            maxNumElems = numAvail = runSize / elemSize;
            nextAvail = 0;

            bitmapLength = maxNumElems >>> 6;
            // 如果不是整数则 + 1
            if ((maxNumElems & 63) != 0) {
                bitmapLength ++;
            }

            for (int i = 0; i < bitmapLength; i ++) {
                bitmap[i] = 0;
            }
        }
        // 添加到双线链表中
        addToPool(head);
    }

bitmapLength = maxNumElems >>> 6 等价于 bitmapLength = maxNumElems / 64,64 为 long 类型所占的 bit 数。

内存分配

内存分配我们需要从 PoolArena 开始。

arduino 复制代码
    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) {
            // Normal
        } else {
           // Huge
        }
    }

根据申请内存大小计算 sizeIdx,若 sizeIdx ≤ smallMaxSizeIdx(38,则为 Small 内存规格,调用tcacheAllocateSmall()

java 复制代码
    private void tcacheAllocateSmall(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity,
                                     final int sizeIdx) {
        
        // 内存中分配
        if (cache.allocateSmall(this, buf, reqCapacity, sizeIdx)) {
            return;
        }

        // 获取 sizeIdx 位置的 head 节点
        final PoolSubpage<T> head = smallSubpagePools[sizeIdx];
        final boolean needsNormalAllocation;
        synchronized (head) {
            final PoolSubpage<T> s = head.next;
            // 相等意味双向链表为空,需要从 PoolChunk 中分配一块内存来进行 PoolSubpage 分配
            needsNormalAllocation = s == head;
            if (!needsNormalAllocation) {
                assert s.doNotDestroy && s.elemSize == sizeIdx2size(sizeIdx) : "doNotDestroy=" +
                        s.doNotDestroy + ", elemSize=" + s.elemSize + ", sizeIdx=" + sizeIdx;
                // 在双向链表中分配
                long handle = s.allocate();
                assert handle >= 0;
                s.chunk.initBufWithSubpage(buf, null, handle, reqCapacity, cache);
            }
        }
        
        if (needsNormalAllocation) {
            synchronized (this) {
                // 在 PoolChunk 中分配
                allocateNormal(buf, reqCapacity, sizeIdx, cache);
            }
        }

        incSmallAllocation();
    }

我们知道 PoolChunk 是 Netty 进行内存分配真正的位置,PoolSubpage 的内存块也是从 PoolChunk 中获取的,所以在PoolSubpage 在进行内存分配时需要判断当前双向链表是否需要到 PoolChunk 中去进行内存分配,如果需要就调用 allocateNormal() 在 PoolChunk 中分配内存,否则就在 PoolSubpage 中分配内存。

PoolSubpage#allocate()

当 PoolSubpage 的双向链表有足够的内存空间来完成内存分配,就调用 PoolSubpage#allocate() 来分配内存:

ini 复制代码
    long allocate() {
        // numAvail == 0 表示内存已分配完了
        // !doNotDestroy 则表示 PoolSubpage 已经从池中被移除了
        if (numAvail == 0 || !doNotDestroy) {
            return -1;
        }
        
        // 获取下一个可用的的坐标
        final int bitmapIdx = getNextAvail();
        // q = bitmapIdx / 64;
        int q = bitmapIdx >>> 6;
        int r = bitmapIdx & 63;
        assert (bitmap[q] >>> r & 1) == 0;   // q 位置的值应该为 0,未被使用
        
        // 将 bitmap[q] 设置为 1,标记此"块"内存已分配
        bitmap[q] |= 1L << r;
        
        if (-- numAvail == 0) {
           // 该 PoolSubpage 已经没有内存块是空闲的,从 PoolArena 的双向链表中移除
            removeFromPool();
        }

        return toHandle(bitmapIdx);
    }
  • 调用 getNextAvail() 来获取该 PoolSubpage 中下一个可用的 "内存块"的位置 bitmapIdx。

  • 将 bitmapIdx 在 bitmap 所对应的位置标识为"已占用"状态。如何来做?

    • 根据 q = bitmapIdx >>> 6 得到 bitmapIdx 这个内存块在 bitmap 数组中第 q 个元素。
    • 根据 r = bitmapIdx & 63 得到 r 位的值。
    • 利用 (bitmap[q] >>> r & 1) == 0 来判断 bitmap[q] 的第 r 位是否为 0,如果是 0 则表示未被占用,未被占用则将第 r 位设置为 1(bitmap[q] |= 1L << r
  • numAvail - 1,若 - 1numAvail == 0,表示该 PoolSubpage 中已经没有空闲的内存块了,则调用 removeFromPool() 将该 PoolSubpage 从 PoolArena 的双向链表中移除。

  • 最后调用 toHandle() 转换为 handle。

这段代码有两个值 q 和 r 很不好理解,在这里我们需要认真梳理下 PoolSubpage 是如何来标识它的内存块情况的。我们来进行 100 次内存分配:

ini 复制代码
for(int i = 0 ; i < 100 ; i++) {
    ByteBufAllocator.DEFAULT.heapBuffer(32);
}

首先大明哥的默认 chunkSize = 8192,所以在我们第一次申请 32B 的内存时,会将整个 run 切分为 8192 / 32 = 256 块,即 maxNumElems = numAvail = 256,所以 bitmapLength = maxNumElems >>> 6 = 4,这里为什么是 4 呢?各位小伙伴想想,一个 long 有 64 位,我们总共需要标注 256 块内存块,不就是 256 / 64 么?

在这 100 次内存分配过程中,我们只需要关注几个位置即可,第 1、2 、60、100 次。

第 1 次

  • 分配前
  • 分配后

分配前都为 0,这个应该好理解,分配后 bitmap[0] = 1,对应的 2 进制 long 为:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001,表明 bitmap[0] 第 0 位所对应的内存卡已经分配出去了。

第 2 次

  • 分配前
  • 分配后
  • bitmapIdx = 1,表明分配第 2 块内存块
  • q = 0 ,r = 1,说明对应的位置为 bitmap[0] 第 1 位。
  • 分配完后,bitmap[0] = 3 ,对应二进制位 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000011

第 60 次

  • 分配前
  • 分配后
  • q = 0 ,r = 59,说明对应的位置为 bitmap[0] 第 59 位,是不是一直都在符合我们的预期值?
  • 分配前 bitmap[0] = 576460752303423487,对应二进制 00000111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  • 分配后 bitmap[0] = 1152921504606846975 对应二进制 00001111 11111111 11111111 11111111 11111111 11111111 11111111 11111111

第 100 次

  • 分配前
  • 分配后
  • q = 1,r = 35。一个 long 最多只能标记 64 个内存块,所以第 100 次内存分配时,就要到第 2 个元素来了。
  • bitmap[0] = -1,表明这个元素所对应的所有内存块都已经分配完毕了。
  • 分配前后的 bitmap[1] 值大明哥就标记了,详细各位小伙伴明白是什么意思了。

到这里各位小伙伴应该对这个 bitmap 的定位很清晰了吧?也明白了 PoolSubpage 是如何来进行内存块的管理了吧?

我们再回到源码部分。

getNextAvail()

getNextAvail() 就是寻找下一个可用的内存块,通过上面的演示,我们基本上可以猜测它是如何实现的:按照顺序遍历判断标识位数组 bitmap 的每一个 long 元素的每一个 bit 是否为 0,我们来看看。

java 复制代码
    private int getNextAvail() {
        int nextAvail = this.nextAvail;
        // nextAvail >= 0,有两种情况
        // 1. 初始化,第一次申请内存时 nextAvail = 0,可以直接返回,这里会设置 nextAvail = -1 ,后面申请就不会再次进入这个逻辑
        // 2. 释放内存,释放内存后,会将释放的内存块的 bitmapIdx 设置为 nextAvail 
        if (nextAvail >= 0) {
            this.nextAvail = -1;
            return nextAvail;
        }
        return findNextAvail();
    }
    
    // 遍历整个 bitmap 数组
    private int findNextAvail() {
        final long[] bitmap = this.bitmap;
        final int bitmapLength = this.bitmapLength;
        // 对 bitmap 数组中的每一个元素进行判断
        for (int i = 0; i < bitmapLength; i ++) {
            long bits = bitmap[i];
            // ~bits != 0 说明该 long 中至少还有一个 bit 位不为 0
            if (~bits != 0) {
                return findNextAvail0(i, bits);
            }
        }
        return -1;
    }
    
    // 检查 bitmap 数组中 long 的每一个 bit 是否为 0
    private int findNextAvail0(int i, long bits) {
        final int maxNumElems = this.maxNumElems;
        final int baseVal = i << 6;

        for (int j = 0; j < 64; j ++) {
            if ((bits & 1) == 0) {
                int val = baseVal | j;
                if (val < maxNumElems) {
                    return val;
                } else {
                    break;
                }
            }
            bits >>>= 1;
        }
        return -1;
    }

通过源码证实了我们的猜想:就是遍历 bitmap 数组中元素的每一个 bit 位,看是否有一个位为 0。

removeFromPool()

当一个 PoolSubpage 中的所有内存块都分配完毕后,需要将其从 PoolArena 的双向链表中移除。

ini 复制代码
    private void removeFromPool() {
        assert prev != null && next != null;
        prev.next = next;
        next.prev = prev;
        next = null;
        prev = null;
    }

toHandle()

这个方法很有意思,因为如果你不理解它,你就无法理解 PoolChunk#allocate(),我们再看看 PoolChunk 中 handle 的定义:

它的第 31 ~ 63 位都是跟 PoolSubpage 相关的,但是你看 PoolChunk#allocateSubpage() 方法根本就没有设置第 31 ~ 63 位的值,大明哥当时也很懵逼,直到看了这个方法才一目了然。

arduino 复制代码
    private long toHandle(int bitmapIdx) {
        int pages = runSize >> pageShifts;
        return (long) runOffset << RUN_OFFSET_SHIFT
               | (long) pages << SIZE_SHIFT
               | 1L << IS_USED_SHIFT
               | 1L << IS_SUBPAGE_SHIFT
               | bitmapIdx;
    }

这个方法很简单,就是根据 bitmapIdx 、runSize、runSize来构建 handle,各个 SHIFT 定义如下:

arduino 复制代码
static final int IS_SUBPAGE_SHIFT = BITMAP_IDX_BIT_LENGTH;
static final int IS_USED_SHIFT = SUBPAGE_BIT_LENGTH + IS_SUBPAGE_SHIFT;
static final int SIZE_SHIFT = INUSED_BIT_LENGTH + IS_USED_SHIFT;
static final int RUN_OFFSET_SHIFT = SIZE_BIT_LENGTH + SIZE_SHIFT;

具体转换成啥样,各位小伙伴去验证验证。

PoolChunk#allocate()

java 复制代码
    boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
        final long handle;
        if (sizeIdx <= arena.smallMaxSizeIdx) {
            // small
            handle = allocateSubpage(sizeIdx);
            if (handle < 0) {
                return false;
            }
            assert isSubpage(handle);
        } else {
            // normal
        }
        //...
        return true;
    }

如果sizeIdx ≤ smallMaxSizeIdx 则调用 allocateSubpage() 来进行 Small 内存规格分配:

ini 复制代码
    private long allocateSubpage(int sizeIdx) {
        // 获取 head
        PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);
        synchronized (head) {
            // 计算 size 与 pageSize 的最小公倍数
            int runSize = calculateRunSize(sizeIdx);
            // 将这个部分的 run 分配出去
            long runHandle = allocateRun(runSize);
            if (runHandle < 0) {
                return -1;
            }
            
            int runOffset = runOffset(runHandle);
            // 确保这个位置的 run 没有分配出去过
            assert subpages[runOffset] == null;
            int elemSize = arena.sizeIdx2size(sizeIdx);
            
            // 构造一个 PoolSubpage 对象
            PoolSubpage<T> subpage = new PoolSubpage<T>(head, this, pageShifts, runOffset,
                               runSize(pageShifts, runHandle), elemSize);
            
            subpages[runOffset] = subpage;
            // 调用 PoolSubpage 的 allocate() 分配内存
            return subpage.allocate();
        }
    }
  • 首先调用 calculateRunSize() 计算 size 与 pageSize 的最小公倍数 runSize,因为 PoolSubpage 需要对 size 进行均分。
  • 调用 allocateRun()将 runSize 分配给 PoolSubpage。
  • 根据对应的信息构建一个 PoolSubpage 对象,然后调用其allocate() 进行内存分配。
  • 最后将该 subpage 添加到 subpages 数组中。

内存释放

PoolSubpage 的内存释放入口也是 PoolChunk :

ini 复制代码
    void free(long handle, int normCapacity, ByteBuffer nioBuffer) {
        int runSize = runSize(pageShifts, handle);
        if (isSubpage(handle)) {
            //是 PoolSubpage
            int sizeIdx = arena.size2SizeIdx(normCapacity);
            // 找到对应的 PoolSubpage 链表 head
            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))) {
                    // 没有释放完全,直接返回
                    return;
                }
                assert !subpage.doNotDestroy;
                // 释放完全
                subpages[sIdx] = null;
            }
        }

        // ...
    }

PoolChunk 释放 PoolSubpage 逻辑很简单,因为我们在 PoolChunk 的 subpages 数组中保存了 PoolSubpage,我们只需要根据对应的 handle 就可以计算出在 subpages 数组中的索引 sIdx,最后调用其 free() 方法来释放内存就可以了。

ini 复制代码
   boolean free(PoolSubpage<T> head, int bitmapIdx) {
        if (elemSize == 0) {
            return true;
        }
        //
        int q = bitmapIdx >>> 6;
        int r = bitmapIdx & 63;
        assert (bitmap[q] >>> r & 1) != 0;
        // 设置为 0 
        bitmap[q] ^= 1L << r;
        
        //设置 nextAvail = bitmapIdx
        setNextAvail(bitmapIdx);

        if (numAvail ++ == 0) {
            // numAvail == 0 说明整个 PoolSubpage 已经完全释放了,则将其加入到双向链表中
            addToPool(head);
            if (maxNumElems > 1) {
                return true;
            }
        }

        if (numAvail != maxNumElems) {
           // numAvail != maxNumElems 说明 PoolSubpage 还没有完全释放
            return true;
        } else {
            // 已经完全释放了
            if (prev == next) {
                return true;
            }
            doNotDestroy = false;
            removeFromPool();
            return false;
        }
    }

PoolSubpage#free() 也比较简单,主要是将对应位置的标识位设置为 0,然后设置 nextAvail = bitmapIdx(这里可以在内存分配时加快检索效率,不需要从头到尾依次循环检索),最后判断该 PoolSubpage 是否已完全释放,是就返回 true,否则返回 false,如果已经完全释放了在 PoolChunk 中的 subpages 数组需要删除该 PoolSubpage 对象。

到这里 PoolSubpage 的源码就已经分析完毕了,相比 PoolChunk,PoolSubpage 相对会简单些,没有难理解的数据结构,也没有难理解的复杂逻辑。

相关推荐
XiaoLeisj2 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck2 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei2 小时前
java的类加载机制的学习
java·学习
码农小旋风3 小时前
详解K8S--声明式API
后端
Peter_chq3 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
Yaml44 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~4 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616884 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
aloha_7894 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot
记录成长java5 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet