Netty內存池之PoolChunk原理詳解

PoolChunk是Netty內存池中的重要組成部分,其做用主要在於維護了一個較大的內存塊,當須要申請超過8KB的內存時,就會從PoolChunk中獲取。本文首先會對PoolChunk的總體結構進行講解,而後會講解其各個主要屬性的做用,最後會從源碼的角度對PoolChunk是如何實現對大塊內存的申請和釋放的。算法

1. PoolChunk總體結構

       PoolChunk默認申請的內存大小是16M,在結構上,其會將這16M內存組織成爲一顆平衡二叉樹,二叉樹的每一層每一個節點所表明的內存大小都是均等的,而且每一層節點所表明的內存大小總和加起來都是16M,整顆二叉樹的總層數爲12,層號從0開始。其結構示意圖以下:數組

圖1-1

       關於上圖,咱們主要有以下幾點須要說明:緩存

  • 一個PoolChunk佔用的總內存是16M,其會按照當前二叉樹所在的層級將16M內存進行劃分,好比第1層將其劃分爲兩個8M,第二層將其劃分爲4個4M等等,整顆二叉樹最多有12層,於是每一個葉節點的內存大小爲8KB,也就是說,PoolChunk可以分配的內存最少爲8KB,最多爲16M;
  • 能夠看出,圖中的二叉樹葉子節點有2^11=2048個,於是整顆樹的節點數有4095個。PoolChunk將這4095個節點平鋪到了一個長度爲4096的數組上,其第1號位存儲了0,第2~3號位存儲了1,第4~7號位存儲了2,依次類推,總體上其實就是將這棵以層號表示的二叉樹存入了一個數組中。這裏的數組就是左邊的depthMap,經過這棵二叉樹,能夠快速經過下標獲得其層數,好比2048號位置的值爲11,表示其在二叉樹的第11層。depthMap的結構以下圖所示:

image.png

  • 在圖中二叉樹的每一個節點上,咱們爲當前節點所表明的內存大小標記了一個數字,這個數字其實就表示了當前節點所可以分配的內存大小,好比0表明16M,1表明了8M等等。這些數字就是由memoryMap來存儲的,表示二叉樹中每一個節點表明的可分配內存大小,其數據結構與depthMap徹底同樣。圖中,每個父節點所表明的可分配內存大小都等於兩個子節點的和,若是某個子節點的內存已經被分配了,那麼該節點就會被標記爲12,表示已分配,而它們的父節點則會被更新爲另外一個子節點的值,表示父節點可分配的內存就是其兩個子節點所能提供的內存之和;
  • 對於PoolChunk對內存的申請和釋放的總體流程,咱們以申請的是9KB的內存進行講述:
    • 首先將9KB=9126拓展爲大於其的第一個2的指數,也就是2<<13=16384,因爲葉節點8KB=2 << 12,其對應的層數爲11,於是16384所在的層數爲10,也就是說從內存池中找到一個第10層的未分配的節點便可;
    • 得出目標節點在第10層後,這裏就會從頭結點開始比較,若是頭結點所存儲的值比10要小,那麼就說明其有足夠的內存用於分配目標內存,而後就會將其左子節點與10進行比較,若是左子節點比10要大(通常此時左子節點已經被分配出去了,其值爲12,於是會比10大),那麼就會將右子節點與10進行比較,此時右子節點確定比10小,那麼就會從右子節點的左子節點開始繼續上面的比較;
    • 當比較到某一個時刻,有一個節點的數字與10相等,就說明這個位置就是咱們所須要的內存塊,那麼就會將其標註爲12,而後遞歸的回溯,將其父節點所表明的內存值更新爲其另外一個子節點的值;

image.png

  • 關於內存的分配,這裏須要說明的最後一個問題就是,經過上面的計算方式,咱們能夠找到一個節點做爲咱們目標要分配的節點,此時就須要將此節點所表明的內存起始地址和長度返回。因爲咱們只有整個PoolChunk所申請的16M內存的地址值,而經過目標節點所在的層號和其是該層第幾個節點就能夠計算出該節點相對於整個內存塊起始地址的偏移量,從而就能夠獲得該節點的起始地址值;關於該節點所佔用的內存長度,直觀的感受能夠理解爲一個映射,好比11表明8KB長度,10表明16KB長度等等。固然這裏的起始地址和偏移量的計算,PoolChunk並非經過這種算法直接實現的,而是經過更高效的位運算來實現的。

