Netty 中的記憶體分配淺析

rickiyang發表於2020-06-12

Netty 出發點作為一款高效能的 RPC 框架必然涉及到頻繁的記憶體分配銷燬操作,如果是在堆上分配記憶體空間將會觸發頻繁的GC,JDK 在1.4之後提供的 NIO 也已經提供了直接直接分配堆外記憶體空間的能力,但是也僅僅是提供了基本的能力,建立、回收相關的功能和效率都很簡陋。基於此,在堆外記憶體使用方面,Netty 自己實現了一套建立、回收堆外記憶體池的相關功能。基於此我們一起來看一下 Netty 是如何實現記憶體分配的。

1. Netty 中的資料容器分類

談到資料儲存肯定要說到記憶體分配,按照儲存空間來劃分,可以分為 堆記憶體 和 堆外記憶體;按照記憶體區域連貫性來劃分可以分為池化記憶體和非池化記憶體。這些劃分在 Netty 中的實現介面分別是:

按照底層儲存空間劃分:

  • 堆緩衝區:HeapBuffer;
  • 直接緩衝區:DirectBuffer。

按照是否池化劃分:

  • 池化:PooledBuffer;
  • 非池化:UnPooledBuffer。

預設使用 PoolDireBuf 型別的記憶體, 這些記憶體主要由 PoolArea 管理。另外 Netty 並不是直接對外暴露這些 API,提供了 Unsafe 類作為出口暴露資料分配的相關操作。

小知識:

什麼是池化?

一般申請記憶體是檢查當前記憶體哪裡有適合當前資料塊大小的空閒記憶體塊,如果有就將資料儲存在當前記憶體塊中。

那麼池化想做的事情是:既然每次來資料都要去找記憶體地址來存,我就先申請一塊記憶體地址,這一塊就是我的專用空間,記憶體分配、回收我全權管理。

池化解決的問題:

記憶體碎片:

內碎片

內碎片就是申請的地址空間大於真正資料使用的記憶體空間。比如固定申請1M的空間作為某個執行緒的使用記憶體,但是該執行緒每次最多隻佔用0.5M,那麼每次都有0.5M的碎片。如果該空間不被有效回收時間一長必然存在記憶體空洞。

外碎片

外碎片是指多個記憶體空間合併的時候發現不夠分配給待使用的空間大小。比如有一個 20byte,13byte 的連續記憶體空間可以被回收,現在有一個 48byte 的資料塊需要儲存,而這兩個加起來也只有 33byte 的空間,必然不會被使用到。

如何實現記憶體池?

  1. 連結串列維護空閒記憶體地址

    最簡單的就是弄一個連結串列來維護當前空閒的記憶體空間地址。如果有使用就從連結串列刪除,有釋放就加入連結串列對應位置。這種方式實現簡單,但是搜尋和釋放記憶體維護的難度還是比較大,不太適合。

  2. 定長記憶體空間分配

    維護兩個列表,一個是未分配記憶體列表,一個是已分配記憶體列表。每個記憶體塊都是一樣大小,分配時如果不夠就將多個塊合併到一起。這種方式的缺點就是會浪費一定的記憶體空間,如果有特定的場景還是沒有問題。

  3. 多段定長池分配

    在上面的定長分配基礎上,由原來的固定一個長度分配空間變為按照不同物件大小(8,16,32,64,128,256,512,1k…64K),的方式分配多個固定大小的記憶體池。每次要申請記憶體的時候按照當前物件大小去對應的池中查詢是否有剩餘空間。

    Linux 本身支援動態記憶體分配和釋放,對應的命令為:malloc/free。malloc 的全稱是 memory allocation,中文叫動態記憶體分配,用於申請一塊連續的指定大小的記憶體塊區域以void*型別返回分配的記憶體區域地址

    malloc / free的實現過程:

    1. 空閒儲存空間以空閒連結串列的方式組織(地址遞增),每個塊包含一個長度、一個指向下一塊的指標以及一個指向自身儲存空間的指標。( 因為程式中的某些地方可能不通過 malloc 呼叫申請,因此 malloc 管理的空間不一定連續)
    2. 當有申請請求時,malloc 會掃描空閒連結串列,直到找到一個足夠大的塊為止(首次適應)(因此每次呼叫malloc 時並不是花費了完全相同的時間)
    3. 如果該塊恰好與請求的大小相符,則將其從連結串列中移走並返回給使用者。如果該塊太大,則將其分為兩部分,尾部的部分分給使用者,剩下的部分留在空閒連結串列中(更改頭部資訊)。因此 malloc 分配的是一塊連續的記憶體。
    4. 釋放時首先搜尋空閒連結串列,找到可以插入被釋放塊的合適位置。如果與被釋放塊相鄰的任一邊是一個空閒塊,則將這兩個塊合為一個更大的塊,以減少記憶體碎片。

