Netty源码(三)内存分配(上)

832 阅读15分钟

前言

本章学习Chunk和Subpage内存分配。

  • 内存规格标准化,如何将用户申请的不规则内存大小,标准化为Netty自己的内存规格(Tiny、Small、Page、Huge)。
  • Chunk如何分配Page级别内存(8K-16MB)。
  • Chunk和Subpage如何分配Tiny、Small级别内存(16B-4KB)。
  • 内存释放。

一、内存规格标准化

内存规格标准化,就是把用户申请的内存,标准化为Netty的内存块规格,比如16B、32B、1K、4K、8K等。

方法入口见PoolArena#normalizeCapacity。这里可以忽略directMemoryCacheAlignment内存对齐参数,认为其为0。

int normalizeCapacity(int reqCapacity) {
    // 大于等于16MB,做内存对齐返回
    if (reqCapacity >= chunkSize) {
        return directMemoryCacheAlignment == 0 ? reqCapacity : alignCapacity(reqCapacity);
    }
    // 大于等于512B,向上取2的n次幂(如果已经是2的n次幂原样返回)
    if (!isTiny(reqCapacity)) {
        int normalizedCapacity = reqCapacity;
        normalizedCapacity --;
        normalizedCapacity |= normalizedCapacity >>>  1;
        normalizedCapacity |= normalizedCapacity >>>  2;
        normalizedCapacity |= normalizedCapacity >>>  4;
        normalizedCapacity |= normalizedCapacity >>>  8;
        normalizedCapacity |= normalizedCapacity >>> 16;
        normalizedCapacity ++;
        if (normalizedCapacity < 0) {
            normalizedCapacity >>>= 1;
        }
        return normalizedCapacity;
    }
    // 小于512B的需要做内存对齐
    if (directMemoryCacheAlignment > 0) {
        return alignCapacity(reqCapacity);
    }
    // 如果是16的倍数,直接返回
    // Quantum-spaced(翻译为量子间距。是什么?)
    if ((reqCapacity & 15) == 0) {
        return reqCapacity;
    }
    // 向上取16的倍数
    return (reqCapacity & ~15) + 16;
}

对于用户申请内存的大小,有不同的逻辑。

  • 大于等于16MB,直接返回。
  • 大于等于512字节,向上取2的n次幂,对应Small、Page规格内存块。
  • 小于512字节,向上取16的倍数,对应所有Tiny规格内存块和512字节Small内存块。至于为什么取16的倍数,注释里仅有一行Quantum-spaced,不理解。

可以学习一下位运算的骚操作。

isTiny:判断一个数是否小于给定2的n次幂(512)。0xFFFFFE00十六进制,表示-512。

static boolean isTiny(int normCapacity) {
    return (normCapacity & 0xFFFFFE00) == 0;
}

向上取2的n次幂,如果已经是2的n次幂原样返回

int normalizedCapacity = reqCapacity;
// 这一行是针对已经是2的n次幂的reqCapacity,做一个适配,变成2的n次幂-1,走一样的逻辑
normalizedCapacity --;
// 把最高的一个1位涂抹到所有低位上(只关注最高的一个1位即可,抽象)
// 这一步做完一定是00000111111这样的,第一个出现的1就是原始最高位的1
normalizedCapacity |= normalizedCapacity >>>  1;
normalizedCapacity |= normalizedCapacity >>>  2;
normalizedCapacity |= normalizedCapacity >>>  4;
normalizedCapacity |= normalizedCapacity >>>  8;
normalizedCapacity |= normalizedCapacity >>> 16;
// 最后加上1,保证是2的n次幂
normalizedCapacity ++;
// 考虑溢出的情况(reqCapacity=Integer.MAX_VALUE),这里会是1开头+31个0
if (normalizedCapacity < 0) {
	// 设置为01开头+30个0
    normalizedCapacity >>>= 1;
}

是否是2的n次幂的倍数

