天天看點

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

作者:架構思考
Netty作為一款高性能網絡應用程式架構,實作了一套高性能記憶體管理機制。通過學習其中的實作原理、算法、并發設計,有利于我們寫出更優雅、更高性能的代碼;當使用Netty時碰到記憶體方面的問題時,也可以更高效定位排查出來。本文基于Netty4.1.43.Final介紹其中的記憶體管理機制。歡迎閱讀~

一、ByteBuf 分類

Netty使用ByteBuf對象作為資料容器,進行I/O讀寫操作,Netty的記憶體管理也是圍繞着ByteBuf對象高效地配置設定和釋放。

當讨論ByteBuf對象管理,主要從以下方面進行分類:

  • Pooled 和 Unpooled

Unpooled,非池化記憶體每次配置設定時直接調用系統 API 向作業系統申請ByteBuf需要的同樣大小記憶體,用完後通過系統調用進行釋放Pooled,池化記憶體配置設定時基于預配置設定的一整塊大記憶體,取其中的部分封裝成ByteBuf提供使用,用完後回收到記憶體池中。

tips: Netty4預設使用Pooled的方式,可通過參數-Dio.netty.allocator.type=unpooled或pooled進行設定。
  • Heap 和 Direct

    Heap,指ByteBuf關聯的記憶體JVM堆内配置設定,配置設定的記憶體受GC 管理。

    Direct,指ByteBuf關聯的記憶體在JVM堆外配置設定,配置設定的記憶體不受GC管理,需要通過系統調用實作申請和釋放,底層基于Java NIO的DirectByteBuffer對象。

note: 使用堆外記憶體的優勢在于,Java進行I/O操作時,需要傳入資料所在緩沖區起始位址和長度,由于GC的存在,對象在堆中的位置往往會發生移動,導緻對象位址變化,系統調用出錯。為避免這種情況,當基于堆記憶體進行I/O系統調用時,需要将記憶體拷貝到堆外,而直接基于堆外記憶體進行I/O操作的話,可以節省該拷貝成本。

二、池化(Pooled)對象管理

非池化對象(Unpooled),使用和釋放對象僅需要調用底層接口實作,池化對象實作則複雜得多,可以帶着以下問題進行研究:

  • 記憶體池管理算法是如何實作高效記憶體配置設定釋放,減少記憶體碎片。
  • 高負載下記憶體池不斷申請/釋放,如何實作彈性伸縮。
  • 記憶體池作為全局資料,在多線程環境下如何減少鎖競争。

1 算法設計

1.1 整體原理

Netty先向系統申請一整塊連續記憶體,稱為chunk,預設大小chunkSize = 16Mb,通過PoolChunk對象包裝。為了更細粒度的管理,Netty将chunk進一步拆分為page,預設每個chunk包含2048個page(pageSize = 8Kb)。

不同大小池化記憶體對象的配置設定政策不同,下面首先介紹申請記憶體大小在(pageSize/2, chunkSize]區間範圍内的池化對象的配置設定原理,其他大對象和小對象的配置設定原理後面再介紹。在同一個chunk中,Netty将page按照不同粒度進行多層分組管理:

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理
  • 第1層,分組大小size = 1*pageSize,一共有2048個組。
  • 第2層,分組大小size = 2*pageSize,一共有1024個組。
  • 第3層,分組大小size = 4*pageSize,一共有512個組。

    ...

當請求配置設定記憶體時,将請求配置設定的記憶體數向上取值到最接近的分組大小,在該分組大小的相應層級中從左至右尋找空閑分組例如請求配置設定記憶體對象為1.5 pageSize,向上取值到分組大小2 pageSize,在該層分組中找到完全空閑的一組記憶體進行配置設定,如下圖:

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

當分組大小2 pageSize的記憶體配置設定出去後,為了友善下次記憶體配置設定,分組被标記為全部已使用(圖中紅色标記),向上更粗粒度的記憶體分組被标記為部分已使用*(圖中黃色标記)。

1.2 算法結構

Netty基于平衡樹實作上面提到的不同粒度的多層分組管理。

當需要建立一個給定大小的ByteBuf,算法需要在PoolChunk中大小為chunkSize的記憶體中,找到第一個能夠容納申請配置設定記憶體的位置。

