-
真實內(nèi)存指的是ByteBuf底層的array和DirectByteBuffer,真實內(nèi)存池化的好處如下:
1.1 降低真實內(nèi)存的分配開銷。因為DirectByteBuffer的分配很抵效(創(chuàng)建堆外內(nèi)存的速度比堆內(nèi)存慢了10到20倍),特別的對于DirectByteBuffer的分配開銷降低尤為明顯。
1.2 因為真實內(nèi)存可以復(fù)用,避免了大量對象的回收,降低了GC頻率。 - 怎么降低線程競爭的
2.1 默認有核數(shù)*2個PoolArea,每個線程初始綁定一個最少線程使用的PoolArea,線程一旦綁定了對應(yīng)的PoolArea,不能再改變PoolArea。
2.2 本地線程也有cache。
1. 總體結(jié)構(gòu)
1.1 整體結(jié)構(gòu)
首先介紹些netty內(nèi)存池的層級結(jié)構(gòu),主要分為Arena、ChunkList、Chunk、Page、Subpage這5個層級,這幾個層級的關(guān)系由大到小,如下圖所示:

Arena代表1個內(nèi)存區(qū)域,為了優(yōu)化內(nèi)存區(qū)域的并發(fā)訪問,netty中內(nèi)存池是由多個Arena組成的數(shù)組,分配時會每個線程按照輪詢策略選擇1個Arena進行內(nèi)存分配。
1個Arena由兩個PoolSubpage數(shù)組和多個ChunkList組成。兩個PoolSubpage數(shù)組分別為tinySubpagePools和smallSubpagePools。多個ChunkList按照雙向鏈表排列,每個ChunkList里包含多個Chunk,每個Chunk里包含多個Page(默認2048個),每個Page(默認大小為8k字節(jié))由多個Subpage組成。
每個Arena由如下幾個ChunkList構(gòu)成:
- PoolChunkList<T> qInit:存儲內(nèi)存利用率0-25%的chunk
- PoolChunkList<T> q000:存儲內(nèi)存利用率1-50%的chunk
- PoolChunkList<T> q025:存儲內(nèi)存利用率25-75%的chunk
- PoolChunkList<T> q050:存儲內(nèi)存利用率50-100%的chunk
- PoolChunkList<T> q075:存儲內(nèi)存利用率75-100%的chunk
- PoolChunkList<T> q100:存儲內(nèi)存利用率100%的chunk
每個ChunkList里包含的Chunk數(shù)量會動態(tài)變化,比如當該chunk的內(nèi)存利用率變化時會向其它ChunkList里移動。
每個Chunk里默認包含2048個Page。
每個Page包含的Subpage的大小和個數(shù)由首次從該Page分配的內(nèi)存大小決定,1個page默認大小為8k,如果首次在該page中需要分配1k字節(jié),那么該page就被分為8個Subpage,每個Subpage大小為1k。
1.2 PoolArea申請流程
在PoolArena中申請內(nèi)存的流程圖如下:

- 對于小于pageSize大小的內(nèi)存,會在tinySubpagePools或smallSubpagePools中分配,tinySubpagePools用于分配小于512字節(jié)的內(nèi)存,smallSubpagePools用于分配大于512小于pageSize的內(nèi)存。
- 對于大于pageSize小于chunkSize大小的內(nèi)存,會在PoolChunkList的Chunk中分配。
- 對于大于chunkSize大小的內(nèi)存,直接創(chuàng)建非池化Chunk來分配內(nèi)存,并且該Chunk不會放在內(nèi)存池中重用。
1.3 PoolChunkList申請流程
對于在q050、q025、q000、qInit、q075這些PoolChunkList里申請內(nèi)存的流程圖如下:

- 在PoolChunk中,數(shù)組組織呈完美二叉樹數(shù)據(jù)結(jié)構(gòu)。二叉樹葉子節(jié)點為2048個Page,每個Page的父節(jié)點用于分配pageSize
*2大小內(nèi)存,同理,對于Page葉子節(jié)點的父節(jié)點的父節(jié)點,用于分配pageSize*4大小的內(nèi)存,后面以此類推。 - 在初始狀態(tài)時,tinySubpagePools和smallSubpagePools為空,因此最初分配小于pageSize的內(nèi)存時,需要新建1個PoolChunk來分配這塊小內(nèi)存,PoolChunk會對Page分類成若干Subpage,然后用Subpage分配這塊小內(nèi)存,最后會把該Subpage放在tinySubpagePools或smallSubpagePools中。
2. 具體細節(jié)
2.1 PoolChunk
Netty一次向系統(tǒng)申請16M的連續(xù)內(nèi)存空間,這塊內(nèi)存通過PoolChunk對象包裝,為了更細粒度的管理它,進一步的把這16M內(nèi)存分成了2048個頁(pageSize=8k)。頁作為Netty內(nèi)存管理的最基本的單位 ,所有的內(nèi)存分配首先必須申請一塊空閑頁。Ps: 這里可能有一個疑問,如果申請1Byte的空間就分配一個頁是不是太浪費空間,在Netty中Page還會被細化用于專門處理小于4096Byte的空間申請 那么這些Page需要通過某種數(shù)據(jù)結(jié)構(gòu)跟算法管理起來。最簡單的是采用數(shù)組或位圖管理