2. PoolChunk主要屬性的做用

       在閱讀Netty內存池源碼的時候,相信大多數讀者都會被其各類紛繁複雜的屬性所混淆,從而感受閱讀起來艱澀難懂。這裏咱們單獨將其屬性列出來,以方便讀者在閱讀源碼時可以更快的理解其各個屬性的做用。數據結構

// netty內存池總的數據結構,該類咱們後續會對其進行講解
final PoolArena<T> arena;
// 當前申請的內存塊,好比對於堆內存,T就是一個byte數組,對於直接內存,T就是ByteBuffer,
// 但不管是哪一種形式,其內存大小都默認是16M
final T memory;
// 指定當前是否使用內存池的方式進行管理
final boolean unpooled;
// 表示當前申請的內存塊中有多大一部分是用於站位使用的,整個內存塊的大小是16M+offset,默認該值爲0
final int offset;
// 存儲了當前表明內存池的二叉樹的各個節點的內存使用狀況,該數組長度爲4096,二叉樹的頭結點在該數組的
// 第1號位,存儲的值爲0;兩個一級子節點在該數組的第2號位和3號位,存儲的值爲1,依次類推。二叉樹的葉節點
// 個數爲2048,於是總節點數爲4095。在進行內存分配時,會從頭結點開始比較,而後比較左子節點,而後比較右
// 子節點,直到找到可以表明目標內存塊的節點。當某個節點所表明的內存被申請以後,該節點的值就會被標記爲12,
// 表示該節點已經被佔用
private final byte[] memoryMap;
// 這裏depthMap存儲的數據結構與memoryMap是徹底同樣的,只不過其值在初始化以後一直不會發生變化。
// 該數據的主要做用在於經過目標索引位置值找到其在整棵樹中對應的層數
private final byte[] depthMap;
// 這裏每個PoolSubPage表明了二叉樹的一個葉節點,也就是說,當二叉樹葉節點內存被分配以後,
// 其會使用一個PoolSubPage對其進行封裝
private final PoolSubpage<T>[] subpages;

// 其值爲-8192,二進制表示爲11111111111111111110000000000000,它的後面0的個數正好爲12,而2^12=8192,
// 於是將其與用戶但願申請的內存大小進行「與操做「,若是其值不爲0,就表示用戶但願申請的內存在8192之上,從而
// 就能夠快速判斷其是在經過PoolSubPage的方式進行申請仍是經過內存計算的方式。
private final int subpageOverflowMask;
// 記錄了每一個業節點內存的大小,默認爲8192,即8KB
private final int pageSize;
// 頁節點所表明的偏移量,默認爲13,主要做用是計算目標內存在內存池中是在哪一個層中,具體的計算公式爲:
// int d = maxOrder - (log2(normCapacity) - pageShifts);
// 好比9KB,通過log2(9KB)獲得14,maxOrder爲11,計算就獲得10,表示9KB內存在內存池中爲第10層的數據
private final int pageShifts;
// 默認爲11,表示當前你最大的層數
private final int maxOrder;
// 記錄了當前整個PoolChunk申請的內存大小,默認爲16M
private final int chunkSize;
// 將chunkSize取2的對數,默認爲24
private final int log2ChunkSize;
// 指定了表明葉節點的PoolSubPage數組所須要初始化的長度
private final int maxSubpageAllocs;

// 指定了某個節點若是已經被申請,那麼其值將被標記爲unusable所指定的值
private final byte unusable;
// 對建立的ByteBuffer進行緩存的一個隊列
private final Deque<ByteBuffer> cachedNioBuffers;

// 記錄了當前PoolChunk中還剩餘的可申請的字節數
private int freeBytes;