為了友善快速查找chunk中能容納請求記憶體的位置,算法建構一個基于byte數組(memoryMap)存儲的完全平衡樹,該平衡樹的多個層級深度,就是前面介紹的按照不同粒度對chunk進行多層分組:

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

樹的深度depth從0開始計算,各層節點數,每個節點對應的記憶體大小如下:

depth = 0, 1 node,nodeSize = chunkSize
depth = 1, 2 nodes,nodeSize = chunkSize/2
...
depth = d, 2^d nodes, nodeSize = chunkSize/(2^d)
...
depth = maxOrder, 2^maxOrder nodes, nodeSize = chunkSize/2^{maxOrder} = pageSize           

樹的最大深度為maxOrder(最大階,預設值11),通過這棵樹,算法在chunk中的查找就可以轉換為:

當申請配置設定大小為chunkSize/2^k的記憶體,在平衡樹高度為k的層級中,從左到右搜尋第一個空閑節點。

數組的使用域從index = 1開始,将平衡樹按照層次順序依次存儲在數組中,depth = n的第1個節點儲存在memoryMap[2^n] 中,第2個節點儲存在memoryMap[2^n+1]中,以此類推(下圖代表已配置設定chunkSize/2)。

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

可以根據memoryMap[id]的值得出節點的使用情況,memoryMap[id]值越大,剩餘的可用記憶體越少。

  • memoryMap[id] = depth_of_id:**id節點空閑**, 初始狀态,depth_of_id的值代表id節點在樹中的深度。
  • memoryMap[id] = maxOrder + 1:**id節點全部已使用**,節點記憶體已完全配置設定,沒有一個子節點空閑。
  • depthofid < memoryMap[id] < maxOrder + 1:**id節點部分已使用**,memoryMap[id] 的值 x,代表**id的子節點中,第一個空閑節點位于深度x,在深度[depth_of_id, x)的範圍内沒有任何空閑節點**。

1.3 申請/釋放記憶體

當申請配置設定記憶體,會首先将請求配置設定的記憶體大小歸一化(向上取值),通過PoolArena#normalizeCapacity()方法,取最近的2的幂的值,例如8000byte歸一化為8192byte( chunkSize/2^11 ),8193byte歸一化為16384byte(chunkSize/2^10)。

處理記憶體申請的算法在PoolChunk#allocateRun方法中,當配置設定已歸一化處理後大小為chunkSize/2^d的記憶體,即需要在depth = d的層級中找到第一塊空閑記憶體,算法從根節點開始周遊 (根節點depth = 0, id = 1),具體步驟如下:

  • 步驟1 判斷是否目前節點值memoryMap[id] > d

    如果是,則無法從該chunk配置設定記憶體,查找結束。

  • 步驟2 判斷是否節點值memoryMap[id] == d,且depth_of_id == h

    如果是,目前節點是depth = d的空閑記憶體,查找結束,更新目前節點值為memoryMap[id] = max_order + 1,代表節點已使用,并周遊目前節點的所有祖先節點,更新節點值為各自的左右子節點值的最小值;如果否,執行步驟3。

  • 步驟3 判斷是否目前節點值memoryMap[id] <= d,且depth_of_id < h

    如果是,則空閑節點在目前節點的子節點中,則先判斷左子節點memoryMap[2 * id] <=d(判斷左子節點是否可配置設定),如果成立,則目前節點更新為左子節點,否則更新為右子節點,然後重複步驟2。

參考示例如下圖,申請配置設定了chunkSize/2的記憶體

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理
note:圖中雖然index = 2的子節點memoryMap[id] = depth_of_id,但實際上節點記憶體已配置設定,因為算法是從上往下開始周遊,是以在實際進行中,節點配置設定記憶體後僅更新祖先節點的值,并沒有更新子節點的值。

釋放記憶體時,根據申請記憶體傳回的id,将 memoryMap[id]更新為depth_of_id,同時設定id節點的祖先節點值為各自左右節點的最小值。

1.4 巨型對象記憶體管理

對于申請配置設定大小超過chunkSize的巨型對象(huge),Netty采用的是非池化管理政策,在每次請求配置設定記憶體時單獨建立特殊的非池化PoolChunk對象進行管理,内部memoryMap為null,當對象記憶體釋放時整個Chunk記憶體釋放,相應記憶體申請邏輯在PoolArena#allocateHuge()方法中,釋放邏輯在PoolArena#destroyChunk()方法中。

