1、PoolChunk:维护一段连续内存,并负责内存块分配与回收,其中比较重要的两个概念:page:可分配的最小内存块单位;chunk:page的集合;

2、PoolSubpage:将page分为更小的块进行维护;

3、PoolChunkList:维护多个PoolChunk的生命周期。

多个PoolChunkList也会形成一个list,方便内存的管理。最终由PoolArena对这一系列类进行管理,PoolArena本身是一个抽象类,其子类为HeapArena和DirectArena,对应堆内存(heap buffer)和堆外内存(direct buffer),除了操作的内存(byte[]和ByteBuffer)不同外两个类完全一致。Arena:竞技场,这个名字听起来比较霸气,不同的对象会到这里来抢夺资源(申请内存)。下面我们来看看这块竞技场上到底发生了些什么事情,首先看看类里面的字段:


static final int numTinySubpagePools = 512 >>> 4;  

final PooledByteBufAllocator parent;
// 下面这几个参数用来控制PoolChunk的总内存大小、page大小等
private final int maxOrder;
final int pageSize;
final int pageShifts;
final int chunkSize;
final int subpageOverflowMask;
final int numSmallSubpagePools;
rivate final PoolSubpage<T>[] tinySubpagePools;
private final PoolSubpage<T>[] smallSubpagePools;
// 下面几个chunklist又组成了一个link list的链表
private final PoolChunkList<T> q050;
private final PoolChunkList<T> q025;
private final PoolChunkList<T> q000;
private final PoolChunkList<T> qInit;
private final PoolChunkList<T> q075;
private final PoolChunkList<T> q100;


前面我们讲过PoolSubpage在初始化以后会交由arena来管理,那么他究竟是怎么管理的呢。之前我们提到所有内存分配的size都会经过normalizeCapacity进行处理,而这个方法的处理方式是,当需求的size>=512时,size成倍增长,及512->1024->2048->4096->8192->...,而需求size<512则是从16开始,每次加16字节,这样从[512,8192)有四个不同值,而从[16,512)有32个不同值。这样tinySubpagePools的大小就是32,而tinySubpagePool的大小则是4,并且从index=0 -> max, 按照从小到大的顺序缓存subpage的数据。如tinySubpagePool[0]上是用来分配大小为16的内存,tinySubpagePool[1]分配大小为32的内存,smallSubpagePools[0]分配大小为512的内存。这样如果需要分配小内存时,只需要计算出index,到对应的index的pool查找缓存的数据即可。需要注意的是subpage从首次分配到释放的过程中,只会负责一个固定size的内存分配,如subpage初始化的时候是分配size=512的内存,则该subpage剩下的所有内存也是用来分配size=512的内存,直到其被完全释放后从arena中去掉。如果下次该subpage又重新被分配,则按照此次的大小size0分配到固定的位置,并且该subpage剩下的所有内存用来分配size=size0的内存。


netty将内存分为tiny(0,512)、small[512,8K)、normal【8K,16M]、huge[16M,)这四种类型,使用tcp进行通信时,初始的内存大小默认为1k,并会在64-64k之间动态调整(以上为默认参数,见AdaptiveRecvByteBufAllocator),在实际使用中小内存的分配会更多,因此这里将常用的小内存(subpage)前置。

可能有同学会问这几个PoolChunkList为什么这么命名,其实是按照内存的使用率来取名的,如qInit代表一个chunk最开始分配后会进入它,随着其使用率增大会逐渐从q000到q100,而随着内存释放,使用率减小,它又会慢慢的从q100到q00,最终这个chunk上的所有内存释放后,整个chunk被回收。我们来看看这几个chunk list的顺序:


q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE);  
q075 = new PoolChunkList<T>(this, q100, 75, 100);
q050 = new PoolChunkList<T>(this, q075, 50, 100);
q025 = new PoolChunkList<T>(this, q050, 25, 75);
q000 = new PoolChunkList<T>(this, q025, 1, 50);
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25);

q100.prevList = q075;
q075.prevList = q050;
q050.prevList = q025;
q025.prevList = q000;
// q000没有前置节点,则当一个chunk进入q000后,如果其内存被完全释放,则不再保留在内存中,其分配的内存被完全回收
q000.prevList = null;
// qInit前置节点为自己,且minUsage=Integer.MIN_VALUE,意味着一个初分配的chunk,在最开始的内存分配过程中(内存使用率<25%),
// 即使完全回收也不会被释放,这样始终保留在内存中,后面的分配就无需新建chunk,减小了分配的时间
qInit.prevList = qInit;

分配内存时先从内存占用率相对较低的chunklist中开始查找,这样查找的平均用时就会更短:


private synchronized void allocateNormal(PooledByteBuf<T> 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<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
long handle = c.allocate(normCapacity);
assert handle > 0;
c.initBuf(buf, handle, reqCapacity);
qInit.add(c);
}


上面是分配内存时的查找顺序,验证了上面说的从内存使用率相对较低的chunklist中查找。


这里为什么不是从较低的q000开始呢,在分析PoolChunkList的时候,我们知道一个chunk随着内存的不停释放,它本身会不停的往其所在的chunk list的prev list移动,直到其完全释放后被回收。 如果这里是从q000开始尝试分配,虽然分配的速度可能更快了(因为分配成功的几率更大),但一个chunk在使用率为25%以内时有更大几率再分配,也就是一个chunk被回收的几率大大降低了。这样就带来了一个问题,我们的应用在实际运行过程中会存在一个访问高峰期,这个时候内存的占用量会是平时的几倍,因此会多分配几倍的chunk出来,而等高峰期过去以后,由于chunk被回收的几率降低,内存回收的进度就会很慢(因为没被完全释放,所以无法回收),内存就存在很大的浪费。

