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);
}
}
}