如上圖1表示已申請,0表示空閑。這樣申請一個Page的復(fù)雜度為O(n),但是申請k個連續(xù)Page,就立馬退化為O(kn)。
Netty采用完全二叉樹進行管理,樹中每個葉子節(jié)點表示一個Page,即樹高為12,中間節(jié)點表示頁節(jié)點的持有者。

這樣的一個完全二叉樹可以用大小為4096的數(shù)組表示,數(shù)組元素的值含義為:
private final byte[] memoryMap; //表示完全二叉樹,共有4096個
private final byte[] depthMap; //表示節(jié)點的層高,共有4096個
- memoryMap[i] = depthMap[i]:表示該節(jié)點下面的所有葉子節(jié)點都可用,這是初始狀態(tài)
- memoryMap[i] = depthMap[i] + 1:表示該節(jié)點下面有一部分葉子節(jié)點被使用,但還有一部分葉子節(jié)點可用
- memoryMap[i] = maxOrder + 1 = 12:表示該節(jié)點下面的所有葉子節(jié)點不可用
有了上面的數(shù)據(jù)結(jié)構(gòu),那么頁的申請跟釋放就非常簡單了,只需要從根節(jié)點一路遍歷找到可用的節(jié)點即可,復(fù)雜度為O(lgn)。代碼為:
#PoolChunk
//根據(jù)申請空間大小,選擇申請方法
long allocate(int normCapacity) {
if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
return allocateRun(normCapacity); //大于1頁
} else {
return allocateSubpage(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;
}
/ /申請空間,即節(jié)點編號
private int allocateNode(int d) {
int id = 1; //從根節(jié)點開始
int initial = - (1 << d); // has last d bits = 0 and rest all = 1
byte val = value(id);
if (val > d) { // unusable
return -1;
}
while (val < d || (id & initial) == 0) { // id & initial == 1 << d for all ids at depth d, for < d it is 0
id <<= 1; //左節(jié)點
val = value(id);
if (val > d) {
id ^= 1; //右節(jié)點
val = value(id);
}
}
byte value = value(id);
assert value == d && (id & initial) == 1 << d : String.format("val = %d, id & initial = %d, d = %d",
value, id & initial, d);
//更新當前申請到的節(jié)點的狀態(tài)信息
setValue(id, unusable); // mark as unusable
//級聯(lián)更新父節(jié)點的狀態(tài)信息
updateParentsAlloc(id);
return id;
}
//級聯(lián)更新父節(jié)點的狀態(tài)信息
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;
}
}
2.2 PoolSubpage
對于小內(nèi)存(小于4096)的分配還會將Page細化成更小的單位Subpage。Subpage按大小分有兩大類,36種情況:
- Tiny:小于512的情況,最小空間為16,對齊大小為16,區(qū)間為[16,512),所以共有32種情況。
- Small:大于等于512的情況,總共有四種,512,1024,2048,4096。

