Netty内存管理深度解析(上)

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/TheLudlows/article/details/86264785

概述

上篇文章中对Netty的内存分配进行大体的阐述,提到了Netty内存分配通过Arena来进行,并且配合对象池化技术(Recycle)来提高效率。关于池化技术在后面的文章中做详细的介绍。在本篇文章中主要分析内存分配涉及的数据结构。先看经典的Jemlloc结构图:
netty
涉及的数据结构在Netty中的实现为:

  • PoolChunk
  • PoolSubPage
  • PoolChunkList
  • PoolArena
    下面逐一分析源码的实现细节。

1. PoolChunk

Netty一次向系统申请16M的连续内存空间,这块内存通过PoolChunk对象包装,为了更细粒度的管理(分配和释放)它,进一步的把这16M内存分成了2048个页(pageSize=8k)。页作为Netty内存管理的最基本的单位 ,通过一棵平衡二叉树(memoryMap)将每一个page联系起来,所有子节点管理的内存也属于其父节点。采用的是Buddy分配算法。

1.1 Buddy算法

Netty实现的伙伴分配算法中,构造了两棵满二叉树,满二叉树非常适合使用数组存储,Netty使用两个字节数组memoryMap和depthMap来表示两棵二叉树,其中MemoryMap存放分配信息,depthMap存放节点的高度信息。
netty
两个二叉树经过初始化后变为一样,都是当前层高度,depthMap不在改变,每次分配内存只修改memoryMap用来记录分配信息。当一个节点被分配以后,该节点的值设置为12(最大高度+1)表示不可用,并且会更父节点的值,直至根节点。如下例子:
netty
如上图,每一个节点中有三个数字,第一个表示编号,即数组的下标,注意下标是从1开始,第二个表示对应节点memoryMap的值,第三个表示对应节点depthMap的值。

分配过程如下:

  1. 从根节点开始寻找合适的节点
  2. 假如4号节点被完全分配,将高度值设置为12表示不可用。
  3. 4号节点的父亲节点即2号节点,将高度值更新为两个子节点的较小值;其他祖先节点亦然,直到高度值更新至根节点。

memoryMap数组的值有如下三种情况:

  • memoryMap[id] = depthMap[id] :该节点没有被分配
  • memoryMap[id] > depthMap[id] : 至少有一个子节点被分配,不能再分配该高度满足的内存,但可以根据实际分配较小一些的内存。比如,上图中分配了4号子节点的2号节点,值从1更新为2,表示该节点不能再分配8MB的只能最大分配4MB内存,因为分配了4号节点后只剩下5号节点可用。
  • mempryMap[id] = 最大高度 + 1(12): 该节点及其子节点已被完全分配, 没有剩余空间。
    ok清楚了Buyddy算法的主要逻辑,PoolChunk代码看起来就轻松多了~
1.2 PoolChunk初始化

该类有两个构造方法,一个用于普通初始化,另一个用于非池化初始化(Huge分配请求)。

 PoolChunk(PoolArena<T> arena, T memory, int pageSize, int maxOrder, int pageShifts, int chunkSize, int offset) {
    unpooled = false;
    this.arena = arena;// 表示该PoolChunk所属的PoolArena。
    this.memory = memory;// 具体用来表示内存;byte[]或java.nio.ByteBuffer。
    this.pageSize = pageSize;//每个page的大小,默认为8192个字节(8K)
    this.pageShifts = pageShifts;// 从1开始左移到页大小的位置,默认13,1<<13 = 8192
    this.maxOrder = maxOrder;// 最大高度,默认11
    this.chunkSize = chunkSize; // chunk块大小,默认16MB
    this.offset = offset;// 暂时没用用到,初始值为0
    unusable = (byte) (maxOrder + 1);// 不可用的二叉树深度12
    log2ChunkSize = log2(chunkSize);// log2(16MB) = 24
    subpageOverflowMask = ~(pageSize - 1); // 判断分配请求为Tiny/Small即分配subpage
    freeBytes = chunkSize;// 可分配字节数
    maxSubpageAllocs = 1 << maxOrder; // 可分配subpage的最大节点数即11层节点数,默认2048

    // 构造两棵二叉树
    memoryMap = new byte[maxSubpageAllocs << 1];
    depthMap = new byte[memoryMap.length];
    int memoryMapIndex = 1; // 注意下标是 1 开始,下标为0的不使用
    for (int d = 0; d <= maxOrder; ++ d) { // 遍历每层,
        int depth = 1 << d; // depth是每层的节点数
        for (int p = 0; p < depth; ++ p) { // 为每一层进行进行初始化,值为当前层数
            memoryMap[memoryMapIndex] = (byte) d;
            depthMap[memoryMapIndex] = (byte) d;
            memoryMapIndex ++;
        }
    }
    // 创建PoolSubpage数组,只有分配小于pageSize的内存才能用到该数组
    subpages = newSubpageArray(maxSubpageAllocs);
}
1.3 分配

