netty5学习笔记-内存池1-PoolChunk

从netty 4开始,netty加入了内存池管理,采用内存池管理比普通的new ByteBuf性能提高了数(2)十倍。相信有些朋友会和我一样,对他的实现方式很感兴趣。这里把学习netty内存池的过程记录下来,与大家一起分享。

首先给大家介绍的是PoolChunk, 该类主要负责内存块的分配与回收,首先来看看两个重要的术语:

page: 可以分配的最小的内存块单位。

chunk: 一堆page的集合。

下面我们用一张图直观的看下PoolChunk是如何管理内存的:

netty5学习笔记-内存池1-PoolChunk_第1张图片

上图中是一个默认大小的chunk, 由2048个page组成了一个chunk,一个page的大小为8192, chunk之上有11层节点,最后一层节点数与page数量相等。每次内存分配需要保证内存的连续性,这样才能简单的操作分配到的内存,因此这里构造了一颗完整的平衡二叉树,所有子节点的管理的内存也属于其父节点。如果我们想获取一个8K的内存,则只需在第11层找一个可用节点即可,而如果我们需要16K的数据,则需要在第10层找一个可用节点。如果一个节点存在一个已经被分配的子节点,则该节点不能被分配,例如我们需要16K内存,这个时候id为2048的节点已经被分配,id为2049的节点未分配,就不能直接分配1024这个节点,因为这个节点下的内存只有8K了。

通过上面这个树结构,我们可以看到每次内存分配都是8K*(2^n), 比如需要24K内存时,实际上会申请到一块32K的内存。为了分配一个大小为chunkSize/(2^k)的内存段,需要在深度为k的层从左开始查找可用节点。如想分配16K的内存,chunkSize = 16M, 则k=10, 需要从第10层找一个空闲的节点分配内存。

如何高效的从这么多page中分配到指定的内存呢。来看看下面这个图:

这个图与上图结构一致,不同的是上方的二叉树的值为当前的层数,两张图和起来用一个数组memoryMap表示,上面的图中的数字表示数组的index,下面的图中的数字表示当前节点及其子节点可以分配的层的高度。如对于id=512的节点,其深度为9,则:

1)memoryMap[512] = 9,则表示其本身到下面所有的子节点都可以被分配;

2)memoryMap[512] = val (从10到11), 则表示512节点下有子节点已经分配过,则该节点不能直接被分配,而其子节点中的第val和val以下层还存在未分配的节点;

3)memoryMap[512] = 12 (即总层数 + 1), 可分配的深度已经大于总层数, 则该节点下的所有子节点都已经被分配。

下面我们在从源码分析下PoolChunk是如何实现的,首先看看它的构造方法:

 

PoolChunk(PoolArena<T> arena, T memory, int pageSize, int maxOrder, int pageShifts, int chunkSize) {
    unpooled = false;
    this.arena = arena;
    // memory是一个容量为chunkSize的byte[](heap方式)或ByteBuffer(direct方式)
    this.memory = memory;
    // 每个page的大小,默认为8192
    this.pageSize = pageSize;
        // 13,   2 ^ 13 = 8192
   this.pageShifts = pageShifts;
    // 默认11
    this.maxOrder = maxOrder;
    // 默认 8192 << 11 = 16MB
    this.chunkSize = chunkSize;
    // 12, 当memoryMap[id] = unusable时,则表示id节点已被分配
    unusable = (byte) (maxOrder + 1);
    // 24, 2 ^ 24 = 16M
    log2ChunkSize = log2(chunkSize);
    // -8192
    subpageOverflowMask = ~(pageSize - 1);
    freeBytes = chunkSize;

    assert maxOrder < 30 : "maxOrder should be < 30, but is: " + maxOrder;
    // 2048, 最多能被分配的Subpage个数
    maxSubpageAllocs = 1 << maxOrder;

    // Generate the memory map.
    memoryMap = new byte[maxSubpageAllocs << 1];
    depthMap = new byte[memoryMap.length];
    int memoryMapIndex = 1;
    // 分配完成后,memoryMap->[0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3…]
    // depthMap->[0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3…]
    for (int d = 0; d <= maxOrder; ++ d) { // move down the tree one level at a time
      int depth = 1 << d;
      for (int p = 0; p < depth; ++ p) {
        // in each level traverse left to right and set value to the depth of subtree
        memoryMap[memoryMapIndex] = (byte) d;
        depthMap[memoryMapIndex] = (byte) d;
        memoryMapIndex ++;
      }
    }

        // subpages包含了maxSubpageAllocs(2048)个PoolSubpage, 每个subpage会从chunk中分配到自己的内存段,两个subpage不会操作相同的段,此处只是初始化一个数组,还没有实际的实例化各个元素
    subpages = newSubpageArray(maxSubpageAllocs);
  }