2. Netty 中的記憶體分配

Netty 採用了 jemalloc 的思想,這是 FreeBSD 實現的一種併發 malloc 的演算法。jemalloc 依賴多個 Arena(分配器) 來分配記憶體,執行中的應用都有固定數量的多個 Arena,預設的數量與處理器的個數有關。系統中有多個 Arena 的原因是由於各個執行緒進行記憶體分配時競爭不可避免,這可能會極大的影響記憶體分配的效率,為了緩解高併發時的執行緒競爭,Netty 允許使用者建立多個分配器(Arena)來分離鎖,提高記憶體分配效率。

執行緒首次分配/回收記憶體時,首先會為其分配一個固定的 Arena。執行緒選擇 Arena 時使用 round-robin 的方式,也就是順序輪流選取。

每個執行緒各種儲存 Arena 和快取池資訊,這樣可以減少競爭並提高訪問效率。Arena 將記憶體分為很多 Chunk 進行管理,Chunk 內部儲存 Page,以頁為單位申請。申請記憶體分配時,會將分配的規格分為幾類:TINY,SAMLL,NORMAL 和 HUGE,分別對應不同的範圍,處理過程也不相同。

4

tiny 代表了大小在 0-512B 的記憶體塊;

small 代表了大小在 512B-8K 的記憶體塊;

normal 代表了大小在 8K-16M 的記憶體塊;

huge 代表了大於 16M 的記憶體塊。

每個塊裡面又定義了更細粒度的單位來分配資料:

  • Chunk:一個 Chunk 的大小是 16M,Chunk 是 Netty 對作業系統進行記憶體申請的單位,後續所有的記憶體分配都是在 Chunk 裡面進行操作。
  • Page:Chunk 內部以 Page 為單位分配記憶體,一個 Page 大小為 8K。當我們需要 16K 的空間時,Netty 就會從一個 Chunk 中找到兩個 Page 進行分配。
  • Subpage 和 element:element 是比 Page 更小的單位,當我們申請小於 8K 的記憶體時,Netty 會以 element 為單位進行記憶體分配。element 沒有固定大小,具體由使用者的需求決定。Netty 通過 Subpage 管理 element,Subpage 是由 Page 轉變過來的。當我們需要 1K 的空間時,Netty 會把一個 Page 變成 Subpage,然後把 Subpage 分成 8 個 1K 的 element 進行分配。

Chunk 中的記憶體分配

執行緒分配記憶體主要從兩個地方分配: PoolThreadCache 和 Arena。其中 PoolThreadCache 執行緒獨享, Arena 為幾個執行緒共享。

5

初次申請記憶體的時候,Netty 會從一整塊記憶體(Chunk)中分出一部分來給使用者使用,這部分工作是由 Arena 來完成。而當使用者使用完畢釋放記憶體的時候,這些被分出來的記憶體會按不同規格大小放在 PoolThreadCache 中快取起來。當下次要申請記憶體的時候,就會先從 PoolThreadCache 中找。

