昨天,我徹底搞懂了Netty內存分配策略!

Netty 作爲一款高性能的 RPC 框架必然涉及到頻繁的內存分配銷燬操作。

圖片來自 Pexels

如果是在堆上分配內存空間將會觸發頻繁的 GC,JDK 在 1.4 之後提供的 NIO 也已經提供了直接直接分配堆外內存空間的能力,但是也僅僅是提供了基本的能力,創建、回收相關的功能和效率都很簡陋。

基於此,在堆外內存使用方面,Netty 自己實現了一套創建、回收堆外內存池的相關功能。基於此我們一起來看一下 Netty 是如何實現內存分配的。

Netty 中的數據容器分類

談到數據保存肯定要說到內存分配,按照存儲空間來劃分,可以分爲堆內存和堆外內存;按照內存區域連貫性來劃分可以分爲池化內存和非池化內存。這些劃分在 Netty 中的實現接口分別如下。

按照底層存儲空間劃分:

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

按照是否池化劃分:

  • 池化:PooledBuffer
  • 非池化:UnPooledBuffer

默認使用 PoolDireBuf 類型的內存,這些內存主要由 PoolArea 管理。另外 Netty 並不是直接對外暴露這些 API,提供了 Unsafe 類作爲出口暴露數據分配的相關操作。

什麼是池化?

一般申請內存是檢查當前內存哪裏有適合當前數據塊大小的空閒內存塊,如果有就將數據保存在當前內存塊中。

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

池化解決的問題:內存碎片。

內碎片:就是申請的地址空間大於真正數據使用的內存空間。

比如固定申請 1M 的空間作爲某個線程的使用內存,但是該線程每次最多隻佔用 0.5M,那麼每次都有 0.5M 的碎片。如果該空間不被有效回收時間一長必然存在內存空洞。

外碎片:是指多個內存空間合併的時候發現不夠分配給待使用的空間大小。

比如有一個 20byte,13byte 的連續內存空間可以被回收,現在有一個 48byte 的數據塊需要存儲,而這兩個加起來也只有 33byte 的空間,必然不會被使用到。

如何實現內存池?

①鏈表維護空閒內存地址

最簡單的就是弄一個鏈表來維護當前空閒的內存空間地址。如果有使用就從鏈表刪除,有釋放就加入鏈表對應位置。

這種方式實現簡單,但是搜索和釋放內存維護的難度還是比較大,不太適合。

②定長內存空間分配

維護兩個列表,一個是未分配內存列表,一個是已分配內存列表。每個內存塊都是一樣大小,分配時如果不夠就將多個塊合併到一起。

這種方式的缺點就是會浪費一定的內存空間,如果有特定的場景還是沒有問題。

③多段定長池分配

在上面的定長分配基礎上,由原來的固定一個長度分配空間變爲按照不同對象大小(8,16,32,64,128,256,512,1k…64K),的方式分配多個固定大小的內存池。

每次要申請內存的時候按照當前對象大小去對應的池中查找是否有剩餘空間。

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

malloc/free 的實現過程:

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

Netty 中的內存分配

Netty 採用了 jemalloc 的思想,這是 FreeBSD 實現的一種併發 malloc 的算法。

jemalloc 依賴多個 Arena(分配器)來分配內存,運行中的應用都有固定數量的多個 Arena,默認的數量與處理器的個數有關。

系統中有多個 Arena 的原因是由於各個線程進行內存分配時競爭不可避免,這可能會極大的影響內存分配的效率,爲了緩解高併發時的線程競爭,Netty 允許使用者創建多個分配器(Arena)來分離鎖,提高內存分配效率。

線程首次分配/回收內存時,首先會爲其分配一個固定的 Arena。線程選擇 Arena 時使用 round-robin 的方式,也就是順序輪流選取。

每個線程各種保存 Arena 和緩存池信息,這樣可以減少競爭並提高訪問效率。Arena 將內存分爲很多 Chunk 進行管理,Chunk 內部保存 Page,以頁爲單位申請。

申請內存分配時,會將分配的規格分爲如下四類,分別對應不同的範圍,處理過程也不相同:

  • 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 爲幾個線程共享。

初次申請內存的時候,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,具體實現爲一棵完全平衡二叉樹:

二叉樹中所有子節點管理的內存也屬於其父節點。當我們要申請大小爲 16K 的內存時,我們會從根節點開始不斷尋找可用的節點,一直到第 10 層。

那麼如何判斷一個節點是否可用呢?Netty 會在每個節點內部保存一個值,這個值代表這個節點之下的第幾層還存在未分配的節點。

比如第 9 層的節點的值如果爲 9,就代表這個節點本身到下面所有的子節點都未分配。

如果第 9 層的節點的值爲 10,代表它本身不可被分配,但第 10 層有子節點可以被分配。

如果第 9 層的節點的值爲 12,此時可分配節點的深度大於了總深度,代表這個節點及其下面的所有子節點都不可被分配。

下圖描述了分配的過程:

對於小內存(小於 4096)的分配還會將 Page 細化成更小的單位 Subpage。

Subpage 按大小分有兩大類:

  • Tiny:小於 512 的情況,最小空間爲 16,對齊大小爲 16,區間爲[16,512),所以共有 32 種情況。
  • 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 組成的大塊內存區域,而每個 PoolChunk 則由多個 Page 組成。

當需要分配的內存小於 Page 的時候,爲了節約內存採用 PoolSubpage 實現小於 Page 大小內存的分配。

在 PoolArena 中爲了保證 PoolChunk 空間的最大利用化,按照 PoolArena 中各 個 PoolChunk 已使用的空間大小將其劃分爲六類:

  • qInit:存儲內存利用率 0-25% 的 chunk
  • q000:存儲內存利用率 1-50% 的 chunk
  • q025:存儲內存利用率 25-75% 的 chunk
  • q050:存儲內存利用率 50-100% 的 chunk
  • q075:存儲內存利用率 75-100%的 chunk
  • 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。

主要規則如下:

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

PoolArena 提供了兩種方式進行內存分配:

①PoolSubpage 用於分配小於 8k 的內存

tinySubpagePools:用於分配小於 512 字節的內存,默認長度爲 32,因爲內存分配最小爲 16,每次增加 16,直到 512,區間 [16,512) 一共有 32 個不同值。

smallSubpagePools:用於分配大於等於 512 字節的內存,默認長度爲 4。tinySubpagePools 和 smallSubpagePools 中的元素默認都是 subpage。

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

 

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