8.池化内存分配
netty内存管理思想
java作为一门拥有GC机制的语言,长久以来它的使用者都不必手动管理内存,这比起c/c++是一个巨大的进步。但现在netty却反其道而行之,实现了一套不依赖GC而自行管理内存的机制。
那么netty为什么要这么做?众所周知netty是一个网络通信层框架,涉及到大量IO操作,对于此类操作,DirectByteBuffer无疑比起堆内存有更多性能上的优势。而与此同时,DirectByteBuffer也有自身的不足,那就是它的申请和释放成本更高。如果直接交给JVM管理,频繁的GC将使DirectByteBuffer的优势荡然无存。所以,对DirectByteBuffer进行池化管理,多次重用以减少申请、释放的想法就比较自然了。但是,不同于一般于我们常见的对象池、连接池等池化的案例,ByteBuffer有大小一说,且申请多大的内存进行管理也难以确定,如果大了会浪费,小了会导致频繁扩容和内存复制以及碎片化。因此netty需要在解决高效分配内存的同时又解决内存碎片化的问题,显然,它使用的算法就非常具有学习价值了。
由于内存管理有许多概念,有必要在前期做一些准备工作以帮助理解。
- 首先,考虑到netty是一个多线程环境,netty使用了ThreadLocal,在ThreadLocal中绑定一些ByteBuf。这样每个线程都可以独立的占有一段内存区间,在线程内操作内存,避免加锁的损耗。这个特殊的ThreadLocal称为PoolThreadLocalCache。
- 其次,即使使用了ThreadLocal,还是不可避免的有线程竞争,netty便将申请的大内存区域划分为多个小的内存区域,提高并发执行的效率。这些内存区域被成为Arena,它们组成了一个内存池,分配时每个线程按照轮训策略选择一个Arena进行内存分配。
- 最后,虽然将大内存区域划分成了Arena,它仍然还是有些大,如果直接在上面任意分配,很容易产生内存碎片,所以还需要再细分。netty将Arena切分成一小块一小块的Chunk,Chunk内部是一些Page的集合。Page也即内存页,是真正分配内存的地方。Page默认大小为8KB,但实际使用时很难一次用到这么大的内存,而是使用像16B,32B这种大小的内存。为了应对这种需求,netty进一步切分Page为更小的SubPage。
以上3点从大方向上简单介绍了内存管理的几个概念,具体的实现形式和细节将一步步探讨。
PooledByteBufAllocate
ByteBufAllocator的默认实现类是PooledByteBufAllocator,而PooledByteBufAllocate及其实现类正是这套内存管理机制的承载者。
首先看一下它的构造函数,来了解哪些类参与了池化内存分配。
public PooledByteBufAllocator(boolean preferDirect, int nHeapArena, int nDirectArena, int pageSize, int maxOrder, int tinyCacheSize, int smallCacheSize, int normalCacheSize, boolean useCacheForAllThreads, int directMemoryCacheAlignment) { super(preferDirect); threadCache = new PoolThreadLocalCache(useCacheForAllThreads); this.tinyCacheSize = tinyCacheSize; this.smallCacheSize = smallCacheSize; this.normalCacheSize = normalCacheSize; chunkSize = validateAndCalculateChunkSize(pageSize, maxOrder); int pageShifts = validateAndCalculatePageShifts(pageSize); if (nHeapArena > 0) { heapArenas = newArenaArray(nHeapArena); for (int i = 0; i < heapArenas.length; i ++) { PoolArena.HeapArena arena = new PoolArena.HeapArena(this, pageSize, maxOrder, pageShifts, chunkSize, directMemoryCacheAlignment); heapArenas[i] = arena; } } else { heapArenas = null; } if (nDirectArena > 0) { directArenas = newArenaArray(nDirectArena); for (int i = 0; i < directArenas.length; i ++) { PoolArena.DirectArena arena = new PoolArena.DirectArena( this, pageSize, maxOrder, pageShifts, chunkSize, directMemoryCacheAlignment); directArenas[i] = arena; } } else { directArenas = null; } }
从构造函数中可以看到,上述管理思想中的ThreadLocal、PoolArena、PoolChunk、Page、PoolSubpage都一一出现。
而且构造函数虽然长,但大致可以分为4个步骤:
- 调用父类构造器,设置是否优先考虑分配堆外内存;
- 设置PoolThreadLocalCache相关的参数,包括threadCache,tinyCacheSize,smallCacheSize,normalCacheSize;
- 设置Chuck大小,并根据Chunck大小计算Page数量;
- 设置heapArena和directArena的数量。
这个构造函数虽然只是简单的设置了一些数值,但这些数值对于后续如何分配内存却有着至关重要的影响。
我们需要先分析一下上述各个来,再进入PoolByteBufAllocate中了解默认的内存分配是怎样起到适合各种情况的。
PoolChunk
PoolChunk在内存分配中处于一个承上启下的位置,所以我们首先来了解一下它。
Chunk初始化
在PooledByteBufAllocate构造函数值,初始化了chunkSize和pageShifts这2个变量,且用到了pageSize和maxOrder,它们分别由"io.netty.allocator.pageSize"和"io.netty.allocator.maxOrder"设置,pageSize不得小于2^12(4096)且必须为2的幂次方,默认为2^13(8192);maxOrder不得大于14,默认为11。validateAndCalculateChunkSize/PageShifts这2个方法用于检测用户设定值的正确性并计算chunkSize和pageShifts。其中chunkSize是将pageSize左移maxOrder位,且不得大于2^30,默认会是2^24。pageShifts是pageSize相对于‘1‘左移的位数,由于pageSize为2^13,这里pageShifts为13。
如果不了解Chunk与Page的关系,在这里会困惑很久。前文说到Chunk是Page的集合,那么Chunk是如何组织Page的呢?我们进入PoolChunk的构造函数一窥全貌。
PoolChunk(PoolArena<T> arena, T memory, int pageSize, int maxOrder, int pageShifts, int chunkSize, int offset) { unpooled = false; this.arena = arena; this.memory = memory; this.pageSize = pageSize; this.pageShifts = pageShifts; this.maxOrder = maxOrder; this.chunkSize = chunkSize; this.offset = offset; unusable = (byte) (maxOrder + 1); log2ChunkSize = log2(chunkSize); subpageOverflowMask = ~(pageSize - 1); freeBytes = chunkSize; maxSubpageAllocs = 1 << maxOrder; memoryMap = new byte[maxSubpageAllocs << 1]; depthMap = new byte[memoryMap.length]; int memoryMapIndex = 1; for (int d = 0; d <= maxOrder; ++ d) { int depth = 1 << d; for (int p = 0; p < depth; ++ p) { memoryMap[memoryMapIndex] = (byte) d; depthMap[memoryMapIndex] = (byte) d; memoryMapIndex ++; } } subpages = newSubpageArray(maxSubpageAllocs); cachedNioBuffers = new ArrayDeque<ByteBuffer>(8); }
这也是一个相当长的构造函数,其中unpooled、arena、memory、subpages、cachedNioBuffers这几个成员变量与这一节关系不大,先忽略。pageSize、maxOrder、pageShifts、chunkSize已在上文阐述。
重点放在memoryMap和depthMap这2个名为map实为数组的成员变量里。它们的长度为maxSubpageAllocs <<1,为2^12,4096。之后,通过一个循环,将memoryMap和depthMap各自初始化。2个map在初始化后,都变成了一个从下标1开始,每个元素的下标index和值val满足 2^(val) <= index < 2^(val + 1)的数组。这类型的数组是标准的满二叉树数据结构。
由于从下标1开始,这两颗满二叉树一共4095个节点,其中有2048个叶子节点。如果将视线回到ChunkSize的计算,会发现ChunkSize也是通过将pageSize*2048得到,这说明满二叉树的每一个叶子结点都代表了一个Page,且父节点可分配的容量是2个子节点的容量和。
PoolChunk分配内存
在初始化完成后,depthMap就不再变化,而memoryMap会随着内存分配而变化。我们来看一下PoolChunk如何分配内存:
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) { final long handle; if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize handle = allocateRun(normCapacity); } else { handle = allocateSubpage(normCapacity); } if (handle < 0) { return false; } ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null; initBuf(buf, nioBuffer, handle, reqCapacity); return true; }
这段逻辑可以分为2段
- 判断normCapacity的大小,以决定分配Page还是分配SubPage。
- 尝试从缓存的NioBuffers中分配。
- 初始化ByteBuf。
第一个步骤的分支点在于normCapacity与subpageOverflowMask的结果,normCapacity是一个规整化后的需要分配的大小。所谓规整化就是向上调整为2的幂次方。而subpageOverflowMask由Mask不难猜出它的作用相当于一个掩码,在构造函数中,它通过~(pageSize - 1)得到,拿出计算器后可知当pageSize为8192时它等于0xFFFFFFFFFFFFE000,确实符合掩码的格式。当normCapacity & subpageOverflowMask != 0时,normCapacity大于等于8192,需要分配Page级别的内存。
跟进allocateRun(normCapacity):
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; }
由于预先对normCapacity进行了规整化,log2(normCapacity)可以得到的对数是一个2的幂次,在这里也相当于二叉树的深度。计算d是为了知晓在二叉树哪一层的节点进行分配内存。为了帮助理解,不妨假设当前需要分配一个16KB大小的内存。则d = 11 - (14 - 13) = 10,为第10层节点。以这个例子再进入allocateNode(d)中。
private int allocateNode(int d) { int id = 1; int initial = - (1 << d); byte val = value(id); if (val > d) { return -1; } while (val < d || (id & initial) == 0) { id <<= 1; val = value(id); if (val > d) { id ^= 1; val = value(id); } } setValue(id, unusable); updateParentsAlloc(id); return id; }
当d=10时,initial不难算出为-1024。val = value(id)=memoryMap[id],此时memory由于还未分配,map里的值还是对应的深度。到了while循环,2个条件里val < d还比较好理解,它表示当前节点的深度不超过d,也就意味着当前节点有足够的容量分配内存;(id & initial)倒是有点晦涩难懂。继续以例子做参照,由于initial=-1024,将它转化为2进制,也可以当做一个掩码,而id从1开始左移,若id左移过多,id & initial就不再为0了,而id左移的意味着深度增加,所以这个条件表达式也是为了确保当前节点的深度不超过d。乍一看这2个条件是重复的,的确,初次分配时,第二个条件没有发挥作用,它得等到第二次分配才体现出效果。
当d等于10,val=10时,会跳出循环,然后将id下标对应的元素值设置为unusable,unusable在PoolChunk的构造函数中定义,为maxOrder+1,意味着不可达的深度,这里为12。
除了更新分配好的节点外,还需要更新其父节点,具体逻辑在updateParentAlloc(id),通过不断将id右移,同时更新父节点为2个子节点中较小的子节点的值,直到根节点。这里value(id ^ 1)就是求得id节点的兄弟节点的值。显然,这是因为其中一个子节点分配出去后,父节点只有剩下的一个子节点可分配了,自然需要更新它的值。
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; } }
回到allocateNode方法,之前说过(id & initial)需要在第二次分配才体现出效果。这是因为在第一次分配后,从根节点到分配节点的父节点深度都减少了1,当第二次分配16KB内存时,在512节点处时,val就已经等于10了,
id & initial = 512 & -1024 = 0,从而进入循环。在循环后,id继续左移,找到1024节点,此时由于1024节点已被分配,memory[1024] = 12 > 10 = d,进入if条件内,通过异或1,找到1024节点的兄弟节点1025进行分配。
allocateNode调用完毕后,回到allocateRun方法,在可用ChunkSize里减去该次分配的内存大小。
由于第二三点涉及到其他一些知识,留在以后分析。