Netty源碼分析-PoolArena

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

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章