为什么是从q050开始尝试分配呢,q050是内存占用50%~100%的chunk,猜测是希望能够提高整个应用的内存使用率,因为这样大部分情况下会使用q050的内存,这样在内存使用不是很多的情况下一些利用率低(<50%)的chunk慢慢就会淘汰出去,最终被回收。

然而为什么不是从qinit中开始呢,这里的chunk利用率低,但又不会被回收,岂不是浪费?

q075,q100由于使用率高,分配成功的几率也会更小,因此放到最后(q100上的chunk使用率都是100%,为什么还要尝试从这里分配呢??)。如果整个list中都无法分配,则新建一个chunk,并将其加入到qinit中。

需要注意的是,上面这个方法已经是被synchronized修饰的了,因为chunk本身的访问不是线程安全的,因此我们在实际分配内存的时候必须保证线程安全,防止同一个内存块被多个对象申请到。

前面我们再回过头来看看整个内存分配的代码:


private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {  
final int normCapacity = normalizeCapacity(reqCapacity);
if (isTinyOrSmall(normCapacity)) { // capacity < pageSize
int tableIdx;
PoolSubpage<T>[] table;
if (isTiny(normCapacity)) { // < 512
// 小内存从tinySubpagePools中分配
if (cache.allocateTiny(this, buf, reqCapacity, normCapacity)) {
// was able to allocate out of the cache so move on
return;
}
tableIdx = tinyIdx(normCapacity);
table = tinySubpagePools;
} else {
// 略大的小内存从smallSubpagePools中分配
if (cache.allocateSmall(this, buf, reqCapacity, normCapacity)) {
// was able to allocate out of the cache so move on
return;
}
tableIdx = smallIdx(normCapacity);
table = smallSubpagePools;
}

// subpage的分配方法不是线程安全,所以需要在实际分配时加锁
synchronized (this) {
final PoolSubpage<T> head = table[tableIdx];
final PoolSubpage<T> s = head.next;
if (s != head) {
assert s.doNotDestroy && s.elemSize == normCapacity;
long handle = s.allocate();
assert handle >= 0;
s.chunk.initBufWithSubpage(buf, handle, reqCapacity);
return;
}
}
} else if (normCapacity <= chunkSize) {
if (cache.allocateNormal(this, buf, reqCapacity, normCapacity)) {
// was able to allocate out of the cache so move on
return;
}
} else {
// Huge allocations are never served via the cache so just call allocateHuge
allocateHuge(buf, reqCapacity);
return;
}
allocateNormal(buf, reqCapacity, normCapacity);
}


从上面的代码我们可以看到除了大内存的分配,都是先尝试从cache中分配,如果无法完成分配则再走其他流程。这个cache有何作用? 它是利用ThreadLocal的特性,去除锁竞争,提高内存分配的效率(后面会单独讲)。 对于小内存(小于8K)的分配,则是先尝试从对应大小的PoolSubpage中分配,如果分配不到再通过allocateNormal分配,如一个size=16的请求,会尝试从tinySubpagePools[0]中尝试,而size=1024则会从smallSubpagePools[1]中尝试,以此类推。这里需要注意的是,在tinySubpagePools和smallSubpagePools每个位置上只有一个PoolSubpage,但PoolSubpage本身有next和prev两个属性,所以其实这里代表的是一个link list而不是单个PoolSubpage。但是在从cache的PoolSubpage中分配内存时,只做了一次尝试,即尝试从head.next中取,那这个link list还有什么用呢? 其实前面在PoolSubpage的分析中讲到,一个subpage如果所有内存都已经分配,则会从这个link list中移除,并且在有部分内存释放时再加入,在内存完全释放时彻底从link list中移除。因此可以保证如果link list中有数据节点,则第一个节点以及后面的所有节点都有可分配的内存,因此不需要分配多次。需要注意PoolSubpage在将自身从link list中彻底移除时有一个策略,即如果link list中没有其他节点了则不进行移除,这样arena中一旦分配了某个大小的小内存后始终存在可以分配的节点。



超大内存由于本身复用性并不高,因此没有做其他任何策略。不用pool而是直接分配一个大内存,用完后直接回收。

private void allocateHuge(PooledByteBuf<T> buf, int reqCapacity) {  
buf.initUnpooled(newUnpooledChunk(reqCapacity), reqCapacity);
}

1、如果chunk不是pool的(如上面的huge方式分配的),则直接销毁(回收);

2、如果分配线程和释放线程是同一个线程, 则先尝试往ThreadLocal的cache中放,此时由于用到了ThreadLocal,没有线程安全问题,所以不加锁;

3、如果cache已满(或者其他原因导致无法添加,这里先不深入),则通过其所在的chunklist进行释放,这里的chunklist释放会涉及到对应内存块的释放,chunk在chunklist之间的移动和chunk的销毁,细节见PoolChunkList的分析。

void free(PoolChunk<T> chunk, long handle, int normCapacity, boolean sameThreads) {  
if (chunk.unpooled) {
destroyChunk(chunk);
} else {
if (sameThreads) {
PoolThreadCache cache = parent.threadCache.get();
if (cache.add(this, chunk, handle, normCapacity)) {
// cached so not free it.
return;
}
}


synchronized (this) {
chunk.parent.free(chunk, handle);
}
}
}