(reqCapacity & 15) == 0
````
**向上取2的n次幂的倍数**:(reqCapacity & ~15)表示除16向下取整,然后加上16。不过前面要判断,如果已经是16的倍数了,直接返回,不然就错了。
```java
(reqCapacity & ~15) + 16

二、Chunk分配8K-16MB规格内存

8K-16MB内存分配不需要Subpage参与,只需要Chunk即可。

整个过程分为两步:

  1. 分配:为用户分配memoryMap中的一个节点,即确定本次分配占用16MB内存块的位置(偏移量和长度)。
  2. 初始化:用给定的16MB内存块位置信息,初始化PooledByteBuf。 流程入口为PoolChunk#allocate
 boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity, PoolThreadCache threadCache) {
    final long handle;
    // 大于1页 8192
    if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
        handle =  allocateRun(normCapacity);
    }
    // 小于 8192
    else {
        handle = allocateSubpage(normCapacity);
    }
    if (handle < 0) {
        return false;
    }
    // 如果缓存中存在ByteBuffer,使用缓存中的ByteBuffer,只有Direct才会存在
    // 这里的目的仅仅是为了少创建对象减少GC,早期版本都没这回事
    ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null;
    // 初始化PooledByteBuf
    initBuf(buf, nioBuffer, handle, reqCapacity, threadCache);
    return true;
}

下面先讲源码,但是源码比较抽象,后面再补一个例子(带图)。

1、分配内存地址

private long allocateRun(int normCapacity) {
    // 计算对于申请容量,需要哪个深度的节点
    int d = maxOrder - (log2(normCapacity) - pageShifts);
    // 根据深度找到空闲的节点 标记为不可使用 并 更新所有父节点
    // 返回分配节点id(memoryMap下标)
    int id = allocateNode(d);
    if (id < 0) {
        return id;
    }
    // 剩余可分配字节 -= 本次分配的空间
    // 本次分配的空间 = 1 << (log2(16MB) - depthMap[id])
    freeBytes -= runLength(id);
    return id;
}

重点看allocateNode方法,分配Tiny和Small规格的逻辑也需要这个方法(言外之意,分配Tiny和Small,也需要先占用一个页,即Chunk树的一个叶子节点)。

private int allocateNode(int d) {
	// 从树根开始搜索
    int id = 1;
    // 一个掩码,用来和id做&,如果id & initial = 0 代表id所在层数 小于目标层数
    int initial = - (1 << d);
    byte val = value(id);
    // 根节点(id=1)的值大于申请深度
    // 表示这颗Chunk树的内存已经不足以分配了
    if (val > d) {
        return -1;
    }
    // val < d 当前节点memoryMap值不足d,应该找他的后代节点,直到val=d,需要继续寻找
    // (id & initial) == 0表示id没到d层 
    // 继续循环
    while (val < d || (id & initial) == 0) {
        // 前往当前节点的下一层的左子节点
        id <<= 1;
        // 下一层的左子节点的memoryMap值
        val = value(id);
        // 如果下一层的左子节点memoryMap值大于d,表示不足以分配
        // 前往它的兄弟节点,即下一层的右子节点
        if (val > d) {
            id ^= 1;
            val = value(id);
        }
    }
    // id确定被分配,标记为不可使用(12)
    setValue(id, unusable);
    // id的所有父节点的memoryMap值
    updateParentsAlloc(id);
    return id;
}

这个算法的逻辑,完全取决于找到节点后如何更新整棵树的值,如果不看updateParentsAlloc方法根本看不懂。updateParentsAlloc方法的含义就是,自下而上迭代更新id节点的所有祖先节点,父节点=Min(左孩子,右孩子)。需要注意的是,这里不会更新分配节点的孩子节点,因为allocateNode方法搜索是自上而下的,更新孩子节点没意义。

private void updateParentsAlloc(int id) {
    while (id > 1) {
        // 父亲节点
        int parentId = id >>> 1;
        // 当前节点
        byte val1 = value(id);
        // 兄弟节点
        byte val2 = value(id ^ 1);
        // 当前节点和兄弟节点取小
        byte val = val1 < val2 ? val1 : val2;
        // 设置父亲节点为子节点中的小值
        setValue(parentId, val);
        // 继续迭代,把id设置为父亲节点
        id = parentId;
    }
}

看完updateParentsAlloc方法,回过头再看allocateNode里的while循环。

这里传入的d,一方面代表元素所处深度,另一方面代表d层元素原始的内存规格,比如d=11,代表需要8k,d=10,代表需要16k。下面把代码里的位运算替换为正常写法。

// val < d 当前节点memoryMap值不足d,应该找他的后代节点,直到val=d,需要继续寻找
// depth[id} < d表示id没到d层 
// 继续循环
while (val < d || depth[id} < d) {
    // 前往当前节点的下一层的左子节点
    id = id * 2;
    // 下一层的左子节点的memoryMap值
    val = value(id);
    // 如果下一层的左子节点memoryMap值大于d,表示不足以分配
    // 前往它的兄弟节点,即下一层的右子节点
    if (val > d) {
        id = id + 1;
        val = value(id);
    }
}

下面结合一个例子,结合图例,再理解一下。

例:按照8KB、8KB、4MB的顺序分配内存,这里直接用标准化过后的内存规格了,不弄复杂

@Test
public void test0() {
    PooledByteBufAllocator allocator = (PooledByteBufAllocator) ByteBufAllocator.DEFAULT;
    // 8KB
    ByteBuf byteBuf = allocator.newDirectBuffer(8192, Integer.MAX_VALUE);
    // 8KB
    byteBuf = allocator.newDirectBuffer(8192, Integer.MAX_VALUE);
    // 4MB
    byteBuf = allocator.newDirectBuffer(4 * 1024 * 1024, Integer.MAX_VALUE);
}

第一步,申请8K内存,计算得到节点深度d = maxOrder - (log2(normCapacity) - pageShifts) = 11 - (log2(8192) - 13) = 11。进入allocateNode,只执行while循环,不会进入里面的if判断,最终分配到id=2048节点。标记2048节点不可用(12),并更新所有祖先节点。

第二步,再次申请8K内存,计算得节点深度d=11。进入allocateNode,关注最后一次while循环。循环当前节点id=1024,注意此时val等于11,已经等于申请深度,但是不满足 (id & initial) == 0 条件,即没有到指定实际深度,所以继续循环。左子节点2048值为12大于申请深度,进入if判断转移到右子节点,最终id=2049。标记2049节点不可用(12),并更新所有祖先节点,注意这时候1024节点更新为12也不可用了。

第三步,申请4MB内存,计算得节点深度d = 11 - (log2(4194304) - 13) = 2。关注最后一次while循环。当前节点id=2,memory=2,但是不满足(id & initial) == 0 条件,继续循环。循环内由于左子节点id=4,memory=3,大于申请深度2,经过if判断跳到右子节点,最终id=5。更新id=5不可用,并迭代更新其祖先节点。注意id=5节点的所有后辈节点都没有更新。

2、初始化Buffer

经过上面一步从Chunk分配到memoryMap中的一个id,就可以计算出实际使用Buffer的下标区间了。比如申请8K,分配到id=2048,实际使用区间是[0,8192);比如又申请了8K,分配到id=2049,实际使用区间是[8192,16384);再比如申请4MB,分配到id=5,实际使用区间是[4194304, 8388608)。

下一步就是把这些关键信息注入PooledByteBuf,称为初始化Buffer,对应方法PoolChunk#initBuf

void initBuf(PooledByteBuf<T> buf, ByteBuffer nioBuffer, long handle, int reqCapacity,
             PoolThreadCache threadCache) {
    // handle低32位 代表memoryMap下标
    int memoryMapIdx = memoryMapIdx(handle);
    // handle高32位 对于subpage,位图下标;对于page,这里是0
    int bitmapIdx = bitmapIdx(handle);
    if (bitmapIdx == 0) {
        // 大于8k 分配 page 走这
        buf.init(this, nioBuffer, handle, runOffset(memoryMapIdx) + offset,
                reqCapacity, runLength(memoryMapIdx), threadCache);
    } else {
        // 分配subpage 走这
        initBufWithSubpage(buf, nioBuffer, handle, bitmapIdx, reqCapacity, threadCache);
    }
}

先解释一下这个方法的入参。

  • buf:PooledByteBuf实例,一个空壳子,没有内存偏移量信息,暂时不可用。
  • nioBuffer:如果使用直接内存,这里可能存在一个不为空的ByteBuffer实例,和前文很多场景一样,只是为了减少new对象和GC而存在的缓存实例。这个实例主要用于ByteBuffer的API操作,通过改变其中的几个index可以达到同一个实例复用的目的。
  • handle:这个long值包含两部分信息。低32位代表memoryMap的下标,即上文中的节点id,高32位对于8k及以上规格内存分配来说是0,对于8k以下规格内存分配来说是subpage的位图下标。这里暂时只关注低32位,即memoryMap下标。
  • reqCapacity:用户申请的原始内存大小(未标准化)。
  • threadCache:先忽略。

重点关注bitmapIdx=0的情况,即分配8K-16MB内存规格的情况。

runOffset(memoryMapIdx) + offset。这个公式计算得到,PooledByteBuf实际分得16MB中使用区间的起始值,即offset偏移量。具体位运算逻辑不看了。

private int runOffset(int id) {
    int shift = id ^ 1 << depth(id);
    return shift * runLength(id);
}
private int runLength(int id) {
    return 1 << log2ChunkSize - depth(id);
}

接着runLength(memoryMapIdx)计算得到使用区间的长度。如上代码所示。

只要将上述关键信息注入PooledByteBuf,那么PooledByteBuf就能正常对外提供服务了。看PooledByteBuf的init方法。

void init(PoolChunk<T> chunk, ByteBuffer nioBuffer,
          long handle, int offset, int length, int maxLength, PoolThreadCache cache) {
    init0(chunk, nioBuffer, handle, offset, length, maxLength, cache);
}

private void init0(PoolChunk<T> chunk, ByteBuffer nioBuffer,
                   long handle, int offset, int length, int maxLength, PoolThreadCache cache) {
    this.chunk = chunk;
    // 对于直接内存是ByteBuffer 对于堆内存是byte数组
    memory = chunk.memory;
    tmpNioBuf = nioBuffer;
    allocator = chunk.arena.parent;
    this.cache = cache;
    // 对于8k规格 只有低32位有用 是memoryMapIdx
    this.handle = handle;
    // runOffset(memoryMapIdx) + offset 在16MB内存块分得的偏移量
    this.offset = offset;
    // 用户申请容量
    this.length = length;
    // runLength(memoryMapIdx) Chunk实际给PooledByteBuf分配的标准化容量
    this.maxLength = maxLength;
}

举个简单的例子,客户端调用Bytebuf.getByte(1)读取索引为1的字节。PooledDirectByteBuf会将下标1实际转换为offset+1,从16MB内存块(memory变量,这里对于直接内存是ByteBuffer)获取offset+1位置的字节返回。

@Override
protected byte _getByte(int index) {
	// 从ByteBuffer指定偏移量获取字节数据
    return memory.get(idx(index));
}
// 计算相对于16MB内存块的实际索引 = 分配offset + 入参index
protected final int idx(int index) {
    return offset + index;
}

三、Subpage分配16B-4KB内存

16B-4KB内存分配,需要先从Chunk分配一个8KB内存块,即分到id=2048-4095的memoryMap叶子节点,然后Subpage做进一步划分。所以标黄的三步与分配8KB规格内存是一样的,即allocateNode方法。

Subpage分配内存的第一步,需要得到memoryMap叶子节点下标和Subpage位图bitmap索引。二者合二为一保存在一个称为handle的long类型变量中返回。

1、分配内存地址

分配内存的入口还是PoolChunk的allocate方法,在第二部分的内容里已经贴了,这里不重复贴了。直接进入第一步PoolChunk#allocateSubpage

private long allocateSubpage(int normCapacity) {
    // 从arena的PoolSubpage<T>[]找到对应规格的PoolSubpage头节点
    PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
    // 小于8K的内存分配,要先向Chunk申请8K,再由Subpage进一步分配
    // 申请深度一定是叶子节点所在深度 即d = 11
    int d = maxOrder;
    synchronized (head) {
        // 根据深度d找到空闲的节点 标记为不可使用 memoryMap[id] = 12 并 更新所有父节点
        int id = allocateNode(d);
        if (id < 0) {
            return id;
        }
        final PoolSubpage<T>[] subpages = this.subpages;
        final int pageSize = this.pageSize;
		// 更新剩余可分配内存数量
        freeBytes -= pageSize;
        // 将memoryMap的id2048-4095映射到PoolChunk的Subpage数组中对应的下标0-2047
        int subpageIdx = subpageIdx(id);
        PoolSubpage<T> subpage = subpages[subpageIdx];
        // 创建并初始化Subpage
        if (subpage == null) {
            subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
            subpages[subpageIdx] = subpage;
        } else {
            subpage.init(head, normCapacity);
        }
        // 分配Subpage位图索引,并返回handle
        return subpage.allocate();
    }
}

首先关注PoolSubpage的构造方法,构造方法中也包含了Subpage的init方法。这个runOffset上面第二部分已经讲过了,就是获取节点id对应16MB的实际偏移量,pageSize即为给Subpage分配的区间长度,所以Subpage分配到[runOffset(id), runOffset(id) + 8192)这个区间的使用权

PoolSubpage(PoolSubpage<T> head, PoolChunk<T> chunk, int memoryMapIdx, int runOffset, int pageSize, int elemSize) {
	// Subpage所属Chunk
    this.chunk = chunk;
    // Chunk树分配给当前Subpage的节点id,即memoryMap索引
    this.memoryMapIdx = memoryMapIdx;
    // 实际Subpage分得16MB内存的偏移量
    this.runOffset = runOffset;
    // 页大小 8KB 也是分得区间长度
    this.pageSize = pageSize;
    // 按照8192>>>10计算,bitmap只有8位长度
    bitmap = new long[pageSize >>> 10];
    // 初始化
    init(head, elemSize);
}

上面的代码其他都好理解,为什么bitmap只给8个元素的长度?考虑内存规格最小只到16B,8K = 512个16B,所以最多需要512个二进制位,也就是8个long。

下面是init方法,主要计算Subpage负责内存规格(elemSize),最大内存块个数(maxNumElems),下一个可用位图索引(nextAvail),位图数组使用长度(bitmapLength),最后将当前PoolSubpage挂到Arena对应规格池的头节点上(这一步暂时不细讲,方法是简单的一个链表插入)。

void init(PoolSubpage<T> head, int elemSize) {
    doNotDestroy = true;
    // 内存块大小 标准化
    this.elemSize = elemSize;
    if (elemSize != 0) {
        // 最大内存块个数 = 剩余可分配内存块个数 = 8K / 内存块大小
        maxNumElems = numAvail = pageSize / elemSize;
        // 初始化下一个可用的位图索引
        nextAvail = 0;
        // 实际使用位图数组长度 = 最大内存块个数 / 2^6
        bitmapLength = maxNumElems >>> 6;
        // 如果最大内存块个数小于64 上面计算出来是0 所以要加1
        if ((maxNumElems & 63) != 0) {
            bitmapLength ++;
        }
        // 初始化位图数组元素为0 即maxNumElems个二进制位初始化为0
        for (int i = 0; i < bitmapLength; i ++) {
            bitmap[i] = 0;
        }
    }
    // 将当前Subpage挂载到Arena对应规格池的头节点上
    addToPool(head);
}
// 把当前SubPage加入链表,且放在头节点之后(相当于第一个,头是空节点)
private void addToPool(PoolSubpage<T> head) {
    assert prev == null && next == null;
    prev = head;
    next = head.next;
    next.prev = this;
    head.next = this;
}

分配内存地址的最后一步,是分配Subpage位图索引并更新位图,构造handle返回。见allocate方法。

long allocate() {
    // 获取下一个可用的位图索引,第一次创建Subpage时是0
    final int bitmapIdx = getNextAvail();
    // 计算处于位图数组的下标
    int q = bitmapIdx >>> 6;
    // 计算本次分配位图索引 处于 位图数组下标为q的元素的二进制位
    int r = bitmapIdx & 63;
    assert (bitmap[q] >>> r & 1) == 0;
    // 位图索引从0更新为1
    bitmap[q] |= 1L << r; // r位置二进制位记为1
    // 可分配内存块数量减一
    if (-- numAvail == 0) {
        // 如果Subpage已经全部分配完了,需要从Arena对应规格池中移走
        removeFromPool();
    }
    // 计算handle
    return toHandle(bitmapIdx);
}

首先重点关注getNextAvail方法,这个方法为本次内存分配请求找到位图索引。这里根据nextAvail值是否大于等于0分为两种逻辑。

  • 大于等于0,使用nextAvail直接返回,并将nextAvail置为-1。这里分为两种情况,第一种是nextAvail=0,这是Subpage刚创建的时候,分配位图索引一定是0;第二种是当有内存被释放归还给Subpage时,nextAvail会置为对应的位图索引,这个时候判断大于0直接可以将这个被释放的位图索引分配出去。
  • 小于0,即-1。这个时候Subpage没有可以快速分配的位图索引,需要遍历bitmap数组,再遍历bitmap数组元素的二进制位,找到为0的二进制位,返回给客户端。这部分代码就不贴了,可以认为就是两重循环找为0的二进制位。
private int getNextAvail() {
    int nextAvail = this.nextAvail;
    // 如果下一个可用索引大于等于0,取这个值,并将nextAvail置为-1
    if (nextAvail >= 0) {
        this.nextAvail = -1;
        return nextAvail;
    }
    // 否则需要遍历位图找到空闲二进制位分配
    return findNextAvail();
}

获得bitmapIdx后,更新bitmap对应二进制位为1,更新可分配内存块数量减一,如果可分配内存块等于0需要从Arena对应规格池中移出。最后是计算handle值

private long toHandle(int bitmapIdx) {
  // 100000000000000000000000000000000000000000000000000000000000000 --- 0x4000000000000000L
  // 000000000000000000000000000000000000000000000000000000000000000 --- bitmapIdx << 32 当占用id=2048时 bitmapIdx = 0
  // 000000000000000000000000000000000000000000000000000100000000000 --- memoryMapIdx 当占用id=2048时 memoryMapIdx = 2048
  return 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
}

由于Java方法不能返回多个出参,这里也不想封装对象(减少new对象次数,即便可以使用对象池或缓存实例),这里选择将Chunk和Subpage分配的偏移量信息封装为一个long返回。高32位是Subpage分配的位图索引,低32位是Chunk分配的节点id(2048-4095)。

为什么要将64位二进制位的最前面一位符号位标志为1?

首先bitmapIdx和memoryMapIdx实际只占用了31位,因为第32位是符号位,这不会影响数据的准确性。

其次,无论分配8K内存(allocateRun)还是分配小于8K内存(allocateSubpage),对外部只能返回一个handle值,有一种情况,外部无法分辨这是Subpage分配的小规格内存还是Chunk直接分配的Page大小内存。

A)分配8K规格内存,memoryMap分配了2048节点,此时memoryMapIdx=0,8K规格内存不需要Subpage参与所以bitmapIdx=0,此时handle为0。

B)分配32B规格内存,memoryMap分配也是2048节点,此时memoryMapIdx=0,由于32B是Subpage分配的第一个内存块,所以bitmapIdx也是0,此时handle也是0。

在A、B两种场景下,返回外部的handle都是0,也就无法区分是Page级别的内存分配还是Subpage级别的内存分配。

案例:分配32B、32B、496B内存。

@Test
public void test00() {
    PooledByteBufAllocator allocator = (PooledByteBufAllocator) ByteBufAllocator.DEFAULT;
    ByteBuf byteBuf = allocator.newDirectBuffer(32, Integer.MAX_VALUE);
    ByteBuf byteBuf2 = allocator.newDirectBuffer(32, Integer.MAX_VALUE);
    ByteBuf byteBufx = allocator.newDirectBuffer(496, Integer.MAX_VALUE);
}

第一步,分配32B。

首先看Chunk部分,它会分配id=2048节点,然后创建一个PoolSubpage,加入到Arena对应规格池中,与规格池中节点形成双向链表,每次加入都是在头节点之后。 再看Subpage部分,它会分配位图索引bitmapIdx=1(bitmap第一个元素,二进制位第1位)给这个PooledByteBuf,并将对应位图索引更新为1。

第二步,再分配32B。

这里超出了本篇文章的源码范畴,用户是从Arena对应的Tiny池中,找到32B规格的Subpage头节点,直接找到上面刚加入链表的Subpage。(这里没考虑线程缓存,这个后续再说,只是搞清Subpage加入Arena规格池的作用)

快速找到Subpage后,还是分配位图索引,这次位图索引bitmapIdx=2(bitmap第一个元素,二进制位第2位),然后更新对应位图索引为1。

第三步,再分配496B。

由于从来没分配过496B规格的内存,这里Chunk需要先分配8KB创建一个Subpage,节点id=2049。然后与规格池中496B规格形成双向链表。

Subpage这次只需要使用bitmap数组的一个元素即可满足需求,计算公式如下。分配位图索引bitmapIdx=1(bitmap第一个元素,二进制位第1位)。可以看到Netty并不是没有内存碎片,由于8192除496除不尽,这里会存在内存碎片

2、初始化Buffer

与Page级别初始化Buffer的不同点是,Subpage初始化Buffer计算偏移量和长度的逻辑不相同。

void initBuf(PooledByteBuf<T> buf, ByteBuffer nioBuffer, long handle, int reqCapacity,
             PoolThreadCache threadCache) {
    // handle低32位 代表memoryMap下标
    int memoryMapIdx = memoryMapIdx(handle);
    // handle高32位 对于subpage,位图下标;对于page,这里是0
    int bitmapIdx = bitmapIdx(handle);
    if (bitmapIdx == 0) {
    	// ... 省略
    } else {
        // 分配subpage 走这
        initBufWithSubpage(buf, nioBuffer, handle, bitmapIdx, reqCapacity, threadCache);
    }
}

private void initBufWithSubpage(PooledByteBuf<T> buf, ByteBuffer nioBuffer,
                                long handle, int bitmapIdx, int reqCapacity, PoolThreadCache threadCache) {
	// 根据handle计算得Chunk树下标
    int memoryMapIdx = memoryMapIdx(handle);
	// 根据Chunk树下标映射到subpages数组对应Subpage元素
    PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
	// 初始化PooledByteBuf
    buf.init(
        this, nioBuffer, handle,
        runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize + offset,
            reqCapacity, subpage.elemSize, threadCache);
}

可以看到offset传入的逻辑是runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize + offset,这就是在Page级别的基础上加上了位图索引代表的偏移量。另外分配内存区间的长度就等于Subpage管理的规格大小elemSize。

四、归还内存

PoolChunk释放内存.png

PoolChunk的free方法,是内存归还给Chunk或Subpage的入口,调用链是Arena->PoolChunkList->PoolChunk。

void free(long handle, ByteBuffer nioBuffer) {
    // handle低32位是memoryMap的下标
    int memoryMapIdx = memoryMapIdx(handle);
    // handle高32位是subpage的位图索引
    int bitmapIdx = bitmapIdx(handle);
    // 位图索引不为0,表示是Subpage分配出去的内存,归还给Subpage
    if (bitmapIdx != 0) {
        PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
        PoolSubpage<T> head = arena.findSubpagePoolHead(subpage.elemSize);
        synchronized (head) {
            // 返回true,表示subpage还在使用,可以不用整体回收subpage给trunk
            // 返回false,表示subpage已经从Arena的subpage池中回收了,不会再被使用,那么需要将当前memoryMapIdx回收给trunk
            if (subpage.free(head, bitmapIdx & 0x3FFFFFFF)) {
                return;
            }
        }
    }

    // 如果位图索引为0 或 归还给subpage失败(subpage已经不再使用被回收了)则 归还给Chunk的memoryMap

    // 增加可用分配字节
    freeBytes += runLength(memoryMapIdx);
    // 设置memoryMap[memoryMapIdx] = 原始值 = depth[memoryMapIdx]
    setValue(memoryMapIdx, depth(memoryMapIdx));
    // 自下而上更新memoryMap[memoryMapIdx]之上的节点
    updateParentsFree(memoryMapIdx);
    // 把ByteBuffer缓存起来,以备下次使用,下次只要重置其中的index即可使用,减少频繁new对象带来的GC
    if (nioBuffer != null && cachedNioBuffers != null &&
        cachedNioBuffers.size() < PooledByteBufAllocator.DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
        cachedNioBuffers.offer(nioBuffer);
    }
}

Subpage的free方法,将内存归还给Subpage。注意这里返回的布尔值,true表示成功归还给Subpage,false表示Subpage不再使用并且已经被回收,需要外部将8K内存块归还给Chunk

Subpage不再使用的评判标准是:

  • numAvail == maxNumElems:可分配内存块数量不等于最大可分配内存块数量,表示有内存块正在被外部使用。
  • prev != next:由于PoolSubpage被维护在Arena的Subpage池中,每个规格的Subpage组成链表,如果当前subpage实例的prev等于next,代表链表中只有空头节点和当前subpage,这个时候subpage不能被移除(removeFromPool)。相反,如果prev不等于next,代表当前subpage不是链表中的唯一节点,在满足numAvail == maxNumElems的条件下,可以被移除(removeFromPool)。
boolean free(PoolSubpage<T> head, int bitmapIdx) {
    if (elemSize == 0) {
        return true;
    }
    // 位图二进制位记为0 表示内存块可以重新分配
    int q = bitmapIdx >>> 6;
    int r = bitmapIdx & 63;
    bitmap[q] ^= 1L << r;
	// 把下一个可选bitmapIdx放到成员变量里,下一次getNextAvail直接返回
    setNextAvail(bitmapIdx); 
    // 如果之前可分配内存块为0,加入subpage池,返回true
    if (numAvail ++ == 0) {
        addToPool(head);
        return true;
    }
    // 如果可分配内存块小于总内存块,直接返回true 表示正在使用subpage
    if (numAvail != maxNumElems) {
        return true;
    } else {
        // 虽然可分配内存块等于总内存块,但是如果当前subpage是subpage池子里的唯一节点时,不会从池中移除
        if (prev == next) {
            return true;
        }

        // 当前subpage不是subpage池中的唯一节点,而且没有人使用这里的内存块,那么将被从subpage池中移除,返回false代表subpage不再被使用
        doNotDestroy = false;
        removeFromPool();
        return false;
    }
}

总结

  • 在进入Chunk内存分配前,Arena会对用户申请的内存大小进行标准化。PoolArena#normalizeCapacity负责内存大小标准化。
    • 大于等于16MB,直接返回。
    • 大于等于512字节,向上取2的n次幂,对应Small、Page规格内存块。
    • 小于512字节,向上取16的倍数,对应所有Tiny规格内存块和512字节Small内存块。为什么是16的倍数?
  • Chunk内存分配根据标准化后的申请内存大小分为两种策略,一种是Page级别8K及以上,另一种是Subpage级别8K以下。两种策略的分配流程都是一样的,先分配内存偏移量和长度确定处于16MB内存块的范围区间,然后执行PooledByteBuf的初始化工作。
  • Page级别:Chunk树自上而下找到满足需求的节点id,执行PooledByteBuf的初始化。
  • Subpage级别:从Chunk树的叶子节点中找到空闲的节点id,得到8K区间,创建Subpage。Subpage再次划分8K内存区间为更小的区间,区间长度为Subpage所管理的内存规格大小,通过位图保存每个内存区间的偏移量。最后执行PooledByteBuf的初始化。