需要注意的是由于memoryMap表示的树中一个包含2 ^ maxOrde - 1个节点,因此memoryMap中的其中一个节点是无用的,为了方便后续的计算,这里将第一个节点作为无用的节点,这样从父节点计算左子结点只需要简单的*2即可,即left node id = parent id << 1。随着内存不断的分配和回收,memoryMap中的值也不停的更新,而depthMap中保存的值表示各个id对应的深度,是个固定值,初始化后不再变更。

下面看看如何向PoolChunk申请一段内存:

 

// normCapacity已经处理过
long allocate(int normCapacity) {
    if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
        // 大于等于pageSize时返回的是可分配normCapacity的节点的id
      return allocateRun(normCapacity);
    } else {
        // 小于pageSize时返回的是一个特殊的long型handle
        // handle = 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
        // 与上面直接返回节点id的逻辑对比可以知道,当handle<Integer.MAX_VALUE时,它表示chunk的节点id;
        // 当handle>Integer.MAX_VALUE,他分配的是一个Subpage,节点id=memoryMapIdx, 且能得到Subpage的bitmapIdx,bitmapIdx后面会讲其用处
      return allocateSubpage(normCapacity);
    }
  }

allocateRun与allocateSubpage都需要查询到一个可用的id, allocateSubpage相对allocateRun多出分配PoolSubpage的步骤,下面先看看allocateRun的实现:

 

 

private long allocateRun(int normCapacity) {
	// log2(val) -> Integer.SIZE - 1 - Integer.numberOfLeadingZeros(val)
	// 如normCapacity=8192,log2(8192)=13,d=11
        int d = maxOrder - (log2(normCapacity) - pageShifts);
        // 通过上面一行算出对应大小需要从那一层分配,得到d后,调用allocateNode来获取对应id
        int id = allocateNode(d);
        if (id < 0) {
            return id;
        }
        // runLenth=id所在深度占用的字节数
        freeBytes -= runLength(id);
        return id;
    }


allocateNode(int d)传入的参数为depth, 通过depth来搜索对应层级第一个可用的node id。下面看allocateNode的实现:

 

 

private int allocateNode(int d) {
        int id = 1;
        // 如d=11,则initial=-2048
        int initial = - (1 << d); // has last d bits = 0 and rest all = 1
        // value(id)=memoryMap[id]
        byte val = value(id);
        // 第一层的节点的值大于d,表示d层及以上都不可分配,此次分配失败
        if (val > d) { // unusable
            return -1;
        }
        // 这里从第二层开始从上往下查找, 一直找到指定层
        while (val < d || (id & initial) == 0) { // id & initial == 1 << d for all ids at depth d, for < d it is 0
            // 往下一层
            id <<= 1;
            val = value(id);
            // 上面对一层节点的值判断已经表示有可用内存可分配,因此发现当前节点不可分配时,
            // 直接切换到父节点的另一子节点,即id ^= 1
            if (val > d) {
                id ^= 1;
                val = value(id);
            }
        }
        byte value = value(id);
        assert value == d && (id & initial) == 1 << d : String.format("val = %d, id & initial = %d, d = %d",
                value, id & initial, d);
        // 该节点本次分配成功,将其标为不可用
        setValue(id, unusable); // mark as unusable
        // 更新父节点的值
        updateParentsAlloc(id);
        return id;
    }

上面有几个需要注意的点:1、分配的节点在d层;2、每一层都可能遇到节点被分配的情况,此时需要切换到父节点的另一个子节点继续往下查找;3、分配的节点需要标记为不可用,防止后面再被分配;4、分配一个节点后,其父节点的值会发生变更,并可能引起更上层的父节点的变更。下面我们来举个例子看看第4点(depth=10节点的子节点从2个可用变为1个可用的情况):

 