// 在Netty的內存池中,全部的PoolChunk都是由當前PoolChunkList進行組織的,
// 關於PoolChunkList和其前置節點以及後置節點咱們會在後續進行講解,本文主要專一於PoolChunk的講解
PoolChunkList<T> parent;
// 在PoolChunkList中當前PoolChunk的前置節點
PoolChunk<T> prev;
// 在PoolChunkList中當前PoolChunk的後置節點
PoolChunk<T> next;

3. 源碼實現

       關於PoolChunk的功能,咱們這裏主要對其內存的分配和回收過程進行講解。this

3.1 內存分配

       PoolChunk的內存分配主要在其allocate()方法中,而分配的總體描述前面已經進行了講解,這裏再也不贅述,咱們直接進入其源碼進行閱讀:spa

boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
  final long handle;
  // 這裏subpageOverflowMask=-8192,經過判斷的結果能夠看出目標容量是否小於8KB。
  // 在下面的兩個分支邏輯中,都會返回一個long型的handle,一個long佔8個字節,其由低位的4個字節和高位的
  // 4個字節組成,低位的4個字節表示當前normCapacity分配的內存在PoolChunk中所分配的節點在整個memoryMap
  // 數組中的下標索引;而高位的4個字節則表示當前須要分配的內存在PoolSubPage所表明的8KB內存中的位圖索引。
  // 對於大於8KB的內存分配,因爲其不會使用PoolSubPage來存儲目標內存,於是高位四個字節的位圖索引爲0,
  // 而低位的4個字節則仍是表示目標內存節點在memoryMap中的位置索引;
  // 對於低於8KB的內存分配,其會使用一個PoolSubPage來表示整個8KB內存,於是須要一個位圖索引來表示目標內存
  // 也即normCapacity會佔用PoolSubPage中的哪一部分的內存。
  if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
    // 申請高於8KB的內存
    handle = allocateRun(normCapacity);
  } else {
    // 申請低於8KB的內存
    handle = allocateSubpage(normCapacity);
  }

  // 若是返回的handle小於0,則表示要申請的內存大小超過了當前PoolChunk所可以申請的最大大小,也即16M,
  // 於是返回false,外部代碼則會直接申請目標內存,而不禁當前PoolChunk處理
  if (handle < 0) {
    return false;
  }
  
  // 這裏會從緩存的ByteBuf對象池中獲取一個ByteBuf對象,不存在則返回null
  ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null;
  // 經過申請到的內存數據對獲取到的ByteBuf對象進行初始化,若是ByteBuf爲null,則建立一個新的而後進行初始化
  initBuf(buf, nioBuffer, handle, reqCapacity);
  return true;
}

       能夠看到對於內存的分配,主要會判斷其是否大於8KB,若是大於8KB,則會直接在PoolChunk的二叉樹中年進行分配,若是小於8KB,則會直接申請一個8KB的內存,而後將8KB的內存交由一個PoolSubpage進行維護。關於PoolSubpage的實現原理,咱們後續會進行講解,這裏咱們只是對其進行簡單的講解,以幫助讀者理解位圖索引的概念。當咱們從PoolChunk的二叉樹中申請到了8KB內存以後,會將其交由一個PoolSubpage進行維護。在PoolSubpage中,其會將整個內存塊大小切分爲一系列的16字節大小,這裏就是8KB,也就是說,它將被切分爲512 = 8KB / 16byte份。爲了標識這每一份是否被佔用,PoolSubpage使用了一個long型數組來表示,該數組的名稱爲bitmap,於是咱們稱其爲位圖數組。爲了表示512份數據是否被佔用,而一個long只有64個字節,於是這裏就須要8 = 512 / 64個long來表示,於是這裏使用的的是long型數組,而不是單獨的一個long字段。可是讀者應該發現了,這裏的handle高32位是一個整型值,而咱們描述的bitmap是一個long型數組,那麼一個整型是如何表示當前申請的內存是bitmap數組中的第幾號元素,以及該元素中整型64字節中的第幾位的。這裏其實就是經過整型的低67位來表示的,第64~67位用來表示當前是佔用的bitmap數組中的第幾號long型元素,而1~64位則用來表示該long型元素的第多少位是當前申請佔用的。於是這裏只須要一個長整型的handle便可表示當前申請到的內存在整個內存池中的位置,以及在PoolSubpage中的位置。這裏咱們首先閱讀allocateRun()方法的源碼:netty