PoolSubpage中直接采用位圖管理空閑空間(因為不存在申請k個連續(xù)的空間),所以申請釋放非常簡單。
代碼:
#PoolSubpage(數(shù)據(jù)結(jié)構(gòu))
final PoolChunk<T> chunk; //對應(yīng)的chunk
private final int memoryMapIdx; //chunk中那一頁,肯定大于等于2048
private final int pageSize; //頁大小
private final long[] bitmap; //位圖
int elemSize; //單位大小
private int maxNumElems; //總共有多少個單位
private int bitmapLength; //位圖大小,maxNumElems >>> 6,一個long有64bit
private int nextAvail; //下一個可用的單位
private int numAvail; //還有多少個可用單位;
這里bitmap是個位圖,0表示可用,1表示不可用. nextAvail表示下一個可用單位的位圖索引,初始狀態(tài)為0,申請之后設(shè)置為-1. 只有在free后再次設(shè)置為可用的單元索引。在PoolSubpage整個空間申請的邏輯就是在找這個單元索引,只要理解了bitmap數(shù)組是個位圖,每個數(shù)組元素表示64個單元代碼的邏輯就比較清晰了
#PoolSubpage
long allocate() {
if (elemSize == 0) {
return toHandle(0);
}
if (numAvail == 0 || !doNotDestroy) {
return -1;
}
final int bitmapIdx = getNextAvail(); //查找下一個單元索引
int q = bitmapIdx >>> 6; //轉(zhuǎn)為位圖數(shù)組索引
int r = bitmapIdx & 63; //保留最低的8位
assert (bitmap[q] >>> r & 1) == 0;
bitmap[q] |= 1L << r; //設(shè)置為1
if (-- numAvail == 0) {
removeFromPool();
}
return toHandle(bitmapIdx); //對索引進行特化處理,防止與頁索引沖突
}
private int getNextAvail() {
int nextAvail = this.nextAvail;
if (nextAvail >= 0) { //大于等于0直接可用
this.nextAvail = -1;
return nextAvail;
}
return findNextAvail(); //通常走一步邏輯,只有第一次跟free后nextAvail才可用
}
//找到位圖數(shù)組可用單元,是一個long類型,有[1,64]單元可用
private int findNextAvail() {
final long[] bitmap = this.bitmap;
final int bitmapLength = this.bitmapLength;
for (int i = 0; i < bitmapLength; i ++) {
long bits = bitmap[i];
if (~bits != 0) {
return findNextAvail0(i, bits);
}
}
return -1;
}
//在64的bit中找到一個可用的
private int findNextAvail0(int i, long bits) {
final int maxNumElems = this.maxNumElems;
final int baseVal = i << 6;
for (int j = 0; j < 64; j ++) {
if ((bits & 1) == 0) {
int val = baseVal | j;
if (val < maxNumElems) {
return val;
} else {
break;
}
}
bits >>>= 1;
}
return -1;
}
2.3 PoolSubpage池
第一次申請小內(nèi)存空間的時候,需要先申請一個空閑頁,然后將該頁轉(zhuǎn)成PoolSubpage,再將該頁設(shè)為已被占用,最后再把這個PoolSubpage存到PoolSubpage池中。這樣下次就不需要再去申請空閑頁了,直接去池中找就好了。Netty中有36種PoolSubpage,所以用36個PoolSubpage鏈表表示PoolSubpage池。
#PoolArena
private final PoolSubpage<T>[] tinySubpagePools;
private final PoolSubpage<T>[] smallSubpagePools;
#PoolSubpage
PoolSubpage<T> prev;
PoolSubpage<T> next;

#PoolArena
allocate(...reqCapacity...){
final int normCapacity = normalizeCapacity(reqCapacity);
//找到池的類型跟下標
boolean tiny = isTiny(normCapacity);
if (tiny) { // < 512
tableIdx = tinyIdx(normCapacity);
table = tinySubpagePools;
} else {
tableIdx = smallIdx(normCapacity);
table = smallSubpagePools;
}
final PoolSubpage<T> head = table[tableIdx];
synchronized (head) {
final PoolSubpage<T> s = head.next;
if (s != head) {
//通過PoolSubpage申請
long handle = s.allocate();
...
}
}
}
2.4 PoolChunkList
上面討論了PoolChunk的內(nèi)存分配算法,但是PoolChunk只有16M,這遠遠不夠用,所以會很很多很多PoolChunk,這些PoolChunk組成一個鏈表,然后用PoolChunkList持有這個鏈表
#PoolChunkList
private PoolChunk<T> head;
#PoolChunk
PoolChunk<T> prev;
PoolChunk<T> next;

這里還沒這么簡單,它有6個PoolChunkList,所以將PoolChunk按內(nèi)存使用率分類組成6個PoolChunkList,同時每個PoolChunkList還把各自串起來,形成一個PoolChunkList鏈表。
#PoolChunkList
private final int minUsage; //最小使用率
private final int maxUsage; //最大使用率
private final int maxCapacity;
private PoolChunkList<T> prevList;
private final PoolChunkList<T> nextList;
#PoolArena
//[100,) 每個PoolChunk使用率100%
q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, chunkSize);
//[75,100) 每個PoolChunk使用率75-100%
q075 = new PoolChunkList<T>(this, q100, 75, 100, chunkSize);
//[50,100)
q050 = new PoolChunkList<T>(this, q075, 50, 100, chunkSize);
//[25,75)
q025 = new PoolChunkList<T>(this, q050, 25, 75, chunkSize);
//[1,50)
q000 = new PoolChunkList<T>(this, q025, 1, 50, chunkSize);
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, chunkSize);