下面看看如何向PoolChunk申请一块内存区域,allocate函数的代码如下;

long allocate(int normCapacity) {
    if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
        return allocateRun(normCapacity);
    } else {
        return allocateSubpage(normCapacity);
    }
}
  • 当需要分配的内存大于等于pageSize时,通过调用allocateRun函数实现内存分配
  • 当需要分配的内存小于pageSize时,通过调用allocateSubpage函数实现内存分配
    每个Page会被切分成大小相同的多个存储块,存储块的大小由第一次申请的内存块大小决定。第一次申请的时1K,则这个Page就会被分成8个存储块。多个存储块通过链表连接起来。
private long allocateRun(int normCapacity) {
    // 计算满足需求的节点的高度
    int d = maxOrder - (log2(normCapacity) - pageShifts);
     // 在该高度层找到空闲的节点
    int id = allocateNode(d);
    if (id < 0) {
        return id;
    }
    // 分配后剩余的字节数
    freeBytes -= runLength(id);
    return id;
}

比如申请16kb的内存大小,log2(1024*16) = 14,pageShifts=13, 最终d = 10,因此就在第10层寻找。继续跟进allocateNode的计算,入参是深度d。

private int allocateNode(int d) {
    int id = 1; // 初始的节点编号即下标,值为0
    int initial = - (1 << d); // d层第一个节点负数,用于判断是都处于d层
    byte val = value(id); // memoryMap[id]
    if (val > d) { // 节点的值(深度)大于d,则表示不可用
        return -1;
    }
    while (val < d || (id & initial) == 0) { val<d 子节点可分配内存,id & initial == 0 高度<d
        id <<= 1; // 进入下一层,id为下一层的左子节点下标
        val = value(id); // 取到左子节点的值
        if (val > d) { // 如果左子节点被占用
            id ^= 1; // 位异或运算,偶数n与1异或结果为n+1,目的是找到兄弟节点得编号
            val = value(id); // 取到兄弟节点值
        }
    }
    byte value = value(id);
    setValue(id, unusable); // 并标记为不可用,即赋值当前节点的值为12
    updateParentsAlloc(id); // 跟新父节点的值
    return id;
}

算法用到了大量的位运算,举个例子,比如第一次分配4M的内存,因此如入参d为2,id为1,深度(val)等于0,进入循环

$loop 1
val = 0,id = 1, initial = -4  initial & id=0
id = 2, val = 1, d = 2 进入#loop 2
$loop 2
val = 1, id = 2, initial = -4  initial & id=0
id = 4, val = 2, d = 2 进入#loop3
$loop 3
val < d 不符合跳出循环

更新祖先节点的分配信息:

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;
        // 比较兄弟节点的val值,将较大的赋值给父节点
        setValue(parentId, val);
        id = parentId;  
    }
}

此过程是对我们前面分析的buddy算法的实现。
分配的内存小于pageSize时,通过调用allocateSubpage函数实现内存分配,这块内容当分析了PoolSubPage之后在进行讲解。

2. PoolSubPage

Netty提供了PoolSubpage把poolChunk的一个page节点8k内存划分成更小的内存段,通过对每个内存段的标记与清理标记进行内存的分配与释放。
一个Page只能用于分配与第一次申请时大小相同的内存,例如,一个8K的Page,如果第一次分配了1K的内存,那么后面这个Page就只能继续分配1K的内存,如果有一个申请2K内存的请求,就需要在一个新的Page中进行分配。

PoolArena中有两个数组专门用来缓存PoolSubpage,结构如下图
netty
在PoolArena中定义如下:

private final PoolSubpage<T>[] tinySubpagePools;
private final PoolSubpage<T>[] smallSubpagePools;

tinySubpagePools来缓存用来分配tiny(小于512Byte)内存的Page;smallSubpagePools来缓存用来分配small(大于等于512Byte且小于pageSize)内存的Page。 至于数组的长度在下文中Arena中介绍,下面看下PoolSubpage的构造方法:

final class PoolSubpage<T> implements PoolSubpageMetric {
    // 用来表示该Page属于哪个Chunk
    final PoolChunk<T> chunk;
    // Page在Chunk.memoryMap中的索引
    private final int memoryMapIdx;
    // 当前Page在chunk.memoryMap的偏移量
    private final int runOffset;
    // Page的大小,默认为8192
    private final int pageSize;
    //通过对每一个二进制位的标记来修改一段内存的占用状态
    private final long[] bitmap;
    // arena双向链表的前驱节点
    PoolSubpage<T> prev;
    // arena双向链表的后继节点
    PoolSubpage<T> next;

    boolean doNotDestroy;
    // 均等切分的大小
    int elemSize;
    // 最多可以切分的小块数
    private int maxNumElems;
    private int bitmapLength; //位图大小,maxNumElems >>> 6,一个long有64bit
    private int nextAvail; //下一个可用的单位
    private int numAvail; //还有多少个可用单位;
}

构造方法有两个,其中一个用于构造双向链表的头节点Head,这是一个特殊节点,相当于一个空节点,下面看下普通节点的构造。

PoolSubpage(PoolSubpage<T> head, PoolChunk<T> chunk, int memoryMapIdx, int runOffset, int pageSize, int elemSize) {
    this.chunk = chunk;
    this.memoryMapIdx = memoryMapIdx;
    this.runOffset = runOffset;
    this.pageSize = pageSize;
    bitmap = new long[pageSize >>> 10]; // pageSize / 16 / 64
    init(head, elemSize);
}

在PoolSubpage的实现中,使用的是字段private final long[] bitmap数组中每一个元素的二进制来记录Page的使用状态,其中bitmap数组的最大长度为:pageSize / 16 / 64 = 8,这里的16指的是块的最小值,64是long类型的所占的bit数。init根据当前需要分配的内存大小,确定需要多少个bitmap元素,实现如下:

void init(PoolSubpage<T> head, int elemSize) {
    doNotDestroy = true;
    this.elemSize = elemSize;
    if (elemSize != 0) {
        maxNumElems = numAvail = pageSize / elemSize; // page切分的个数
        nextAvail = 0;
        bitmapLength = maxNumElems >>> 6; // 切分后需要用多少个long表示
        if ((maxNumElems & 63) != 0) {// 如果块的个数不是64的整倍数,则加 1
            bitmapLength ++; // 比如elemSize为4096,则maxNumElems为2,那么maxNumElems为0,因此需要加一层判断
        }
        // 初始化每个元素
        for (int i = 0; i < bitmapLength; i ++) {
            bitmap[i] = 0;
        }
    }
    addToPool(head);
}

addToPool()方法将该PoolSubpage加入到Arena的双向链表中,代码如下:

private void addToPool(PoolSubpage<T> head) {
    assert prev == null && next == null;
    prev = head;
    next = head.next;
    next.prev = this;
    head.next = this;
}

每次新加入的节点都在Head节点之后。下面分析allocate方法:

long allocate() {
    if (elemSize == 0) return toHandle(0);

    if (numAvail == 0 || !doNotDestroy) return -1;
    // 找到当前page中可分配内存段的bitmapIdx;
    final int bitmapIdx = getNextAvail();
    int q = bitmapIdx >>> 6;
    int r = bitmapIdx & 63;
    assert (bitmap[q] >>> r & 1) == 0;
    bitmap[q] |= 1L << r;

    if (-- numAvail == 0)  removeFromPool();
    return toHandle(bitmapIdx);
}
private long toHandle(int bitmapIdx) {
    return 0x4000000000000000L | (long) bitmapIdx << 32 | memoryMapIdx;
}
  1. getNextAvail()方法来得到此Page中下一个可用“块”的位置bitmapIdx,其实就是编号,比如elemSize为64,8kb被分为128块,则需要2个long类型表示,总共2*64=128位,bitmapIdx就是就是指128块小内存的编号。
    后面分析具体获取bitmapIdx的逻辑。
  2. q = bitmapIdx >>> 6,r = bitmapIdx & 63,确定bitmap数组下标为q的元素第r位来标识、描述 bitmapIdx 内存段的状态,假设bitmapIdx=66,则q=1,r=2,即是用bitmap[1]这个long类型数的第2个bit位来表示此“内存块”的。
  3. 将bitmap[q]这个long型的数的第r 位bit置为1,标识此“块”已经被分配。
  4. 将page的可用“块数”numAvail减一,减一之后如果结果为0,则表示此Page的内存无可分配的了,因此将subpage从Arena所持有的链表中移除。
  5. 转换为64位分配信息,其中低32位表示PoolSubpage所属的Page的标号,高32位表示均等切分小块的坐标,