1.5 小對象記憶體管理

當請求對象的大小reqCapacity <= 496,歸一化計算後方式是向上取最近的16的倍數,例如15規整為16、40規整為48、490規整為496,規整後的大小(normalizedCapacity)小于pageSize的小對象可分為2類:微型對象(tiny):規整後為16的整倍數,如16、32、48、...、496,一共31種規格小型對象(small):規整後為2的幂的,有512、1024、2048、4096,一共4種規格。

這些小對象直接配置設定一個page會造成浪費,在page中進行平衡樹的标記又額外消耗更多空間,是以Netty的實作是:先PoolChunk中申請空閑page,同一個page分為相同大小規格的小記憶體進行存儲。

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

這些page用PoolSubpage對象進行封裝,PoolSubpage内部有記錄記憶體規格大小(elemSize)、可用記憶體數量(numAvail)和各個小記憶體的使用情況,通過long[]類型的bitmap相應bit值0或1,來記錄記憶體是否已使用。

note:應該有讀者注意到,Netty申請池化記憶體進行歸一化處理後的值更大了,例如1025byte會歸一化為2048byte,8193byte歸一化為16384byte,這樣是不是造成了一些浪費?可以了解為是一種取舍,通過歸一化處理,使池化記憶體配置設定大小規格化,大大友善記憶體申請和記憶體、記憶體複用,提高效率。

2 彈性伸縮

前面的算法原理部分介紹了Netty如何實作記憶體塊的申請和釋放,單個chunk比較容量有限,如何管理多個chunk,建構成能夠彈性伸縮記憶體池?

2.1 PoolChunk管理

為了解決單個PoolChunk容量有限的問題,Netty将多個PoolChunk組成連結清單一起管理,然後用PoolChunkList對象持有連結清單的head。

将所有PoolChunk組成一個連結清單的話,進行周遊查找管理效率較低,是以Netty設計了PoolArena對象(arena中文是舞台、場所),實作對多個PoolChunkList、PoolSubpage的管理,線程安全控制、對外提供記憶體配置設定、釋放的服務。

PoolArena内部持有6個PoolChunkList,各個PoolChunkList持有的PoolChunk的使用率區間不同:

// 容納使用率 (0,25%) 的PoolChunk
private final PoolChunkList<T> qInit;
// [1%,50%) 
private final PoolChunkList<T> q000;
// [25%, 75%) 
private final PoolChunkList<T> q025;
// [50%, 100%) 
private final PoolChunkList<T> q050;
// [75%, 100%) 
private final PoolChunkList<T> q075;
// 100% 
private final PoolChunkList<T> q100;           
「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

6個PoolChunkList對象組成雙向連結清單,當PoolChunk記憶體配置設定、釋放,導緻使用率變化,需要判斷PoolChunk是否超過所在PoolChunkList的限定使用率範圍,如果超出了,需要沿着6個PoolChunkList的雙向連結清單找到新的合适PoolChunkList,成為新的head;同樣的,當建立PoolChunk并配置設定完記憶體,該PoolChunk也需要按照上面邏輯放入合适的PoolChunkList中

配置設定歸一化記憶體normCapacity(大小範圍在[pageSize, chunkSize]) 具體處理如下:

  • 按順序依次通路q050、q025、q000、qInit、q075,周遊PoolChunkList内PoolChunk連結清單判斷是否有PoolChunk能配置設定記憶體。
  • 如果上面5個PoolChunkList有任意一個PoolChunk記憶體配置設定成功,PoolChunk使用率發生變更,重新檢查并放入合适的PoolChunkList中,結束。
  • 否則建立一個PoolChunk,配置設定記憶體,放入合适的PoolChunkList中(PoolChunkList擴容)。
note:可以看到配置設定記憶體依次優先在q050 -> q025 -> q000 -> qInit -> q075的PoolChunkList的内配置設定,這樣做的好處是,使配置設定後各個區間記憶體使用率更多處于[75,100)的區間範圍内,提高PoolChunk記憶體使用率的同時也兼顧效率,減少在PoolChunkList中PoolChunk的周遊。