Chunk、Page、Subpage 和 element 都是 Arena 中的概念,Arena 的工作就是從一整塊記憶體中分出合適大小的記憶體塊。Arena 中最大的記憶體單位是 Chunk,這是 Netty 向作業系統申請記憶體的單位。而一塊 Chunk(16M) 申請下來之後,內部會被分成 2048 個 Page(8K),當使用者向 Netty 申請超過 8K 記憶體的時候,Netty 會以 Page 的形式分配記憶體。

Chunk 內部通過夥伴演算法管理 Page,具體實現為一棵完全平衡二叉樹:

6

二叉樹中所有子節點管理的記憶體也屬於其父節點。當我們要申請大小為 16K 的記憶體時,我們會從根節點開始不斷尋找可用的節點,一直到第 10 層。那麼如何判斷一個節點是否可用呢?Netty 會在每個節點內部儲存一個值,這個值代表這個節點之下的第幾層還存在未分配的節點。比如第 9 層的節點的值如果為 9,就代表這個節點本身到下面所有的子節點都未分配;如果第 9 層的節點的值為 10,代表它本身不可被分配,但第 10 層有子節點可以被分配;如果第 9 層的節點的值為 12,此時可分配節點的深度大於了總深度,代表這個節點及其下面的所有子節點都不可被分配。下圖描述了分配的過程:

7

對於小記憶體(小於4096)的分配還會將 Page 細化成更小的單位 Subpage。Subpage 按大小分有兩大類:

  1. Tiny:小於 512 的情況,最小空間為 16,對齊大小為 16,區間為[16,512),所以共有 32 種情況。
  2. Small:大於等於 512 的情況,總共有四種,512,1024,2048,4096。

PoolSubpage 中直接採用點陣圖管理空閒空間(因為不存在申請 k 個連續的空間),所以申請釋放非常簡單。

第一次申請小記憶體空間的時候,需要先申請一個空閒頁,然後將該頁轉成 PoolSubpage,再將該頁設為已被佔用,最後再把這個 PoolSubpage 存到 PoolSubpage 池中。這樣下次就不需要再去申請空閒頁了,直接去池中找就好了。Netty 中有 36 種 PoolSubpage,所以用 36 個 PoolSubpage 連結串列表示 PoolSubpage 池。

因為單個 PoolChunk 只有 16M,這遠遠不夠用,所以會很很多很多 PoolChunk,這些 PoolChunk 組成一個連結串列,然後用 PoolChunkList 持有這個連結串列。

我們先從記憶體分配器 PoolArena 來分析 Netty 中的記憶體是如何分配的,Area 的工作就是從一整塊記憶體中協調如何分配合適大小的記憶體給當前資料使用。PoolArena 是 Netty 的記憶體池實現抽象類,其內部子類為 HeapArena 和 DirectArena,HeapArena 對應堆記憶體(heap buffer),DirectArena 對應堆外直接記憶體(direct buffer),兩者除了操作的記憶體(byte[]和ByteBuffer)不同外其餘完全一致。

從結構上來看,PoolArena 中主要包含三部分子記憶體池:

tinySubpagePools;

smallSubpagePools;

一系列的 PoolChunkList。

tinySubpagePools 和 smallSubpagePools 都是 PoolSubpage 的陣列,陣列長度分別為 32 和 4。

PoolChunkList 則主要是一個容器,其內部可以儲存一系列的 PoolChunk 物件,並且,Netty 會根據記憶體使用率的不同,將 PoolChunkList 分為不同等級的容器。

abstract class PoolArena<T> implements PoolArenaMetric {