|<--   24   -->| <--   6      --> | <--         32         --> |
|  long数组偏移 |long的二进制位偏移 |所属page在memoryMapIdx的编号(只能是叶子节点)|

下面来看下getNextAvail()方法是如何得到此Page中下一个可用“块”的位置bitmapIdx的

private int getNextAvail() {
    int nextAvail = this.nextAvail;
    if (nextAvail >= 0) {
        this.nextAvail = -1;
        return nextAvail;
    }
    return findNextAvail();
}

nextAvail在构造函数中被初始化为0,第一次申请可用“块”的时候nextAvail=0,会直接返回。表示直接用第0位内存块,接下来nextAvail为-1,因此调用findNextAvail方法,继续跟进

private int findNextAvail() {
    final long[] bitmap = this.bitmap;
    final int bitmapLength = this.bitmapLength;
    for (int i = 0; i < bitmapLength; i ++) { // 遍历数组每一个long元素
        long bits = bitmap[i];
        if (~bits != 0) { // ~表示取反码,如果不等于0,表示还有些块处于空闲,如果等于0,表示全部被占用
            return findNextAvail0(i, bits);
        }
    }
    return -1;
}

private int findNextAvail0(int i, long bits) {
    final int maxNumElems = this.maxNumElems;
    final int baseVal = i << 6;

    for (int j = 0; j < 64; j ++) { // 遍历long类型元素的64位二进制
        if ((bits & 1) == 0) { // 如果为0,表示空闲,进入if分支
            int val = baseVal | j; // baseVal | j计算的结果就是小块内存的编号
            if (val < maxNumElems) { //判断是否越界,超过maxNumElems(512)
                return val;
            } else {
                break;
            }
        }
        bits >>>= 1;
    }
    return -1;
}

总的来说其实分配小于pagesize的内存是通过按顺序遍历标识数组bitmap中每个long元素中的每一bit位中为0的位置。

3. 分配和释放

在PoolChunk一节中,我们分析了分配不小于PageSize(8Kb)内存的过程,在PoolSubPage一节中我们描述PoolSubPage是如何分配更小的内存块(小于8Kb),本节分析PoolChunk一节遗留的问题。PoolChunk- > PoolSubPage,具体的去认识PoolChunk是如何利用PoolSubPage来分配小内存块的。
allocateSubpage()代码如下:

private long allocateSubpage(int normCapacity) {
    PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
    synchronized (head) {
        int d = maxOrder; // subpages are only be allocated from pages i.e., leaves
        int id = allocateNode(d); // 找到可分配的叶子节点,该方法在分析Chunk的allocate方法是介绍过
        if (id < 0) return id;
        final PoolSubpage<T>[] subpages = this.subpages;
        final int pageSize = this.pageSize;
        // 更新Chunk的可用量
        freeBytes -= pageSize;
        int subpageIdx = subpageIdx(id);
        PoolSubpage<T> subpage = subpages[subpageIdx];
        if (subpage == null) {// 一般情况都为null,构造一个PoolSubpage对象
            subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
            subpages[subpageIdx] = subpage; // 添加到数组中
        } else {// 如果不为空,表示那块内存已被释放,只需初始化,即添加道Arena的双向列表中
            subpage.init(head, normCapacity);
        }
        // 找到具体的某块小内存
        return subpage.allocate();
    }
}

arena中维护了分配的内存小于PageSize,所以分配的节点必然在二叉树的最高层。找到最高层合适的节点后,新建或初始化subpage并加入到chunk的subpages数组,同时将subpage加入到arena的subpage双向链表中,最后完成分配请求的内存。关于Arena的内容会面再说。

当分配好了之后,需要与ByteBuf关联起来,该逻辑的实现也是由PoolChunk负责:

void initBuf(PooledByteBuf<T> buf, long handle, int reqCapacity) {
    int memoryMapIdx = memoryMapIdx(handle);
    int bitmapIdx = bitmapIdx(handle);
    if (bitmapIdx == 0) { // Page级别PooledByteBuf初始化
        byte val = value(memoryMapIdx); // 获取到二叉树的值
        assert val == unusable : String.valueOf(val);
        buf.init(this, handle, runOffset(memoryMapIdx) + offset, reqCapacity, runLength(memoryMapIdx),
                 arena.parent.threadCache());
    } else {// SubPage级别PooledByteBuf初始化
        initBufWithSubpage(buf, handle, bitmapIdx, reqCapacity);
    }
}

runOffset(memoryMapIdx)是计算从chunk头部开始的byte长度,runLength是计算memoryMapIdx节点对应的内存长度。