1)parent的depth=10,表示该层及所有子层可直接分配;

2)left节点被分配,其depth=12, 不能再被分配;

3)由于left节点已经被分配,此时parent节点已经不能直接分配,但还存在一个可分配节点right,此时parent的depth被设置为right的值11;

4)parent不可分配,则其以上的所有父节点的状态可能也会发生变化,此时需要递归的修改更上层parent的值,及指针上移后重复上面的操作。

 

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 = parentId;
        }
    }

 

 

到这里一个节点的分配就完成了,但是还有另一种情况,即分配的ByteBuf大小小于pageSize, 这种情况会调用allocateSubpage方法:

 

private long allocateSubpage(int normCapacity) {
        int d = maxOrder; // subpages are only be allocated from pages i.e., leaves
        int id = allocateNode(d);
        if (id < 0) {
            return id;
        }

        final PoolSubpage<T>[] subpages = this.subpages;
        final int pageSize = this.pageSize;

        freeBytes -= pageSize;
	// 包含数据的节点在最后一层,而最后一层的左边第一个节点index=2048,因此若id=2048则subpageIdx=0,id=2049,subpageIdx=1
	// 根据这个规律找到对应位置的PoolSubpage
        int subpageIdx = subpageIdx(id);
        PoolSubpage<T> subpage = subpages[subpageIdx];
        if (subpage == null) {
            // 如果PoolSubpage未创建则创建一个,创建时传入当前id对应的offset,pageSize,本次分配的大小normCapacity
            subpage = new PoolSubpage<T>(this, id, runOffset(id), pageSize, normCapacity);
            subpages[subpageIdx] = subpage;
        } else {
            // 已经创建则初始化数据
            subpage.<span style="color:#009900;">init</span>(normCapacity);
        }
        // 调用此方法得到一个可以操作的handle
        return subpage.<span style="color:#009900;">allocate</span>();
    }

这里可以看到,在小size的对象分配时会先分配一个PoolSubpage,最终返回一个包含该PoolSubpage信息的handle,后面的操作也是通过此handle进行操作。在看如何操作内存之前,我们先来看看分配一个节点后如何释放(比较简单,所以先讲这里),其对应的方法:

 

void free(long handle) {
	// 传入的handle即allocate得到的handle
        int memoryMapIdx = (int) handle;
        int bitmapIdx = (int) (handle >>> Integer.SIZE);

        if (bitmapIdx != 0) { // free a subpage
            // !=0表示分配的是一个subpage
            PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
            assert subpage != null && subpage.doNotDestroy;
            if (subpage.free(bitmapIdx & 0x3FFFFFFF)) {//subpage释放方法后面再讲
                return;
            }
        }
        freeBytes += runLength(memoryMapIdx);
        // 将节点的值改为可用,及其depth
        setValue(memoryMapIdx, depth(memoryMapIdx));
        // 修改其父节点的值
        updateParentsFree(memoryMapIdx);
    }

这里会将节点的值改为可用,完成后该节点就可以再次被分配了,此时父节点的值还未更新,可能会导致分配节点时无法访问到此节点,因此还需要同时改变其父节点的值,

 

 

 private void updateParentsFree(int id) {
        int logChild = depth(id) + 1;
        while (id > 1) {
            int parentId = id >>> 1;
            byte val1 = value(id);
            byte val2 = value(id ^ 1);
            logChild -= 1; // in first iteration equals log, subsequently reduce 1 from logChild as we traverse up

            if (val1 == logChild && val2 == logChild) {
            	//当两个子节点都可分配时,该节点变回自己所在层的depth,表示该节点也可被分配
                setValue(parentId, (byte) (logChild - 1));
            } else {
            	// 否则与上面的updateParentsAlloc逻辑相同
                byte val = val1 < val2 ? val1 : val2;
                setValue(parentId, val);
            }

            id = parentId;
        }
    }