   enum SizeClass {
        Tiny,
        Small,
        Normal
    }
  // 該引數指定了tinySubpagePools陣列的長度,由於tinySubpagePools每一個元素的記憶體塊差值為16,
	// 因而陣列長度是512/16,也即這裡的512 >>> 4
  static final int numTinySubpagePools = 512 >>> 4;
	//表示該PoolArena的allocator
  final PooledByteBufAllocator parent;
  //表示PoolChunk中由Page節點構成的二叉樹的最大高度,預設11
  private final int maxOrder;
  //page的大小,預設8K
  final int pageSize;
  // 指定了葉節點大小8KB是2的多少次冪,預設為13,該欄位的主要作用是,在計算目標記憶體屬於二叉樹的
	// 第幾層的時候,可以藉助於其記憶體大小相對於pageShifts的差值,從而快速計算其所在層數
  final int pageShifts;
  //預設16MB
  final int chunkSize;
  // 由於PoolSubpage的大小為8KB=8196,因而該欄位的值為
	// -8192=>=> 1111 1111 1111 1111 1110 0000 0000 0000
	// 這樣在判斷目標記憶體是否小於8KB時,只需要將目標記憶體與該數字進行與操作,只要操作結果等於0,
	// 就說明目標記憶體是小於8KB的,這樣就可以判斷其是應該首先在tinySubpagePools或smallSubpagePools
	// 中進行記憶體申請
  final int subpageOverflowMask;
  // 該引數指定了smallSubpagePools陣列的長度,預設為4
  final int numSmallSubpagePools;
  //tinySubpagePools用來分配小於512 byte的Page
  private final PoolSubpage<T>[] tinySubpagePools;
  //smallSubpagePools用來分配大於等於512 byte且小於pageSize記憶體的Page
  private final PoolSubpage<T>[] smallSubpagePools;
  //用來儲存用來分配給大於等於pageSize大小記憶體的PoolChunk
  //儲存記憶體利用率50-100%的chunk
  private final PoolChunkList<T> q050;
  //儲存記憶體利用率25-75%的chunk
  private final PoolChunkList<T> q025;
  //儲存記憶體利用率1-50%的chunk
  private final PoolChunkList<T> q000;
  //儲存記憶體利用率0-25%的chunk
  private final PoolChunkList<T> qInit;
  //儲存記憶體利用率75-100%的chunk
  private final PoolChunkList<T> q075;
  //儲存記憶體利用率100%的chunk
  private final PoolChunkList<T> q100;
	//堆記憶體(heap buffer)
  static final class HeapArena extends PoolArena<byte[]> {
  
  }
   //堆外直接記憶體(direct buffer)
  static final class DirectArena extends PoolArena<ByteBuffer> {
    
  }
  
  
}

如上所示,PoolArena 是由多個 PoolChunk 組成的大塊記憶體區域,而每個 PoolChun k則由多個 Page 組成。當需要分配的記憶體小於 Page 的時候,為了節約記憶體採用 PoolSubpage 實現小於 Page 大小記憶體的分配。在PoolArena 中為了保證 PoolChunk 空間的最大利用化,按照 PoolArena 中各 個PoolChunk 已使用的空間大小將其劃分為 6 類:

  1. qInit:儲存記憶體利用率 0-25% 的 chunk;
  2. q000:儲存記憶體利用率 1-50% 的 chunk;
  3. q025:儲存記憶體利用率 25-75% 的 chunk;
  4. q050:儲存記憶體利用率 50-100% 的 chunk;
  5. q075:儲存記憶體利用率 75-100%的 chunk;
  6. q100:儲存記憶體利用率 100%的 chunk。

PoolArena 維護了一個 PoolChunkList 組成的雙向連結串列,每個 PoolChunkList 內部維護了一個 PoolChunk 雙向連結串列。分配記憶體時,PoolArena 通過在 PoolChunkList 找到一個合適的 PoolChunk,然後從 PoolChunk 中分配一塊記憶體。

下面來看 PoolArena 是如何分配記憶體的:

private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
  // 將需要申請的容量格式為 2^N
  final int normCapacity = normalizeCapacity(reqCapacity);
  // 判斷目標容量是否小於8KB,小於8KB則使用tiny或small的方式申請記憶體
  if (isTinyOrSmall(normCapacity)) { // capacity < pageSize
    int tableIdx;
    PoolSubpage<T>[] table;
    boolean tiny = isTiny(normCapacity);
    // 判斷目標容量是否小於512位元組,小於512位元組的為tiny型別的
    if (tiny) { // < 512
      // 將分配區域轉移到 tinySubpagePools 中
      if (cache.allocateTiny(this, buf, reqCapacity, normCapacity)) {
        // was able to allocate out of the cache so move on
        return;
      }
      // 如果無法從當前執行緒快取中申請到記憶體,則嘗試從tinySubpagePools中申請,這裡tinyIdx()方法
      // 就是計算目標記憶體是在tinySubpagePools陣列中的第幾號元素中的
      tableIdx = tinyIdx(normCapacity);
      table = tinySubpagePools;
    } else {
      // 如果目標記憶體在512byte~8KB之間,則嘗試從smallSubpagePools中申請記憶體。這裡首先從
      // 當前執行緒的快取中申請small級別的記憶體,如果申請到了,則直接返回
      if (cache.allocateSmall(this, buf, reqCapacity, normCapacity)) {
        // was able to allocate out of the cache so move on
        return;
      }
      tableIdx = smallIdx(normCapacity);
      table = smallSubpagePools;
    }
		// 獲取目標元素的頭結點
    final PoolSubpage<T> head = table[tableIdx];

    // 這裡需要注意的是,由於對head進行了加鎖,而在同步程式碼塊中判斷了s != head,
    // 也就是說PoolSubpage連結串列中是存在未使用的PoolSubpage的,因為如果該節點已經用完了,
    // 其是會被移除當前連結串列的。也就是說只要s != head,那麼這裡的allocate()方法
    // 就一定能夠申請到所需要的記憶體塊
    synchronized (head) {
      // s != head就證明當前PoolSubpage連結串列中存在可用的PoolSubpage,並且一定能夠申請到記憶體,
      // 因為已經耗盡的PoolSubpage是會從連結串列中移除的
      final PoolSubpage<T> s = head.next;
      // 如果此時 subpage 已經被分配過記憶體了執行下文,如果只是初始化過,則跳過該分支
      if (s != head) {
        // 從PoolSubpage中申請記憶體
        assert s.doNotDestroy && s.elemSize == normCapacity;
        // 通過申請的記憶體對ByteBuf進行初始化
        long handle = s.allocate();
        assert handle >= 0;
        // 初始化 PoolByteBuf 說明其位置被分配到該區域,但此時尚未分配記憶體
        s.chunk.initBufWithSubpage(buf, handle, reqCapacity);
				// 對tiny型別的申請數進行更新
        if (tiny) {
          allocationsTiny.increment();
        } else {
          allocationsSmall.increment();
        }
        return;
      }
    }
    // 走到這裡,說明目標PoolSubpage連結串列中無法申請到目標記憶體塊,因而就嘗試從PoolChunk中申請
    allocateNormal(buf, reqCapacity, normCapacity);
    return;
  }
   // 走到這裡說明目標記憶體是大於8KB的,那麼就判斷目標記憶體是否大於16M,如果大於16M,
  // 則不使用記憶體池對其進行管理,如果小於16M,則到PoolChunkList中進行記憶體申請
  if (normCapacity <= chunkSize) {
    // 小於16M,首先到當前執行緒的快取中申請,如果申請到了則直接返回,如果沒有申請到,
    // 則到PoolChunkList中進行申請
    if (cache.allocateNormal(this, buf, reqCapacity, normCapacity)) {
      // was able to allocate out of the cache so move on
      return;
    }
    allocateNormal(buf, reqCapacity, normCapacity);
  } else {
    // 對於大於16M的記憶體,Netty不會對其進行維護,而是直接申請,然後返回給使用者使用
    allocateHuge(buf, reqCapacity);
  }
}

所有記憶體分配的 size 都會經過 normalizeCapacity() 進行處理,申請的容量總是會被格式為 2^N。主要規則如下:

  1. 如果目標容量小於 16 位元組,則返回 16;
  2. 如果目標容量大於 16 位元組,小於 512 位元組,則以 16 位元組為單位,返回大於目標位元組數的第一個 16 位元組的倍數。比如申請的 100 位元組,那麼大於 100 的 16 整數倍最低為: 16 * 7 = 112,因而返回 112;
  3. 如果目標容量大於 512 位元組,則返回大於目標容量的第一個 2 的指數冪。比如申請的 1000 位元組,那麼返回的將是:2^10 = 1024。