private int runLength(int id) {
   // log2ChunkSize 为24
   return 1 << log2ChunkSize - depth(id);
}
private int runOffset(int id) {
   // shift 为某个节点左边的同层节点数量
   int shift = id ^ 1 << depth(id);
   return shift * runLength(id);
}

接下来调用了PooledByteBuf的init方法,如果是 SubPage级别调用initBufWithSubpage:

  private void initBufWithSubpage(PooledByteBuf<T> buf, long handle, int bitmapIdx, int reqCapacity) {
      int memoryMapIdx = memoryMapIdx(handle);
      PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
      buf.init(
          this, handle,
          runOffset(memoryMapIdx) + (bitmapIdx & 0x3FFFFFFF) * subpage.elemSize + offset,
              reqCapacity, subpage.elemSize, arena.parent.threadCache());
  }

和 Page级别PooledByteBuf初始化相比,偏移地址offset的计算似乎更加复杂,显示获取到Subpage的偏移量,然后再计算小块内存的额偏移量。

private void init0(PoolChunk<T> chunk, long handle, int offset, int length, int maxLength, PoolThreadCache cache) {
    this.chunk = chunk;
    memory = chunk.memory;
    allocator = chunk.arena.parent;
    this.cache = cache;
    this.handle = handle;
    this.offset = offset;
    this.length = length;
    this.maxLength = maxLength;
    tmpNioBuf = null;
}

init方法内部调用的是init0,这里我们看到将ByteBuf对象与内存偏移量地址,Chunk联系起来。
下面看一下释放的过程:

void free(long handle) {
    int memoryMapIdx = memoryMapIdx(handle);
    int bitmapIdx = bitmapIdx(handle);

    if (bitmapIdx != 0) { // free a subpage
        PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
        PoolSubpage<T> head = arena.findSubpagePoolHead(subpage.elemSize);
        synchronized (head) {
            if (subpage.free(head, bitmapIdx & 0x3FFFFFFF)) {
                return;
            }
        }
    }
    freeBytes += runLength(memoryMapIdx);
    setValue(memoryMapIdx, depth(memoryMapIdx)); // 节点分配信息还原为高度值
    updateParentsFree(memoryMapIdx); // 更新祖先节点的分配信息
}

long handle的低32位保存memoryMapIdx,高32位保存在bitmap的坐标(如果有意要的话),前两行代码取到memoryMapIdx和bitmap的坐标(可能为空)。
如果bitmapIdx不为0,表明此块内存是小于pageSize的内存块,那么一定是在叶子节点上,因此定位到subpage,调用subpage.free

boolean free(PoolSubpage<T> head, int bitmapIdx) {
    int q = bitmapIdx >>> 6;
    int r = bitmapIdx & 63; // q r 为小块内存在bitmap数组中的坐标
    bitmap[q] ^= 1L << r; // 此处的位异或操作是将将对应的位置位0,表示空闲
    setNextAvail(bitmapIdx);// 设置nextAvail为释放的内存
    if (numAvail ++ == 0) { // 此次释放一块小内存,如果释放之前subPage管理的所有小块内存全部被占用,则subPage一定从双向列表中移除
    // 但是此时释放了一块,因此将subpage再次加入到arena双向链表
        addToPool(head);
        return true;
    }
    if (numAvail != maxNumElems) { 
        return true;
    } else {// 可用的内存块和总块数相同,
        if (prev == next) {// 如果双向列表只有head和当前subpage,直接返回
            return true;
        }
        // 如果池中还有其他子页面,请从池中删除此subpage。
        doNotDestroy = false;
        removeFromPool();
        return false;
    }
}

楼主已开始看后半段代码有些困惑,为什么一会加入到双向列表中,一会又从双向列表中删除。这里整理总结一下,列表是Arena用来表示那些subPage有空闲的内存可以分配,并且按照elemSize划分为多个列表,列表的Head用数组管理起来。 当subpage中的内存块全部被用光,在分配的时刻就会从列表中删除,或者当所有的小内存块都是空闲并且列表中还有其他的subpage,那么将此subpage移除。

回到poolChunk.free方法中,如果subpage.free返回true,则直接返回,释放内存结束。如果返回false,则表明释放了一个subpage,相当于释放了一个叶子节点。逻辑和释放大于pagesize的内存是一样的。由于代码逻辑是分配的逆过程,此处不再赘述。

由于篇幅限制,下面的内容见下文:Netty内存管理深度解析(下)

4. PoolChunkList

5. PoolArena

6. 缓存

总结

展开阅读全文

没有更多推荐了,返回首页