既然按使用率分配,那么PoolChunk在使用過程中是會動態(tài)變化的,所以PoolChunk會在不同PoolChunkList中變化。同時申請空間,使用哪一個PoolChunkList也是有先后順序的
#PoolChunkList
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
if (head == null || normCapacity > maxCapacity) {
return false;
}
for (PoolChunk<T> cur = head;;) {
long handle = cur.allocate(normCapacity);
if (handle < 0) {
cur = cur.next;
if (cur == null) {
return false;
}
} else {
cur.initBuf(buf, handle, reqCapacity);
if (cur.usage() >= maxUsage) {
remove(cur);
nextList.add(cur); //移到下一個PoolChunkList中
}
return true;
}
}
}
#PoolArena
allocateNormal(...){
if (q050.allocate(...) || q025.allocate(...) ||
q000.allocate(...) || qInit.allocate(...) ||
q075.allocate(...)) {
return;
}
PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
...
qInit.add(c);
}
這樣設(shè)計的目的是考慮到隨著內(nèi)存的申請與釋放,PoolChunk的內(nèi)存碎片也會相應(yīng)的升高,使用率越高的PoolChunk其申請一塊連續(xù)空間的失敗的概率也會大大的提高。
- qInit前置節(jié)點為自己,且minUsage=Integer.MIN_VALUE,意味著一個初分配的chunk,在最開始的內(nèi)存分配過程中(內(nèi)存使用率<25%),即使完全釋放也不會被回收,會始終保留在內(nèi)存中。
- q000沒有前置節(jié)點,當一個chunk進入到q000列表,如果其內(nèi)存被完全釋放,則不再保留在內(nèi)存中,其分配的內(nèi)存被完全回收。
- 各個PoolChunkList的區(qū)間是交叉的,這是故意的,因為如果介于一個臨界值的話,PoolChunk會在前后PoolChunkList不停的來回移動。
- 為什么area中ChunkList的分配順序如下面代碼所示,要從q050開始?
4.1 為什么不從q0000開始?
因為永遠從空閑率很高的chunk分配,那么chunk的空閑率就不太可能降為0,chunk自然不會被回收,造成內(nèi)存得到釋放。當負載很大申請了很多的chunk,但是負載降低時chunk又不能及時回收。
4.2 為什么不從qinit開始?
因為qinit得不到釋放。(為什么?????)
4.3 為什么不從q075和q100開始?
q075和q100由于內(nèi)存利用率太高,導(dǎo)致內(nèi)存分配的成功率大大降低,因此放到最后;
if (q050.allocate(...) || q025.allocate(...) ||
q000.allocate(...) || qInit.allocate(...) ||
q075.allocate(...)) {
return;
}
2.5 PoolArena
PoolArena是上述功能的門面,通過PoolArena提供接口供上層使用,屏蔽底層實現(xiàn)細節(jié)。為了減少線程成間的競爭,很自然會提供多個PoolArena。Netty默認會生成2×CPU個PoolArena跟IO線程數(shù)一致。然后第一次使用的時候會找一個使用線程最少的PoolArena
private <T> PoolArena<T> leastUsedArena(PoolArena<T>[] arenas) {
if (arenas == null || arenas.length == 0) {
return null;
}
PoolArena<T> minArena = arenas[0];
for (int i = 1; i < arenas.length; i++) {
PoolArena<T> arena = arenas[i];
if (arena.numThreadCaches.get() < minArena.numThreadCaches.get()) {
minArena = arena;
}
}
return minArena;
}
2.6 本地線程存儲
雖然提供了多個PoolArena減少線程間的競爭,但是難免還是會存在鎖競爭,所以需要利用ThreaLocal進一步優(yōu)化,把已申請的內(nèi)存放入到ThreaLocal自然就沒有競爭了。大體思路是在ThreadLocal里面放一個PoolThreadCache對象,然后釋放的內(nèi)存都放入到PoolThreadCache里面,下次申請先從PoolThreadCache獲取。
但是,如果thread1申請了一塊內(nèi)存,然后傳到thread2在線程釋放,這個Netty在內(nèi)存holder對象里面會引用PoolThreadCache,所以還是會釋放到thread1里

3. 性能測試
可以寫兩個簡單的測試用例,感受一下Netty內(nèi)存池帶來的效果。
- 申請10000000個HeapBuffer,DirectBuffer,池化的DirectBuffer花的時間, 可以看出池化效果非常明顯,而且十分平和
| capacity | HeapBuffer | DirectBuffer | 池化的DirectBuffer |
|---|---|---|---|
| 64Byte | 465 | 13211 | 2059 |
| 256Byte | 946 | 15074 | 2309 |
| 512Byte | 2528 | 19516 | 2188 |
| 1024Byte | 4393 | 21928 | 2044 |
- 啟一個DiscardServer,然后發(fā)送80G的數(shù)據(jù),看下GC次數(shù),效果感人
| 非池化 | 池化 | 池化+COMPOSITE_CUMULATOR |
|---|---|---|
| 208 | 27 | 0 |
參考
Netty內(nèi)存池實現(xiàn)
Netty-內(nèi)存管理
Netty 4 at Twitter: Reduced GC Overhead