private long allocateRun(int normCapacity) {
  // 這裏maxOrder爲11,表示整棵樹最大的層數,log2(normCapacity)會將申請的目標內存大小轉換爲大於該大小的
  // 第一個2的指數次冪數而後取2的對數的形式,好比log2(9KB)轉換以後爲14,這是由於大於9KB的第一個2的指數
  // 次冪爲16384,將其取2的對數後爲14。pageShifts默認爲13,這裏整個表達式的目的就是快速計算出申請目標
  // 內存(normCapacity)須要對應的層數。
  int d = maxOrder - (log2(normCapacity) - pageShifts);
  // 經過前面講的遞歸方式從先父節點,而後左子節點,接着右子節點的方式依次判斷其是否與目標層數相等,
  // 若是相等,則會將該節點所對應的在memoryMap數組中的位置索引返回
  int id = allocateNode(d);
  // 若是返回值小於0,則說明在當前PoolChunk中沒法分配目標大小的內存,這通常是因爲目標內存大於16M,
  // 或者當前PoolChunk已經分配了過多的內存,剩餘可分配的內存不足以分配目標內存大小致使的
  if (id < 0) {
    return id;
  }
  
  // 更新剩餘可分配內存的值
  freeBytes -= runLength(id);
  return id;
}

       這裏allocateRun()方法首先會計算目標內存所對應的二叉樹層數,而後遞歸的在二叉樹中查找是否有對應的節點,找到了則直接返回。這裏咱們繼續看allocateNode()方法看其是如何對二叉樹進行遞歸遍歷的:code

private int allocateNode(int d) {
  int id = 1;
  int initial = -(1 << d);
  // 獲取memoryMap中索引爲id的位置的數據層數,初始時獲取的就是根節點的層數
  byte val = value(id);
  // 若是更節點的層數值都比d要大,說明當前PoolChunk中沒有足夠的內存用於分配目標內存,直接返回-1
  if (val > d) {
    return -1;
  }
  
  // 這裏就是經過比較當前節點的值是否比目標節點的值要小,若是要小,則說明當前節點所表明的子樹是可以
  // 分配目標內存大小的,則會繼續遍歷其左子節點,而後遍歷右子節點
  while (val < d || (id & initial) == 0) {
    id <<= 1;
    val = value(id);
    // 這裏val > d其實就是表示當前節點的數值比目標數值要大,也就是說當前節點是無法申請到目標容量的內存,
    // 那麼就會執行 id ^= 1,其實也就是將id切換到當前節點的兄弟節點,本質上其實就是從二叉樹的
    // 左子節點開始查找,若是左子節點沒法分配目標大小的內存,那麼就到右子節點進行查找
    if (val > d) {
      id ^= 1;
      val = value(id);
    }
  }
  
  // 當找到以後,獲取該節點所在的層數
  byte value = value(id);
  // 將該memoryMap中該節點位置的值設置爲unusable=12,表示其已經被佔用
  setValue(id, unusable);
  // 遞歸的更新父節點的值,使其繼續保持」父節點存儲的層數所表明的內存大小是未分配的
  // 子節點的層數所表明的內存之和「的語義。
  updateParentsAlloc(id);
  return id;
}

       這裏allocateNode()方法主要邏輯就是查找目標內存在memoryMap中的索引下標值,而且對所申請的節點的父節點值進行更新。下面咱們來看看allocateSubpage()的實現原理:orm