PoolChunk本身主要是负责节点的分配与释放,因此节点的分配与释放都了解了对这个类的了解就已经差不多了。但这里还有几个方法没讲到,这几个方法是干什么用的呢,下面我们来看看:

    void initBuf(PooledByteBuf<T> buf, long handle, int reqCapacity) {
        int memoryMapIdx = (int) handle;
        int bitmapIdx = (int) (handle >>> Integer.SIZE);
        if (bitmapIdx == 0) {
            // 到这里表示分配的是>=pageSize的数据
           byte val = value(memoryMapIdx);
            assert val == unusable : String.valueOf(val);
           buf.init(this, handle, runOffset(memoryMapIdx), reqCapacity, runLength(memoryMapIdx));
        } else {
            // 到这里表示分配的是<pageSize的数据
           initBufWithSubpage(buf, handle, bitmapIdx, reqCapacity);
        }
    }

    void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int reqCapacity) {
 initBufWithSubpage(buf, handle, (int) (handle >>> Integer.SIZE), reqCapacity);
 }

 private void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int bitmapIdx, int reqCapacity) {
 assert bitmapIdx != 0;

 int memoryMapIdx = (int) handle;

 PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
 assert subpage.doNotDestroy;
 assert reqCapacity <= subpage.elemSize;

 buf.init(
 this, handle,
 runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize, reqCapacity, subpage.elemSize);
 }

通过allocate拿到了handle,相当于得到了一把钥匙,现在就可以拿这把钥匙开启后面的操作了。首先我们拿handle+buf+reqCapacity几个参数来进行实际的分配,前面讲过handle有两种含义,1、handle<Integer.MAX_VALUE, 表示一个node id; 2、handle>Integer.MAX_VALUE, 则里面包含node id + 对应的subpage的bitmapIdx。最终传递给PooledByteBuf的信息包括,PoolChunk中的memory: 完整的byte数组或ByteBuf;handle:表示node id的数值,释放内存段的时候使用这个参数; offset:该PooledByteBuf可操作memory的第一个位置; length:该PooledByteBuf本次申请的长度;maxLength:该PooledByteBuf最大可用的长度。PooledByteBuf通过这些参数完成自身的初始化后,就可以开始实际的读写了,它的可读写区域就是memory数组的offset 到 offset + length, 如果写入的数据超过length, 可以扩容至maxLength, 即可读写的区域变为offset 到 offset + maxLength, 如果超过maxLength, 则需要重新申请数据块了,这个后面再说。

到这里PoolChunk的功能基本上已经讲完,下面来回顾下PoolChunk的简单使用过程(真实的使用比较复杂,后面再讲):

1) 初始化PoolChunk,初始时传入分配好的内存块memory(heap或direct),通过maxOrder构造完整的平衡二叉树,并且将对应值存入memoryMap;
2) 应用获取一个大小为size0的内存段,通过调用PoolChunk.allocate方法获取到对应的二叉树节点id并转换为handle,
   如果size0 >= 8192, handle=id,如果size0 < 8192, handle = 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
   获取完成后更新memoryMap, 保证该节点不会重复分配,同时其父节点的可分配状况也级联的改变。
3) 通过上一步获取到的handle来初始化应用的ByteBuf,调用的方法为PoolChunk.initBuf,ByteBuf会接收memory, handle, offset, length, maxLengh;
4) 应用对ByteBuf进行写入读取等操作(可能会触发扩容等操作,后面的文章会深入讲;
5) 应用使用完ByteBuf后释放它,ByteBuf释放时调用PoolChunk.free,传入的参数为handle,PoolChunk将handle对应的节点置为可分配,同时改变其父节点的状态。
   该节点可以再次分配了(其父节点也有可能被分配了)。
6)下次应用获取内存段时再次进入第二步。

需要注意的是PoolChunk提供了内存池的功能,然后它的所有方法都是线程不安全的,因此需要应用保证其可以被安全的访问,否则会导致节点被重复分配等问题;ByteBuf的设计使内存不使用后不需要手动的将数据置0,直接重置节点状态即可;申请小内存后会通过PoolSubpage进行管理,为什么这么做,下一篇再慢慢讲,回家先。

 

 

 

 

 

 

 

 

你可能感兴趣的:(源码,内存池,netty5,PoolChunk)