为了避免频繁的内存分配给系统带来负担以及GC对系统性能带来波动,Netty4提出了全新的内存管理,使用了全新的内存池来管理内存的分配和回收。内存池这块的代码复杂难懂,而且几乎没有注释阅读起来比较费力,特别是以前没有接触过内存分配算法的阅读起来更为蛋疼,好在经过几个晚上的努力,终于捋出了一些端倪,特来此记录一番。
Netty4的内存池集大家之精华,参考了各路英雄豪杰的优秀思想,它参考了slab分配,Buddy(伙伴)分配。接触过memcached的应该了解slab分配,它的思路是把内存分割成大小不等的内存块,用户线程请求内存时根据请求的内存大小分配最贴近size的内存块,在减少内存碎片的同时又能很好的避免内存浪费。Buddy分配是在分配的过程中把一些内存块等量分割,回收时合并,尽可能保证系统中有足够大的连续内存。
Netty内存分配包括了堆内存和非堆内存(Direct内存),但是内存分配的核心算法是类似,所以从堆内存分配代码入手来学习整个内存池的原理。
在应用层通过设置PooledByteBufAllocator来执行ByteBuf的分配,但是最终的内存分配工作被委托给PoolArena,由于Netty通常用于高并发系统,所以各个线程进行内存分配时竞争不可避免,这可能会极大的影响内存分配的效率,为了缓解高并发时的线程竞争,Netty允许使用者创建多个分配器(Arena)来分离锁,提高内存分配效率,当然是以内存来作为代价的。可以通过PooledByteBufAllocator构造子中的nHeapArena参数来执行Arena的数量,或者通过key为io.netty.allocator.numHeapArenas的系统变量进行设置,它有一个默认值,通过以下代码决定默认值:
DEFAULT_NUM_HEAP_ARENA = Math.max(0,
SystemPropertyUtil.getInt(
"io.netty.allocator.numHeapArenas",
(int) Math.min(
runtime.availableProcessors(),
Runtime.getRuntime().maxMemory() / defaultChunkSize / 2 / 3)));
final int idx = index.getAndIncrement();
final PoolArena heapArena;
final PoolArena directArena;
if (heapArenas != null) {
heapArena = heapArenas[Math.abs(idx % heapArenas.length)];
} else {
heapArena = null;
}
内存池中包含页(page)和块(chunk)两种分配单位,页大小可以通过PooledByteBufAllocator构造子中的pageSize传入,或者通过系统变量io.netty.allocator.pageSize设置,默认8192即8K,这个pageSize大小不是随意设置是有限制的,它必须大于4096(4K),而且为了方便的支持位运算使内存分配更高效,它必须是2的整数次幂。对于chunkSize它是通过pageSize和maxOrder参数计算而来,计算公式是chunkSize=pageSize*(2的maxOrder次幂),maxOrder可以通过PooledByteBufAllocator构造子的maxOrder参数或io.netty.allocator.maxOrder系统变量设置,只能设置0-14范围内的值,默认值11,也就是说一个chunk大小默认等于2的11次方个page,chunkSize的计算方法可以查看PooledByteBufAllocator的validateAndCalculateChunkSize方法代码:
private static int validateAndCalculateChunkSize(int pageSize, int maxOrder) {
if (maxOrder > 14) {
throw new IllegalArgumentException("maxOrder: " + maxOrder + " (expected: 0-14)");
}
// Ensure the resulting chunkSize does not overflow.
int chunkSize = pageSize;
for (int i = maxOrder; i > 0; i --) {
if (chunkSize > MAX_CHUNK_SIZE / 2) {
throw new IllegalArgumentException(String.format(
"pageSize (%d) << maxOrder (%d) must not exceed %d", pageSize, maxOrder, MAX_CHUNK_SIZE));
}
chunkSize <<= 1;
}
return chunkSize;
}
在Arena中由tinySubpagePools和smallSubpagePools来缓存分配给tiny(小于512)和small(大等于512)的内存页。同时创建了6个Chunk列表(PoolChunkList)来分配给Normal(超过一页)大小,包括:
这六个PoolChunkList也通过链表串联,串联关系是:qInit->q000->q025->q050->q075->q100。
内存池包含两层分配区:线程私有分配区和内存池公有分配区。当内存被分配给某个线程之后,在释放内存时释放的内存不会直接返回给公有分配区,而是直接在线程私有分配区中缓存,当线程频繁的申请内存时会提高分配效率,同时当线程申请内存的动作不活跃时可能会造成内存浪费的情况,这时候内存池会对线程私有分配区中的情况进行监控,当发现线程的分配活动并不活跃时会把线程缓存的内存块释放返回给公有区。在整个内存分配时可能会出现分配的内存过大导致内存池无法分配的情况,这时候就需要JVM堆直接分配,所以严格的讲有三层分配区。
下面是内存分配大致流程图:
内存池采用了slab分配思路,内存被划分成多种不同大小的内存单元,在分配内存时根据使用者请求的内存大小进行计算,匹配最接近的内存单元。在计算时分下面几种情况:
请求的内存大小是否超过了chunkSize,如果已超出说明一个该内存已经超出了一个chunk能分配的范围,这种内存内存池无法分配应由JVM分配,直接返回原始大小。
请求大小大于等于512,返回一个512的2次幂倍数当做最终的内存大小,当原始大小是512时,返回512,当原始大小在(512,1024]区间,返回1024,当在(1024,2048]区间,返回2048等等。
请求大小小于512,返回一个16的整数倍,原始大小(0,16]区间返回16,(16,32]区间返回32,(32,48]区间返回48等等,这些大小的内存块在内存池中叫tiny块。
相关代码在PoolArena的normalizeCapacity方法:
int normalizeCapacity(int reqCapacity) {
if (reqCapacity < 0) {
throw new IllegalArgumentException("capacity: " + reqCapacity + " (expected: 0+)");
}
if (reqCapacity >= chunkSize) {
return reqCapacity;
}
if (!isTiny(reqCapacity)) { // >= 512
// Doubled
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;
}
// Quantum-spaced
if ((reqCapacity & 15) == 0) {
return reqCapacity;
}
return (reqCapacity & ~15) + 16;
}
分配的内存大小小于512时内存池分配tiny块,大小在[512,pageSize]区间时分配small块,tiny块和small块基于page分配,分配的大小在(pageSize,chunkSize]区间时分配normal块,normall块基于chunk分配,内存大小超过chunk,内存池无法分配这种大内存,直接由JVM堆分配,内存池也不会缓存这种内存。
为了避免线程竞争,内存分配优先在线程内分配,在PoolThreadCache中定义了tinySubPageHeapCaches、smallSubPageHeapCaches、normalHeapCaches分别在线程内缓存tiny、small、normall内存块,其中tiny块的个数为32个,small块的个数有pageSize来决定,它的计算公式是:pageShifts - 9,这个pageShifts就是pageSize二进制表示时尾部0的个数,那么按照这种方式有没有可能因为0的数量小于9个而导致计算结果是负数呢?当然不会,上面提到了pageSize不是随意设置的,它必须大于4096(4K),pageSize是4096时,它的二进制表示是1000000000000,那么这个pageShifts就是12,所以small块的数量就是3。pageShifts的计算代码在PooledByteBufAllocator的validateAndCalculatePageShifts方法中:
private static int validateAndCalculatePageShifts(int pageSize) {
if (pageSize < MIN_PAGE_SIZE) {
throw new IllegalArgumentException("pageSize: " + pageSize + " (expected: " + MIN_PAGE_SIZE + "+)");
}
if ((pageSize & pageSize - 1) != 0) {
throw new IllegalArgumentException("pageSize: " + pageSize + " (expected: power of 2)");
}
// Logarithm base 2. At this point we know that pageSize is a power of two.
return Integer.SIZE - 1 - Integer.numberOfLeadingZeros(pageSize);
}
normal块的个数计算稍微复杂一点,它和三个参数有关:chunkSize、pageSize、maxCachedBufferCapacity,chunkSize和pageSize前面已经介绍过了,maxCachedBufferCapacity这个参数使用者可以通过设置io.netty.allocator.maxCachedBufferCapacity系统变量传入,默认是32 * 1024。取chunkSize和maxCachedBufferCapacity中最小的一个和pageSize做个除法,这就是normal块的个数。在前面提到过了,chunkSize=pageSize*(2的maxOrder次幂),maxOrder的默认值是11,pageSize默认值8192,所以默认情况下,normal块的数量应该是32 * 1024/8192=4。
在分配时通过请求的内存大小计算内存块的索引,对于tiny计算方式如下:
static int tinyIdx(int normCapacity) {
return normCapacity >>> 4;
}
在上面提到了tiny块的大小只能是16的倍数,所以通过上面代码计算出来的索引就是0,1,2,3,4........31。
对于small计算方式如下:
static int smallIdx(int normCapacity) {
int tableIdx = 0;
int i = normCapacity >>> 10;
while (i != 0) {
i >>>= 1;
tableIdx ++;
}
return tableIdx;
}
因为small块的大小是512的2次幂倍数,代码函数中计算512计算出0,1024计算出1,2048计算出2,4096计算出3等等。
normal计算方式如下:
int idx = log2(normCapacity >> numShiftsNormalHeap);
numShiftsNormalHeap是pageSize的对数,pageSize为8192时,numShiftsNormalHeap的值是13,normal块的size都是大于pageSize的,所以它的值也是512的2次幂倍数,当normCapacity小于等于8192时计算出的idx是0,8192*2时为1,8192*4时为2。
计算出索引之后就可以定位到线程中的内存块(MemoryRegionCache)了,MemoryRegionCache维护了一个Entry列表,每个Entry都对应一个可分配的内存单元Chunk以及一个长整形数handle,内存分配成功之后会给应用层返回这个chunk和handle,这个handle的作用后面再介绍。
内存池的初始阶段,线程是没有内存缓存的,所以最开始的内存分配都需要在全局分配区进行分配,全局分配区的内存构造和线程私有分配区的类似,也包含了tiny、small、normal几种规模,计算索引的方式也都是一模一样的,代码都完全复用。
无论是tinySubpagePools还是smallSubpagePools成员,在内存池初始化时是不会预置内存的,所以最开始的内存分配都会进入PoolArena的allocateNormal方法:
private synchronized void allocateNormal(PooledByteBuf buf, int reqCapacity, int normCapacity) {
if (q050.allocate(buf, reqCapacity, normCapacity) || q025.allocate(buf, reqCapacity, normCapacity) ||
q000.allocate(buf, reqCapacity, normCapacity) || qInit.allocate(buf, reqCapacity, normCapacity) ||
q075.allocate(buf, reqCapacity, normCapacity) || q100.allocate(buf, reqCapacity, normCapacity)) {
return;
}
// Add a new chunk.
PoolChunk c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
long handle = c.allocate(normCapacity);
assert handle > 0;
c.initBuf(buf, handle, reqCapacity);
qInit.add(c);
}
初始状态下所有的PoolChunkList都是空的,所以在此先创建chunk块并且添加到PoolChunkList中,需要注意的是虽然都是通过qInit.add添加chunk,这并不代表chunk都会被添加到qInit这个PoolChunkList,看一下PoolChunkList的add方法就可以知道:
void add(PoolChunk chunk) {
if (chunk.usage() >= maxUsage) {
nextList.add(chunk);
return;
}
chunk.parent = this;
if (head == null) {
head = chunk;
chunk.prev = null;
chunk.next = null;
} else {
chunk.prev = null;
chunk.next = head;
head.prev = chunk;
head = chunk;
}
}
PoolChunkList有两个重要的参数minUsage和maxUsage,这两个参数都是使用率,当chunk中内存可用率在[minUsage,maxUsage]区间时,这个chunk才会落到该PoolChunkList中,否则把chunk传到下一个PoolChunkList进行检查,从这里可以看出,chunk只会被添加到内存匹配的PoolChunkList中,为了更有说服力,再看一下free方法的代码:
void free(PoolChunk chunk, long handle) {
chunk.free(handle);
if (chunk.usage() < minUsage) {
remove(chunk);
if (prevList == null) {
assert chunk.usage() == 0;
arena.destroyChunk(chunk);
} else {
prevList.add(chunk);
}
}
}
在释放内存时也会检查minUsage,如果不匹配传到上一个PoolChunkList进行检查,最终会把Chunk归还到大小跟它匹配的PoolChunkList中。
1、memory,物理内存,内存请求者千辛万苦拐弯抹角就是为了得到它,在HeapArena中它就是一个chunkSize大小的byte数组。
2、memoryMap数组,内存分配控制信息,数组元素是一个32位的整数数,该整形数包含如下信息:
这个数组的大小等于pageSize*2,但是实际上只会使用pageSize*2-1个位置,第一个位置(索引为0的位置)未被使用,在PoolChunk初始化时填充该数组,每个元素代表一种分配方式,当chunkSize=8*pageSize时,该数组初始化完成之后它的第一个元素size域等于8,offset等于0表示一次性分配8页,第三个元素size等于4,offset等于4,表示一次性分配4页,从第五页开始分配。memoryMap初始化之后在结构上可以看做是一颗完美二叉树(所有非叶子结点都有且只有两个字结点,所有叶子结点的高度相同),为了便于理解我们把内存分配过程抽象成遍历匹配二叉树节点的过程,对于8页的chunk它类似于下图(s=size,o=offset):
memoryMap初始化代码在PoolChunk的构造方法中:
int chunkSizeInPages = chunkSize >>> pageShifts;
maxSubpageAllocs = 1 << maxOrder;
// Generate the memory map.
memoryMap = new int[maxSubpageAllocs << 1];
int memoryMapIndex = 1;
for (int i = 0; i <= maxOrder; i ++) {
int runSizeInPages = chunkSizeInPages >>> i;
for (int j = 0; j < chunkSizeInPages; j += runSizeInPages) {
//noinspection PointlessBitwiseExpression
memoryMap[memoryMapIndex ++] = j << 17 | runSizeInPages << 2 | ST_UNUSED;
}
}
所有的状态初始化时都是ST_UNUSED。
在进行内存分配时,memoryMap的第一元素(索引1的元素)开始检查,通过上面的分析可以知道第一个元素的size是等于chunkSize的,当然memoryMap中只存储了页数量,通过runLength方法把页数转换成内存大小:
private int runLength(int val) {
return (val >>> 2 & 0x7FFF) << pageShifts;
}
对于大于pageSize的内存,分配过程如下:
1.内存块是否已被分配,如果已被分配,返回-1标识分配失败。
2.请求的内存是否和当前memoryMap数组元素表示的内存相等,如果相等直接返回该数组元素的索引,并且把状态置成ST_ALLOCATED已分配。如果不相等,把当前元素状态设置成已分裂ST_BRANCH,跳到它其中的一个子节点,并把另外一个子节点的状态设置成ST_UNUSED。
3.内存块是否已被分裂(状态是ST_BRANCH),如果已分裂,跳到它的子节点,检查子节点是否可以成功分配,否则跳到子节点的兄弟节点(即当前节点的另一个子节点)。
相关代码:
private long allocateRun(int normCapacity, int curIdx, int val) {
for (;;) {
if ((val & ST_ALLOCATED) != 0) { // state == ST_ALLOCATED || state == ST_ALLOCATED_SUBPAGE
return -1;
}
if ((val & ST_BRANCH) != 0) { // state == ST_BRANCH
int nextIdx = curIdx << 1 ^ nextRandom();
long res = allocateRun(normCapacity, nextIdx, memoryMap[nextIdx]);
if (res > 0) {
return res;
}
curIdx = nextIdx ^ 1;
val = memoryMap[curIdx];
continue;
}
// state == ST_UNUSED
return allocateRunSimple(normCapacity, curIdx, val);
}
}
private long allocateRunSimple(int normCapacity, int curIdx, int val) {
int runLength = runLength(val);
if (normCapacity > runLength) {
return -1;
}
for (;;) {
if (normCapacity == runLength) {
// Found the run that fits.
// Note that capacity has been normalized already, so we don't need to deal with
// the values that are not power of 2.
memoryMap[curIdx] = val & ~3 | ST_ALLOCATED;
freeBytes -= runLength;
return curIdx;
}
int nextIdx = curIdx << 1 ^ nextRandom();
int unusedIdx = nextIdx ^ 1;
memoryMap[curIdx] = val & ~3 | ST_BRANCH;
//noinspection PointlessBitwiseExpression
memoryMap[unusedIdx] = memoryMap[unusedIdx] & ~3 | ST_UNUSED;
runLength >>>= 1;
curIdx = nextIdx;
val = memoryMap[curIdx];
}
}
分配成功之后,PoolChunk会返回一个正32位整形数,这个数就是memoryMap数组的索引,就是上面说的handle,接下来根据这些信息初始化PooledByteBuf:
void initBuf(PooledByteBuf buf, long handle, int reqCapacity) {
int memoryMapIdx = (int) handle;
int bitmapIdx = (int) (handle >>> 32);
if (bitmapIdx == 0) {
int val = memoryMap[memoryMapIdx];
assert (val & 3) == ST_ALLOCATED : String.valueOf(val & 3);
buf.init(this, handle, runOffset(val), reqCapacity, runLength(val));
} else {
initBufWithSubpage(buf, handle, bitmapIdx, reqCapacity);
}
}
根据索引获取到memoryMap数组中的元素,根据该元素可以提取出offset和size,最终通过offset和size初始化ByteBuf:
void init(PoolChunk chunk, long handle, int offset, int length, int maxLength) {
assert handle >= 0;
assert chunk != null;
this.chunk = chunk;
this.handle = handle;
memory = chunk.memory;
this.offset = offset;
this.length = length;
this.maxLength = maxLength;
setIndex(0, 0);
tmpNioBuf = null;
}
调用完这个方法之后,就说明chunk中从偏移量offset开始总长度为length的内存被我这个ByteBuf占用了。
protected final int idx(int index) {
return offset + index;
}
@Override
protected byte _getByte(int index) {
return memory[idx(index)];
}
到这对大于pageSize的内存分配就结束了。当chunk被填充到PoolChunkList之后,在后续的内存分配中有可能会直接在PoolChunkList中分配,遍历PoolChunkList所有的chunk直到分配成功为止,具体分配逻辑是一样的,这里不再赘述,在分配成功之后因为chunk的使用率会发生变化,所以PoolChunkList中的chunk会有所调整,把分配内存后的chunk调整到适合它的PoolChunkList中。
对于大小在pageSize以内的内存分配,由PoolSubpage类来辅助分配,在PoolArena中定义了tinySubpagePools和smallSubpagePools分别存储tiny页和small页,数组元素都是一个PoolSubpage链表的头指针,挂接多个大小相同的page。同样由normCapacity方法来计算数组索引,由tinyIdx和smallIdx方法进行计算。page的分配是按需的,假如一个page是8K,一次只请求了1K,那么剩下的7K会继续分配给其它的请求,内存分配更精细。PoolSubpage中有几个重要属性:
内存池初始化时内存页缓存tinySubpagePools和smallSubpagePools页都是空的,在PoolSubpage初始化之后会自动把它添加到tinySubpagePools或smallSubpagePools中的一个缓存中,见PoolSubpage的init方法:
void init(int elemSize) {
doNotDestroy = true;
this.elemSize = elemSize;
if (elemSize != 0) {
maxNumElems = numAvail = pageSize / elemSize;
nextAvail = 0;
bitmapLength = maxNumElems >>> 6;
if ((maxNumElems & 63) != 0) {
bitmapLength ++;
}
for (int i = 0; i < bitmapLength; i ++) {
bitmap[i] = 0;
}
}
addToPool();
}
在分配page内存时先要定位到对应的PoolSubpage,如果还未创建过和当前请求大小匹配的PoolSubpage先创建然后添加到缓存中,下次分配时可直接从缓存中读取。
page的分配时同样从memoryMap的第一个元素(索引1的元素)开始检查,检查过程如下:
1.状态是ST_BRANCH,跳到到它的一个直接子节点重复分配过程。
2.状态是ST_UNUSED,重复跳到节点的任意一个子节点动作,并且把经过的节点的状态都置成ST_BRANCH,直到到达叶子节点为止(据前面的分析,叶子节点都对应一个page),把最后达到的叶子节点状态置成ST_ALLOCATED_SUBPAGE,初始化PoolSubpage并且在PoolSubpage上进行内存分配返回一个长整形数,该长整形数的1-32位存储memoryMapIdx,33-62存储bitmapIdx。
3.状态是ST_ALLOCATED_SUBPAGE,定位到对应的PoolSubpage,并且在该PoolSubpage上进行内存分配,这个PoolSubpage之前分配过内存给某个请求,所以这次分配在nextAvail索引上继续分配,当PoolSubpage所有的内存单元都被分配完,那么把它从缓存中删除,见代码:
long allocate() {
if (elemSize == 0) {
return toHandle(0);
}
if (numAvail == 0 || !doNotDestroy) {
return -1;
}
final int bitmapIdx = nextAvail;
int q = bitmapIdx >>> 6;
int r = bitmapIdx & 63;
assert (bitmap[q] >>> r & 1) == 0;
bitmap[q] |= 1L << r;
if (-- numAvail == 0) {
removeFromPool();
nextAvail = -1;
} else {
nextAvail = findNextAvailable();
}
return toHandle(bitmapIdx);
}
前面已经提到了,内存池不会预置内存块到线程缓存中,在线程申请到内存使用完成之后归还内存时优先把内存块缓存到线程中,除非该内存块不适合缓存在线程中(内存太大),当当前线程内存分配动作非常活跃时,这样会明显的提高分配效率,但是当它不活跃时对内存又是极大的浪费,所以内存池会监控该线程,随时做好把内存从线程缓存中删除的准备,详见MemoryRegionCache类的trim方法代码:
private void trim() {
int free = size() - maxEntriesInUse;
entriesInUse = 0;
maxEntriesInUse = 0;
if (free <= maxUnusedCached) {
return;
}
int i = head;
for (; free > 0; free--) {
if (!freeEntry(entries[i])) {
// all freed
return;
}
i = nextIdx(i);
}
}
maxUnusedCached值等于缓存中Entry数量的一半,当缓存中空闲的内存块数量超过总Entry数的一半时说明线程的内存分配动作不活跃,释放所有Entry对应的chunk,当内存分配的次数超过阀值freeSweepAllocationThreshold时就会进行一次活跃度检查并释放不活跃线程缓存中空闲的内存。这里可能会存在问题:回收动作是有allocate触发的,假如某一个线程在从内存池中请求到内存之后在也没有触发过内存分配并且线程一直是存活的,那么缓存到该线程中的内存可能就无法回收了,这种情况不太可能出现在Netty多路复用器中的线程,因为多路复用器中的线程会处理IO事件,处理IO事件时ByteBuf都会向内存池请求内存,但是如果线程是使用者自定义的线程,那这个问题是有可能存在的。
除此之后内存池中还有一个任务用来监控线程状态,当发现线程不是存活态时回收被其缓存的内存。详见内部类ReleaseCacheTask:
private final class ReleaseCacheTask implements Runnable {
private ScheduledFuture> releaseTaskFuture;
@Override
public void run() {
synchronized (caches) {
for (Iterator> i = caches.entrySet().iterator();
i.hasNext();) {
Map.Entry cache = i.next();
if (cache.getKey().isAlive()) {
// Thread is still alive...
continue;
}
cache.getValue().free();
i.remove();
}
if (caches.isEmpty()) {
// Nothing in the caches anymore so no need to continue to check if something needs to be
// released periodically. The task will be rescheduled if there is any need later.
if (releaseTaskFuture != null) {
releaseTaskFuture.cancel(true);
releaseTaskFuture = null;
}
}
}
}
}
1、释放Chunk,在从内存池中分配内存时,内存池会返回一个handle的整形数给应用层,应用层需要保存这个handle并且在进行内存释放时把它传回,根据handle定位memoryMap中的分配状态信息,并且根据状态做不同的处理:
2、调整Chunk所在的PoolChunkList,内存回收之后Chunk中的可用内存会发生变化,可能已经不在当前PoolChunkList的范围中了,需要往前调整。