PoolArena 提供了兩種方式進行記憶體分配:

  1. PoolSubpage 用於分配小於 8k 的記憶體

    • tinySubpagePools:用於分配小於 512 位元組的記憶體,預設長度為 32,因為記憶體分配最小為 16,每次增加16,直到512,區間[16,512)一共有 32 個不同值;
    • smallSubpagePools:用於分配大於等於 512 位元組的記憶體,預設長度為 4;
  • tinySubpagePools 和 smallSubpagePools 中的元素預設都是 subpage。
  1. poolChunkList 用於分配大於 8k 的記憶體

    上面已經解釋了 q 開頭的幾個變數用於儲存大於 8k 的資料。

預設先嚐試從 poolThreadCache 中分配記憶體,PoolThreadCache 利用 ThreadLocal 的特性,消除了多執行緒競爭,提高記憶體分配效率;

首次分配時,poolThreadCache 中並沒有可用記憶體進行分配,當上一次分配的記憶體使用完並釋放時,會將其加入到 poolThreadCache 中,提供該執行緒下次申請時使用。

如果是分配小記憶體,則嘗試從 tinySubpagePools 或 smallSubpagePools 中分配記憶體,如果沒有合適 subpage,則採用方法 allocateNormal 分配記憶體。

如果分配一個 page 以上的記憶體,直接採用方法 allocateNormal() 分配記憶體,allocateNormal()則會將申請動作交由 PoolChunkList 進行。

private synchronized void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
  //如果在對應的PoolChunkList能申請到記憶體,則返回
  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)) {
    ++allocationsNormal;
    return;
  }

  // Add a new chunk.
  PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
  long handle = c.allocate(normCapacity);
  ++allocationsNormal;
  assert handle > 0;
  c.initBuf(buf, handle, reqCapacity);
  qInit.add(c);
}

首先將申請動作按照 q050->q025->q000->qInit->q075 的順序依次交由各個 PoolChunkList 進行處理,如果在對應的 PoolChunkList 中申請到了記憶體,則直接返回。

如果申請不到,那麼直接建立一個新的 PoolChunk,然後在該 PoolChunk 中申請目標記憶體,最後將該 PoolChunk 新增到 qInit 中。

上面說過 Chunk 是 Netty 向作業系統申請記憶體塊的最大單位,每個 Chunk 是16M,PoolChunk 內部通過 memoryMap 陣列維護了一顆完全平衡二叉樹作為管理底層記憶體分佈及回收的標記位,所有的子節點管理的記憶體也屬於其父節點。

關於 PoolChunk 內部如何維護完全平衡二叉樹就不在這裡展開,大家有興趣可以自行看原始碼。

對於記憶體的釋放,PoolArena 主要是分為兩種情況,即池化和非池化,如果是非池化,則會直接銷燬目標記憶體塊,如果是池化的,則會將其新增到當前執行緒的快取中。如下是 free()方法的原始碼:

public void free(PoolChunk<T> chunk, ByteBuffer nioBuffer, long handle, int normCapacity,
     PoolThreadCache cache) {
  // 如果是非池化的,則直接銷燬目標記憶體塊,並且更新相關的資料
  if (chunk.unpooled) {
    int size = chunk.chunkSize();
    destroyChunk(chunk);
    activeBytesHuge.add(-size);
    deallocationsHuge.increment();
  } else {
    // 如果是池化的,首先判斷其是哪種型別的,即tiny,small或者normal,
    // 然後將其交由當前執行緒的快取進行處理,如果新增成功,則直接返回
    SizeClass sizeClass = sizeClass(normCapacity);
    if (cache != null && cache.add(this, chunk, nioBuffer, handle,
          normCapacity, sizeClass)) {
      return;
    }

    // 如果當前執行緒的快取已滿,則將目標記憶體塊返還給公共記憶體塊進行處理
    freeChunk(chunk, handle, sizeClass, nioBuffer);
  }
}

相關文章