private long allocateSubpage(int normCapacity) {
  // 這裏其實也是與PoolThreadCache中存儲PoolSubpage的方式相同,也是採用分層的方式進行存儲的,
  // 具體是取目標數組中哪個元素的PoolSubpage則是根據目標容量normCapacity來進行的。
  PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
  int d = maxOrder;
  synchronized (head) {
    // 這裏調用allocateNode()方法在二叉樹中查找時,傳入的d值maxOrder=11,也就是說,其自己就是
    // 直接在葉節點上查找可用的葉節點位置
    int id = allocateNode(d);
    // 小於0說明沒有符合條件的內存塊
    if (id < 0) {
      return id;
    }

    final PoolSubpage<T>[] subpages = this.subpages;
    final int pageSize = this.pageSize;

    freeBytes -= pageSize;

    // 計算當前id對應的PoolSubpage數組中的位置
    int subpageIdx = subpageIdx(id);
    PoolSubpage<T> subpage = subpages[subpageIdx];
    // 這裏主要是經過一個PoolSubpage對申請到的內存塊進行管理,具體的管理方式咱們後續文章中會進行講解。
    if (subpage == null) {
      // 這裏runOffset()方法會返回該id在PoolChunk中維護的字節數組中的偏移量位置,
      // normCapacity則記錄了當前將要申請的內存大小;
      // pageSize記錄了每一個頁的大小,默認爲8KB
      subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
      subpages[subpageIdx] = subpage;
    } else {
      subpage.init(head, normCapacity);
    }
    
    // 經過PoolSubpage申請一塊內存,而且返回表明該內存塊的位圖索引,位圖索引的具體計算方式,
    // 咱們前面已經簡要講述,詳細的實現原理咱們後面會進行講解。
    return subpage.allocate();
  }
}

       這裏能夠看到,allocateSubpage()方法主要是將申請到的8KB內存交由一個PoolSubpage進行管理,而且由其返回響應的位圖索引。這裏關於handle參數的產生方式已經講解完成,關於allocate()方法中initBuf()方法的調用,其原理比較簡單,本質上就是首先計算申請到的內存塊的起始位置地址值,以及申請的內存塊的長度,而後將其設置到一個ByteBuf對象中,以對其進行初始化,這裏再也不贅述其實現原理。對象

3.2 內存釋放

       關於內存釋放的原理,其比較簡單,經過前面的講解,咱們能夠看到,內存的申請就是在主內存塊中查找能夠申請的內存塊,而後將表明其位置的好比層號,或者位圖索引標誌爲已經分配。那麼這裏的釋放過程其實就是返回來,而後將這些標誌進行重置。這裏咱們以直接內存(ByteBuffer)的釋放過程講解內存釋放的源碼:

void free(long handle, ByteBuffer nioBuffer) {
  int memoryMapIdx = memoryMapIdx(handle);	// 根據當前內存塊在memoryMap數組中的位置
  int bitmapIdx = bitmapIdx(handle);	// 獲取當前內存塊的位圖索引

  // 若是位圖索引不等於0,說明當前內存塊是小於8KB的內存塊,於是將其釋放過程交由PoolSubpage進行
  if (bitmapIdx != 0) {
    PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
    PoolSubpage<T> head = arena.findSubpagePoolHead(subpage.elemSize);
    synchronized (head) {
      // 由PoolSubpage釋放內存
      if (subpage.free(head, bitmapIdx & 0x3FFFFFFF)) {
        return;
      }
    }
  }
  
  // 走到這裏說明須要釋放的內存大小大於8KB,這裏首先計算要釋放的內存塊的大小
  freeBytes += runLength(memoryMapIdx);
  // 將要釋放的內存塊所對應的二叉樹的節點對應的值進行重置
  setValue(memoryMapIdx, depth(memoryMapIdx));
  // 將要釋放的內存塊所對應的二叉樹的各級父節點的值進行更新
  updateParentsFree(memoryMapIdx);

  // 將建立的ByteBuf對象釋放到緩存池中,以便下次申請時複用
  if (nioBuffer != null && cachedNioBuffers != null &&
      cachedNioBuffers.size() < PooledByteBufAllocator
        .DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
    cachedNioBuffers.offer(nioBuffer);
  }
}

       能夠看到,這裏對內存的釋放,主要是判斷其是否小於8KB,若是低於8KB,則將其交由PoolSubpage進行處理,不然就經過二叉樹的方式對其進行重置。

4. 小結

       本文首先對PoolChunk的總體結構進行了講解,而且詳細講解了PoolChunk中平衡二叉樹的實現原理。而後對PoolChunk中各個屬性值進行了描述,以幫助讀者後續閱讀源碼時更容易理解。最後咱們對PoolChunk的兩個主要功能:內存分配和釋放的實現原理進行了詳細講解。

相關文章
相關標籤/搜索