當PoolChunk記憶體釋放,同樣PoolChunk使用率發生變更,重新檢查并放入合适的PoolChunkList中,如果釋放後PoolChunk記憶體使用率為0,則從PoolChunkList中移除,釋放掉這部分空間,避免在高峰的時候申請過記憶體一直緩存在池中(PoolChunkList縮容)。

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

PoolChunkList的額定使用率區間存在交叉,這樣設計是因為如果基于一個臨界值的話,當PoolChunk記憶體申請釋放後的記憶體使用率在臨界值上下徘徊的話,會導緻在PoolChunkList連結清單前後來回移動。

2.2 PoolSubpage管理

PoolArena内部持有2個PoolSubpage數組,分别存儲tiny和small規格類型的PoolSubpage:

// 數組長度32,實際使用域從index = 1開始,對應31種tiny規格PoolSubpage
private final PoolSubpage<T>[] tinySubpagePools;
// 數組長度4,對應4種small規格PoolSubpage
private final PoolSubpage<T>[] smallSubpagePools;           

相同規格大小(elemSize)的PoolSubpage組成連結清單,不同規格的PoolSubpage連結清單的head則分别儲存在tinySubpagePools 或者 smallSubpagePools數組中,如下圖:

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

當需要配置設定小記憶體對象到PoolSubpage中時,根據歸一化後的大小,計算出需要通路的PoolSubpage連結清單在tinySubpagePools和smallSubpagePools數組的下标,通路連結清單中的PoolSubpage的申請記憶體配置設定,如果通路到的PoolSubpage連結清單節點數為0,則建立新的PoolSubpage配置設定記憶體然後加傳入連結表。

PoolSubpage連結清單存儲的PoolSubpage都是已配置設定部分記憶體,當記憶體全部配置設定完或者記憶體全部釋放完的PoolSubpage會移對外連結表,減少不必要的連結清單節點;當PoolSubpage記憶體全部配置設定完後再釋放部分記憶體,會重新将加傳入連結表。

PoolArean記憶體池彈性伸縮可用下圖總結:

「後端」支撐百萬級并發,Netty如何實作高性能記憶體管理

3 并發設計

記憶體配置設定釋放不可避免地會遇到多線程并發場景,無論是PoolChunk的平衡樹标記或者PoolSubpage的bitmap标記都是多線程不安全,如何線上程安全的前提下盡量提升并發性能?

首先,為了減少線程間的競争,Netty會提前建立多個PoolArena(預設生成數量 = 2 * CPU核心數),當線程首次請求池化記憶體配置設定,會找被最少線程持有的PoolArena,并儲存線程局部變量PoolThreadCache中,實作線程與PoolArena的關聯綁定(PoolThreadLocalCache#initialValue()方法)。

note:Java自帶的ThreadLocal實作線程局部變量的原理是:基于Thread的ThreadLocalMap類型成員變量,該變量中map的key為ThreadLocal,value-為需要自定義的線程局部變量值。調用ThreadLocal#get()方法時,會通過Thread.currentThread()擷取目前線程通路Thread的ThreadLocalMap中的值。

Netty設計了ThreadLocal的更高性能替代類:FastThreadLocal,需要配套繼承Thread的類FastThreadLocalThread一起使用,基本原理是将原來Thead的基于ThreadLocalMap存儲局部變量,擴充為能更快速通路的數組進行存儲(Object[] indexedVariables),每個FastThreadLocal内部維護了一個全局原子自增的int類型的數組index。

此外,Netty還設計了緩存機制提升并發性能:當請求對象記憶體釋放,PoolArena并沒有馬上釋放,而是先嘗試将該記憶體關聯的PoolChunk和chunk中的偏移位置(handler變量)等資訊存入PoolThreadLocalCache中的固定大小緩存隊列中(如果緩存隊列滿了則馬上釋放記憶體);當請求記憶體配置設定,PoolArena會優先通路PoolThreadLocalCache的緩存隊列中是否有緩存記憶體可用,如果有,則直接配置設定,提高配置設定效率。

總結

Netty池化記憶體管理的設計借鑒了Facebook的jemalloc,同時也與Linux記憶體配置設定算法Buddy算法和Slab算法也有相似之處,很多分布式系統、架構的設計都可以在作業系統的設計中找到原型,學習底層原理是很有價值的。

文章來源:https://zhuanlan.zhihu.com/p/100239049

繼續閱讀