動態

詳情 返回 返回

談一談 Netty 的內存管理 —— 且看 Netty 如何實現 Java 版的 Jemalloc - 動態 詳情

本文基於 Netty 4.1.112.Final 版本進行討論

在之前的 Netty 系列中,筆者是以 4.1.56.Final 版本為基礎和大家討論的,那麼從本文開始,筆者將用最新版本 4.1.112.Final 對 Netty 的相關設計展開解析,之所以這麼做的原因是 Netty 的內存池設計一直在不斷地演進優化。

在 4.1.52.Final 之前 Netty 內存池是基於 jemalloc3 的設計思想實現的,由於在該版本的實現中,內存規格的粒度設計的比較粗,可能會引起比較嚴重的內存碎片問題。所以為了近一步降低內存碎片,Netty 在 4.1.52.Final 版本中重新基於 jemalloc4 的設計思想對內存池進行了重構,通過將內存規格近一步拆分成更細的粒度,以及重新設計了內存分配算法儘量將內存碎片控制在比較小的範圍內。

隨後在 4.1.75.Final 版本中,Netty 為了近一步降低不必要的內存消耗,將 ChunkSize 從原來的 16M 改為了 4M 。而且在默認情況下不在為普通的用户線程提供內存池的 Thread Local 緩存。在兼顧性能的前提下,將不必要的內存消耗盡量控制在比較小的範圍內。

Netty 在後續的版本迭代中,針對內存池這塊的設計,仍然會不斷地伴隨着一些小範圍的優化,由於這些優化點太過細小,瑣碎,筆者就不在一一列出,所以乾脆直接以最新版本 4.1.112.Final 來對內存池的設計與實現展開剖析。

image

1. 一步一圖推演 Netty 內存池總體架構設計

Netty 內存池的整體設計相對來説還是有那麼一點點的複雜,其中涉及到了眾多概念模型,每種模型在架構層面上承擔着不同的職責,模型與模型之間又有着千絲萬縷的聯繫,在面對一個複雜的系統設計時,我們還是按照老套路,從最簡單的設計開始,一步一步的演進,直到還原出內存池的完整樣貌。

因此在本小節中,筆者的着墨重點是在總體架構設計層面上,先把內存池涉及到的這些眾多概念模型為大家梳理清晰,但並不會涉及太複雜的源碼實現細節,讓大家有一個整體完整的認識。有了這個基礎,在本文後續的小節中,我們再來詳細討論源碼的實現細節。

首先第一個登場的模型是 PoolArena , 它是內存池中最為重要的一個概念,整個內存管理的核心實現就是在這裏完成的。

image

PoolArena 有兩個實現,一個是 HeapArena,負責池化堆內內存,另一個是 DirectArena,負責池化堆外內存。和上篇文章一樣,本文我們的重點還是在 Direct Memory 的池化管理上,後續相關的源碼實現,筆者都是以 DirectArena 進行展開。

我們可以直接把 PoolArena 當做一個內存池來看待,當線程在申請 PooledByteBuf 的時候都會到 PoolArena 中去拿。這樣一來就引入一個問題,就是系統中有那麼多的線程,而內存的申請又是非常頻繁且重要的操作,這就導致這麼多的線程頻繁的去爭搶這一個 PoolArena,相關鎖的競爭程度會非常激烈,極大的影響了內存分配的速度。

因此 Netty 設計了多個 PoolArena 來分攤線程的競爭,將線程與 PoolArena 進行綁定來降低鎖的競爭,提高內存分配的並行度。

image

PoolArena 的默認個數為 availableProcessors * 2 , 因為 Netty 中的 Reactor 線程個數默認恰好也是 CPU 核數的兩倍,而內存的分配與釋放在 Reactor 線程中是一個非常高頻的操作,所以這裏將 Reactor 線程與 PoolArena 一對一綁定起來,避免 Reactor 線程之間的相互競爭。

除此之外,我們還可以通過 -Dio.netty.allocator.numHeapArenas 以及 -Dio.netty.allocator.numDirectArenas 來調整系統中 HeapArena 和 DirectArena 的個數。

public class PooledByteBufAllocator {
    // 默認 HeapArena 的個數
    private static final int DEFAULT_NUM_HEAP_ARENA;
    // 默認 DirectArena 的個數
    private static final int DEFAULT_NUM_DIRECT_ARENA;
    static {
        // PoolArena 的默認個數為 availableProcessors * 2
        final int defaultMinNumArena = NettyRuntime.availableProcessors() * 2;
        DEFAULT_NUM_HEAP_ARENA = Math.max(0,
                SystemPropertyUtil.getInt(
                        "io.netty.allocator.numHeapArenas",
                        (int) Math.min(
                                defaultMinNumArena,
                                runtime.maxMemory() / defaultChunkSize / 2 / 3)));

        DEFAULT_NUM_DIRECT_ARENA = Math.max(0,
                SystemPropertyUtil.getInt(
                        "io.netty.allocator.numDirectArenas",
                        (int) Math.min(
                                defaultMinNumArena,
                                PlatformDependent.maxDirectMemory() / defaultChunkSize / 2 / 3)));
    }
}

但事實上,系統中的線程不光只有 Reactor 線程這一種,還有 FastThreadLocalThread 類型的線程,以及普通 Thread 類型的用户線程,位於 Reactor 線程之外的 FastThreadLocalThread , UserThread 在運行起來之後會脱離 Reactor 線程自己單獨向 PoolArena 來申請內存。

所以無論是什麼類型的線程,在它運行起來之後,當第一次向內存池申請內存的時候,都會採用 Round-Robin 的方式與一個固定的 PoolArena 進行綁定,後續在線程整個生命週期中的內存申請以及釋放等操作都只會與這個綁定的 PoolArena 進行交互。

所以線程與 PoolArena 的關係是多對一的關係,也就是説一個線程只能綁定到一個固定的 PoolArena 上,而一個 PoolArena 卻可以被多個線程綁定。

image

這樣一來雖然線程與 PoolArena 產生了綁定,在很大程度上降低了竟爭同一 PoolArena 的激烈程度,但仍然會存在競爭的情況。那這種微小的競爭會帶來什麼影響呢 ?

針對內存池的場景,比如現在有兩個線程:Thread1 和 Thread2 ,它倆共同綁定到了同一個 PoolArena 上,Thread1 首先向 PoolArena 申請了一個內存塊,並加載到運行它的 CPU1 L1 Cache 中,Thread1 使用完之後將這個內存塊釋放回 PoolArena。

假設此時 Thread2 向 PoolArena 申請同樣尺寸的內存塊,而且恰好申請到了剛剛被 Thread1 釋放的內存塊。注意,此時這個內存塊已經在 CPU1 L1 Cache 中緩存了,運行 Thread2 的 CPU2 L1 Cache 中並沒有,這就涉及到了 cacheline 的核間通信(MESI 協議相關),又要耗費幾十個時鐘週期。

為了極致的性能,我們能不能做到無鎖化呢 ?近一步把 cacheline 核間通信的這部分開銷省去。

這就需要引入內存池的第二個模型 —— PoolThreadCache ,作為線程的 Thread Local 緩存,它用於緩存線程從 PoolArena 中申請到的內存塊,線程每次申請內存的時候首先會到 PoolThreadCache 中查看是否已經緩存了相應尺寸的內存塊,如果有,則直接從 PoolThreadCache 獲取,如果沒有,再到 PoolArena 中去申請。同理,線程每次釋放內存的時候,也是先釋放到 PoolThreadCache 中,而不會直接釋放回 PoolArena 。

這樣一來,我們通過為每個線程引入 Thread Local 本地緩存 —— PoolThreadCache,實現了內存申請與釋放的無鎖化,同時也避免了 cacheline 在多核之間的通信開銷,極大地提升了內存池的性能。

但是這樣又會引來一個問題,就是內存消耗太大了,系統中有那麼多的線程,如果每個線程在向 PoolArena 申請內存的時候,我們都為它默認創建一個 PoolThreadCache 本地緩存的話,這一部分的內存消耗將會特別大。

因此為了近一步降低內存消耗又同時兼顧內存池的性能,在 Netty 的權衡之下,默認只會為 Reactor 線程以及 FastThreadLocalThread 類型的線程創建 PoolThreadCache,而普通的用户線程在默認情況下將不再擁有本地緩存。

image

同時 Netty 也為此提供了一個配置選項 -Dio.netty.allocator.useCacheForAllThreads, 默認為 false 。如果我們將其配置為 true , 那麼 Netty 默認將會為系統中的所有線程分配 PoolThreadCache 。

       DEFAULT_USE_CACHE_FOR_ALL_THREADS = SystemPropertyUtil.getBoolean(
                "io.netty.allocator.useCacheForAllThreads", false);

好了,現在我們已經清楚了內存池的線程模型,那麼接下來大家一定很好奇這個 PoolArena 裏面到底長什麼樣子。 PoolArena 是內存池的核心實現,它裏面管理了各種不同規格的內存塊,PoolArena 的整個數據結構設計都是圍繞着這些內存塊的管理展開的。所以在拆解 PoolArena 之前,我們需要知道 Netty 內存池究竟劃分了哪些規格的內存塊

於是就引入了內存池的第三個模型 —— SizeClasses ,Netty 的內存池也是按照內存頁 page 進行內存管理的,不過與 OS 不同的是,在 Netty 中一個 page 的大小默認為 8k,我們可以通過 -Dio.netty.allocator.pageSize 調整 page 大小,但最低只能調整到 4k,而且 pageSize 必須是 2 的次冪。

// 8k
int defaultPageSize = SystemPropertyUtil.getInt("io.netty.allocator.pageSize", 8192);
// 4K
private static final int MIN_PAGE_SIZE = 4096;

Netty 內存池最小的管理單位是 page , 而內存池單次向 OS 申請內存的單位是 Chunk,一個 Chunk 的大小默認為 4M。Netty 用一個 PoolChunk 的結構來管理這 4M 的內存空間。我們可以通過 -Dio.netty.allocator.maxOrder 來調整 chunkSize 的大小(默認為 4M),maxOrder 的默認值為 9 ,最大值為 14。

// 9
int defaultMaxOrder = SystemPropertyUtil.getInt("io.netty.allocator.maxOrder", 9);

// 8196 << 9 = 4M
final int defaultChunkSize = DEFAULT_PAGE_SIZE << DEFAULT_MAX_ORDER;

// 1G
private static final int MAX_CHUNK_SIZE = (int) (((long) Integer.MAX_VALUE + 1) / 2);

我們看到 ChunkSize 的大小是由 PAGE_SIZE 和 MAX_ORDER 共同決定的 —— PAGE_SIZE << MAX_ORDER,當 pageSize 為 8K 的時候,chunkSize 最大不能超過 128M,無論 pageSize 配置成哪種大小,最大的 chunkSize 不能超過 1G。

Netty 在向 OS 申請到一個 PoolChunk 的內存空間(4M)之後,會通過 SizeClasses 近一步將這 4M 的內存空間切分成 68 種規格的內存塊來進行池化管理。其中最小規格的內存塊為 16 字節,最大規格的內存塊為 4M 。也就是説,Netty 的內存池只提供如下 68 種內存規格來讓用户申請。

image

除此之外,Netty 又將這 68 種內存規格分為了三類:

  1. [16B , 28K] 這段範圍內的規格被劃分為 Small 規格
  2. [32K , 4M] 這段範圍內的規格被劃分為 Normal 規格
  3. 超過 4M 的內存規格被劃分為 Huge 規格

image

其中 Small 和 Normal 規格的內存塊會被內存池(PoolArena)進行池化管理,Huge 規格的內存塊不會被內存池管理,當我們向內存池申請 Huge 規格的內存塊時,內存池是直接向 OS 申請內存,釋放的時候也是直接釋放回 OS ,內存池並不會緩存這些 Huge 規格的內存塊。

abstract class PoolArena<T> {
    enum SizeClass {
        Small,
        Normal
    }
}

image

那麼接下來的問題就是 Small 和 Normal 這兩種規格的內存塊在 PoolArena 中是如何被管理起來的呢 ?前面我們提到,在 Netty 內存池中,內存管理的基本單位是 Page , 一個 Page 的內存規格是 8K ,這個是內存管理的基礎,而 Small , Normal 這兩種規格是在這個基礎之上進行管理的。

所以我們首先需要弄清楚 Netty 是如何管理這些以 Page 為粒度的內存塊的,這就引入了內存池的第四個模型 —— PoolChunk 。PoolChunk 的設計參考了 Linux 內核中的夥伴系統,在內核中,內存管理的基本單位也是 Page(4K),這些 Page 會按照夥伴的形式被內核組織在夥伴系統中。

內核中的夥伴指的是大小相同並且在物理內存上連續的兩個或者多個 page(個數必須是 2 的次冪)。

image

如上圖所示,內核中夥伴系統的核心數據結構就是這個 struct free_area 類型的數組 —— free_area[MAX_ORDER]。

struct zone {
    // 夥伴系統的核心數據結構
    struct free_area    free_area[MAX_ORDER];
}

數組 free_area[MAX_ORDER] 中的索引表示的是分配階 order,這個 order 用於指定對應 free_area 結構中組織管理的內存塊包含多少個 page。比如 free_area[0] 中管理的內存塊都是一個一個的 Page , free_area[1] 中管理的內存塊尺寸是 2 個 Page , free_area[10] 中管理的內存塊尺寸為 1024 個 Page。

這些相同尺寸的內存塊在 struct free_area 結構中是通過 struct list_head 結構類型的雙向鏈表統一組織起來的。

struct free_area {
 struct list_head free_list[MIGRATE_TYPES];
};

struct list_head {
    // 雙向鏈表
    struct list_head *next, *prev;
};

當我們向內核申請 2 ^ order 個 Page 的時候,內核首先會到夥伴系統中的 free_area[order] 對應的雙向鏈表 free_list 中查看是否有空閒的內存塊,如果有則從 free_list 將內存塊摘下並分配出去,如果沒有,則繼續向上到 free_area[order + 1] 中去查找,反覆這個過程,直到在 free_area[order + n] 中的 free_list 鏈表中找到空閒的內存塊。

但是此時我們在 free_area[order + n] 鏈表中找到的空閒內存塊的尺寸是 2 ^ (order + n) 大小,而我們需要的是 2 ^ order 尺寸的內存塊,於是內核會將這 2 ^ (order + n) 大小的內存塊逐級減半分裂,將每一次分裂後的內存塊插入到相應的 free_area 數組裏對應的 free_list 鏈表中,並將最後分裂出的 2 ^ order 尺寸的內存塊分配給進程使用。

假設我們現在要向下圖中的夥伴系統申請一個 Page (對應的分配階 order = 0),那麼內核會在夥伴系統中首先查看 order = 0 對應的空閒鏈表 free_area[0] 中是否有空閒內存塊可供分配。

如果沒有,內核則會根據前邊介紹的內存分配邏輯,繼續升級到 free_area[1] , free_area[2] 鏈表中尋找空閒內存塊,直到查找到 free_area[3] 發現有一個可供分配的內存塊。這個內存塊中包含了 8 個 連續的空閒 page。

image

隨後內核會將 free_area[3] 中的這個空閒內存塊從鏈表中摘下,然後減半分裂成兩個內存塊,分裂出來的這兩個內存塊分別包含 4 個 page(分配階 order = 2)。將第二個內存塊(圖中綠色部分,order = 2),插入到 free_rea[2] 鏈表中。

第一個內存塊(圖中黃色部分,order = 2)繼續減半分裂,分裂出來的這兩個內存塊分別包含 2 個 page(分配階 order = 1)。如上圖中第 4 步所示,前半部分為黃色,後半部分為紫色。同理按照前邊的分裂邏輯,內核會將後半部分內存塊(紫色部分,分配階 order = 1)插入到 free_area[1] 鏈表中。

前半部分(圖中黃色部分,order = 1)在上圖中的第 6 步繼續減半分裂,分裂出來的這兩個內存塊分別包含 1 個 page(分配階 order = 0),前半部分為青色,後半部分為黃色。後半部分插入到 frea_area[0] 鏈表中,前半部分返回給進程,以上就是內核中夥伴系統的內存分配過程。

下面我們繼續來回顧一下內核夥伴系統的內存回收過程,當我們向內核釋放 2 ^ order 個 Page 的時候,內核首先會檢查 free_area[order] 對應的 free_list 中是否有與我們要釋放的內存塊在內存地址上連續的空閒內存塊,如果有地址連續的內存塊,則將兩個內存塊進行合併,然後在到上一級 free_area[order + 1] 中繼續查找是否有空閒內存塊與合併之後的內存塊在地址上連續,如果有則繼續重複上述過程向上合併,如果沒有,則將合併之後的內存塊插入到 free_area[order + 1] 中。

假設我們現在需要將一個編號為 10 的 Page 釋放回下圖所示的夥伴系統中,連續的編號表示內存地址連續。首先內核會在 free_area[0] 中發現有一個空閒的內存塊 page11 與要釋放的 page10 連續,於是將兩個連續的內存塊合併,合併之後的內存塊的分配階 order = 1。

image

隨後內核在 free_area[1] 中發現 page8 和 page9 組成的內存塊與 page10 和 page11 合併後的內存塊是夥伴,於是繼續將這兩個內存塊(分配階 order = 1)繼續合併成一個新的內存塊(分配階 order = 2)。隨後內核會在 free_area[2] 中查找新合併後的內存塊夥伴。

接着內核在 free_area[2] 中發現 page12,page13,page14,page15 組成的內存塊與 page8,page9,page10,page11 組成的新內存塊是夥伴,於是將它們從 free_area[2] 上摘下繼續合併成一個新的內存塊(分配階 order = 3),隨後內核會在 free_area[3] 中查找新內存塊的夥伴。

但在 free_area[3] 中的內存塊(page20 到 page 27)與新合併的內存塊(page8 到 page15)雖然大小相同但是物理上並不連續,所以它們不是夥伴,不能在繼續向上合併了。於是內核將 page8 到 pag15 組成的內存塊(分配階 order = 3)插入到 free_area[3] 中,整個夥伴系統回收內存的過程如下如所示:

image

現在我們已經清楚了夥伴系統在 Linux 內核中的實現,那麼同樣是對 Page 的管理,Netty 中的 PoolChunk 也是一樣,它的實現和內核中的夥伴系統非常相似。PoolChunk 也有一個數組 runsAvail。

final class PoolChunk<T> implements PoolChunkMetric {
    // Netty 的夥伴系統結構
    private final IntPriorityQueue[] runsAvail;
}

和內核中的 free_area 數組一樣,它們裏面都保存了不同 Page 級別的內存塊,不一樣的是內核中的夥伴系統一共只有 11 個 Page 級別的內存塊尺寸,分別是: 1 個 Page , 2 個 Page , 4 個 Page,8 個 Page 一直到 1024 個 Page。內存塊的尺寸必須是 2 的次冪個 Page。

Netty 中的夥伴系統一共有 32 個 Page 級別的內存塊尺寸,這一點我們可以從前面介紹的 SizeClasses 計算出來的內存規格表看得出來。PoolChunk 中管理的這些 Page 級別的內存塊尺寸只要是 Page 的整數倍就可以,而不是內核中要求的 2 的次冪個 Page。

image

因此 runsAvail 數組中一共有 32 個元素,數組下標就是上圖中的 pageIndex , 數組類型為 IntPriorityQueue(優先級隊列),數組中的每一項存儲着所有相同 size 的內存塊,這裏的 size 就是上圖中 pageIndex 對應的 size 。

比如 runsAvail[0] 中存儲的全部是單個 Page 的內存塊,runsAvail[1] 中存儲的全部是尺寸為 2 個 Page 的內存塊,runsAvail[2] 中存儲的全部是尺寸為 3 個 Page 的內存塊,runsAvail[31] 中存儲的是尺寸為 512 個 Page 的內存塊。

Netty 中的一個 Page 是 8k

image

PoolChunk 可以看做是 Netty 中的夥伴系統,內存的申請和釋放過程和內核中的夥伴系統非常相似,當我們向 PoolChunk 申請 Page 級別的內存塊時,Netty 首先會從上面的 Page 規格表中獲取到內存塊尺寸對應的 pageIndex,然後到 runsAvail[pageIndex] 中去獲取對應尺寸的內存塊。

如果沒有空閒內存塊,Netty 的處理方式也是和內核一樣,逐級向上去找,直到在 runsAvail[pageIndex + n] 中找到內存塊。然後從這個大的內存塊中將我們需要的內存塊尺寸切分出來分配,剩下的內存塊直接插入到對應的 runsAvail[剩下的內存塊尺寸 index]中,並不會像內核那樣逐級減半分裂。

PoolChunk 的內存塊回收過程則和內核一樣,回收的時候會將連續的內存塊合併成更大的,直到無法合併為止。最後將合併後的內存塊插入到對應的 runsAvail[合併後內存塊尺寸 index] 中。

Netty 這裏還有一點和內核不一樣的是,內核的夥伴系統是使用 struct free_area 結構來組織相同尺寸的內存塊,它是一個雙向鏈表的結構,每次向內核申請 Page 的時候,都是從 free_list 的頭部獲取內存塊。釋放的時候也是講內存塊插入到 free_list 的頭部。這樣一來我們總是可以獲取到剛剛被釋放的內存塊,局部性比較好。

但 Netty 的夥伴系統採用的是 IntPriorityQueue ,一個優先級隊列來組織相同尺寸的內存塊,它會按照內存地址從低到高組織這些內存塊,我們每次從 IntPriorityQueue 中獲取的都是內存地址最低的內存塊。Netty 這樣設計的目的主要還是為了降低內存碎片,犧牲一定的局部性。

這裏犧牲掉局部性是 OK 的,因為在 PoolChunk 的設計中,Netty 更加註重內存碎片的大小,PoolChunk 主要提供 Page 級別內存塊的申請,Normal 規格 —— [32K , 4M] 的內存塊就是從 PoolChunk 中直接申請的。為了使 PoolChunk 這段 4M 的內存空間中內存碎片儘量的少,所以我們每次向 PoolChunk 申請 Page 級別內存塊的時候,總是從低內存地址開始有序的申請。

而在 Netty 的應用場景中,往往頻繁申請的都是那些小規格的內存塊,針對這種頻繁使用的 Small 規格的內存塊,Netty 在設計上就必須要保證局部性,因為這塊是熱點,所以性能的考量是首位。

而 Normal 規格的大內存塊,往往不會那麼頻繁的申請,所以在 PoolChunk 的設計上,內存碎片的考量是首位。

現在我們知道了 Normal 規格的內存塊是在 PoolChunk 中管理的,而 PoolChunk 的模型設計我們也清楚了,那 Small 規格的內存塊在哪裏管理呢 ?這就需要引入內存池的第五個模型 —— PoolSubpage 。

還是一樣的套路,遇事不決問內核!! 由於都是針對 Page 級別內存塊的管理,所以 PoolChunk 的設計參考了內核的夥伴系統,那麼針對小內存塊的管理,PoolSubpage 自然也會參考內核中的 slab cache 。所以 PoolSubpage 可以看做是 Netty 中的 slab 。

對內核 slab 的設計實現細節感興趣的讀者可以回看下筆者之前專門介紹 slab 的文章 —— 《細節拉滿,80 張圖帶你一步一步推演 slab 內存池的設計與實現》。由於篇幅的關係,筆者這裏就不再詳細介紹內核中的 slab 了,我們直接從 PoolSubpage 這個模型的設計開始聊起,思想都是一樣的。

通過前面的介紹我們知道,PoolChunk 承擔的是 Page 級別內存塊的管理工作,在 Netty 內存池的整個架構設計上屬於最底層的模型,它是一個基座,為整個內存池提供最基礎的內存分配能力,分配粒度按照 Page 進行。

但在 Netty 的實際應用場景中,往往使用最頻繁的是 Small 規格的內存塊 —— [16B , 28K] 。我們不可能每申請一個 Small 規格的內存塊(比如 16 字節)都要向 PoolChunk 去獲取一個 Page(8K),這樣內存資源的浪費是非常可觀的。

所以 Netty 借鑑了 Linux 內核中 Slab 的設計思想,當我們第一次申請一個 Small 規格的內存塊時,Netty 會首先到 PoolChunk 中申請一個或者若干個 Page 組成的大內存塊(Page 粒度),這個大內存塊在 Netty 中的模型就是 PoolSubpage 。然後按照對應的 Small 規格將這個大內存塊切分成多個尺寸相同的小內存塊緩存在 PoolSubpage 中。

image

每次申請這個規格的內存塊時,Netty 都會到對應尺寸的 PoolSubpage 中去獲取,每次釋放這個規格的內存塊時,Netty 會直接將其釋放回對應的 PoolSubpage 中。而且每次申請 Small 規格的內存塊時,Netty 都會優先獲取剛剛釋放回 PoolSubpage 的內存塊,保證了局部性。當 PoolSubpage 中緩存的所有內存塊全部被釋放回來後,Netty 就會將整個 PoolSubpage 重新釋放回 PoolChunk 中。

比如當我們首次向 Netty 內存池申請一個 16 字節的內存塊時,首先會從 PoolChunk 中申請 1 個 Page(8K),然後包裝成 PoolSubpage 。隨後會將 PoolSubpage 中的這 8K 內存空間切分成 512 個 16 字節的小內存塊。 後續針對 16 字節小內存塊的申請和釋放就都只會和這個 PoolSubpage 打交道了。

當我們第一次申請 28K 的內存塊時,由於它也是 Small 規格的尺寸,所以按照相同的套路,Netty 會首先從 PoolChunk 中申請 7 個 Pages(56K), 然後包裝成 PoolSubpage。隨後會將 PoolSubpage 中的這 56K 內存空間切分成 2 個 28K 的內存塊。

PoolSubpage 的尺寸是內存塊的尺寸與 PageSize 的最小公倍數。

每當一個 PoolSubpage 分配完之後,Netty 就會重新到 PoolChunk 中申請一個新的 PoolSubpage 。這樣一來,慢慢的,針對某一種特定的 Small 規格,就形成了一個 PoolSubpage 鏈表,這個鏈表是一個雙向循環鏈表,如下圖所示:

image

在 Netty 中,每一個 Small 規格尺寸都會對應一個這樣的 PoolSubpage 雙向循環鏈表,內存池中一共設計了 39 個 Small 規格尺寸 —— [16B , 28k],所以也就對應了 39 個這樣的 PoolSubpage 雙向循環鏈表,形成一個 PoolSubpage 鏈表數組 —— smallSubpagePools,它是內存池中管理 Small 規格內存塊的核心數據結構。

abstract class PoolArena<T> {
  // 管理 Small 規格內存塊的核心數據結構
  final PoolSubpage<T>[] smallSubpagePools;
}

smallSubpagePools 數組的下標就是對應的 Small 規格在 SizeClasses 內存規格表中的 index 。

image

這個設計也是參考了內核中的 kmalloc 體系,內核中的 kmalloc 也是用一個數組來組織不同尺寸的 slab , 只不過和 Netty 不同的是,kmalloc 支持的小內存塊尺寸在 8 字節到 8K 之間。

struct kmem_cache *
kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH + 1];

這裏的 smallSubpagePools 就相當於是內核中的 kmalloc,關於 kmalloc 的設計與實現細節感興趣的讀者可以回看下筆者之前的文章 —— 《深度解讀 Linux 內核級通用內存池 —— kmalloc 體系》。

好了,到現在我們已經清楚了,Netty 內存池是如何管理 Small 規格以及 Normal 規格的內存塊了。根據目前我們掌握的信息和場景可以得出內存池 —— PoolArena 的基本骨架,如下圖所示:

image

但這還不是 PoolArena 的完整樣貌,如果 PoolArena 中只有一個 PoolChunk 的話肯定是遠遠不夠的,因為 PoolChunk 總會有全部分配完畢的那一刻,這時 Netty 就不得不在次向 OS 申請一個新的 PoolChunk (4M),這樣一來,隨着時間的推移 ,PoolArena 中就會有多個 PoolChunk,那麼這些 PoolChunk 在內存池中是如何被組織管理的呢 ? 這就引入了內存池的第六個模型 —— PoolChunkList 。

PoolChunkList 是一個雙向鏈表的數據結構,它用來組織和管理 PoolArena 中的這些 PoolChunk。

image

但事實上,對於 PoolArena 中的這些眾多 PoolChunk 來説,可能不同 PoolChunk 它們的內存使用率都是不一樣的,於是 Netty 又近一步根據 PoolChunk 的內存使用率設計出了 6 個 PoolChunkList 。每個 PoolChunkList 管理着內存使用率在一定範圍內的 PoolChunk。

image

如上圖所示,PoolArena 中一共有 6 個 PoolChunkList,分別是:qInit,q000,q025,q050,q075,q100。它們之間通過一個雙向鏈表串聯在一起,每個 PoolChunkList 管理着內存使用率在相同範圍內的 PoolChunk :

  • qInit 顧名思義,當一個新的 PoolChunk 被創建出來之後,它就會被放到 qInit 中,該 PoolChunkList 管理的 PoolChunk 內存使用率在 [0% , 25%) 之間,當裏邊的 PoolChunk 內存使用率大於等於 25% 時,就會被向後移動到下一個 q000 中。
  • q000 管理的 PoolChunk 內存使用率在 [1% , 50%) 之間,當裏邊的 PoolChunk 內存使用率大於等於 50% 時,就會被向後移動到下一個 q025 中。當裏邊的 PoolChunk 內存使用率小於 1% 時,PoolChunk 就會被重新釋放回 OS 中。因為 ChunkSize 是 4M ,Netty 內存池提供的最小內存塊尺寸為 16 字節,當 PoolChunk 內存使用率小於 1% 時, 其實內存使用率已經就是 0% 了,對於一個已經全部釋放完的 Empty PoolChunk,就需要釋放回 OS 中。
  • q025 管理的 PoolChunk 內存使用率在 [25% , 75%) 之間,當裏邊的 PoolChunk 內存使用率大於等於 75% 時,就會被向後移動到下一個 q050 中。當裏邊的 PoolChunk 內存使用率小於 25% 時,就會被向前移動到上一個 q000 中。
  • q050 管理的 PoolChunk 內存使用率在 [50% , 100%) 之間,當裏邊的 PoolChunk 內存使用率小於 50% 時,就會被向前移動到上一個 q025 中。當裏邊的 PoolChunk 內存使用率達到 100% 時,直接移動到 q100 中。
  • q075 管理的 PoolChunk 內存使用率在 [75% , 100%) 之間,當裏邊的 PoolChunk 內存使用率小於 75% 時,就會被向前移動到上一個 q050 中。當裏邊的 PoolChunk 內存使用率達到 100% 時,直接移動到 q100 中。
  • q100 管理的全部都是內存使用率 100 % 的 PoolChunk,當有內存釋放回 PoolChunk 之後,才會向前移動到 q075 中。

從以上內容中我們可以看出,PoolArena 中的每一個 PoolChunkList 都規定了其中 PoolChunk 的內存使用率的上限和下限,當某個 PoolChunkList 中的 PoolChunk 內存使用率低於規定的下限時,Netty 首先會將其從當前 PoolChunkList 中移除,然後移動到前一個 PoolChunkList 中。

當 PoolChunk 的內存使用率達到規定的上限時,Netty 會將其移動到下一個 PoolChunkList 中。但這裏有一個特殊的設計不知大家注意到沒有,就是 q000 它的 prevList 指向 NULL , 也就是説當 q000 中的 PoolChunk 內存使用率低於下限 —— 1% 時,這個 PoolChunk 並不會向前移動到 qInit 中,而是會釋放回 OS 中。

image

qInit 的 prevList 指向的是它自己,也就是説,當 qInit 中的 PoolChunk 內存使用率為 0 % 時,這個 PoolChunk 並不會釋放回 OS , 反而是繼續留在 qInit 中。那為什麼 q000 中的 PoolChunk 內存使用率低於下限時會釋放回 OS ?而 qInit 中的 PoolChunk 反而要繼續留在 qInit 中呢

PoolArena 中那些剛剛新被創建出來的 PoolChunk 首先會被 Netty 添加到 qInit 中,如果該 PoolChunk 的內存使用率一直穩定在 0% 到 25% 之間的話,那麼它將會一直停留在 qInit 中,直到內存使用率達到 25% 才會被移動到下一個 q000 中。

如果內存使用不那麼頻繁,PoolChunk 的內存使用率會慢慢的降到 0% , 但是此時我們不能釋放它,而是應該讓它繼續留在 qInit 中,因為如果一旦釋放,下一次需要內存的時候還需要在重新創建 PoolChunk,所以為了避免 PoolChunk 的重複創建,我們需要保證內存池 PoolArena 中始終至少有一個 PoolChunk 可用。

如果內存使用比較頻繁,q000 中的 PoolChunk 內存使用率會慢慢達到 50% ,隨後它會被移動到下一個 q025 中,隨着內存使用率越來越高,達到 75% 之後,它又會被移動到 q050 中,隨着內存繼續的頻繁申請,最終 PoolChunk 被移動了 q100 中。

在內存頻繁使用的場景下,這個 PoolChunk 大概率會一直停留在 q050 或者 q075 中,但如果隨着內存使用的熱度降低,PoolChunk 會慢慢的向前移動直到進入到 q000 , 這時如果內存還在持續釋放,那麼這個 PoolChunk 的內存使用率慢慢的就會低於 1% 。

這種情況下,Netty 就會認為此時內存的申請並不頻繁,沒必要讓它一直停留在內存池中,直接將它釋放回 OS 就好。用的多了我就多存點,用的少了我就少存點,減少內存池帶來的不必要內存消耗。

以上是筆者要為大家介紹的 Netty 針對 PoolChunkList 的第一個設計,下面我們繼續來看第二個設計,當我們向內存池 PoolArena 申請內存的時候,進入到 PoolArena 內部之後,就會發現,我們同時面對的是 5 個都可提供內存分配的 PoolChunkList,它們分別是 qInit [0% , 25%) ,q000 [1% , 50%),q025 [25% , 75%),q050 [50% , 100%) ,q075 [75% , 100%) 。那我們到底該選擇哪個 PoolChunkList 進行內存分配呢 ? 也就是説這五個 PoolChunkList 的優先級我們該如何抉擇

Netty 選擇的內存分配順序是:q050 > q025 > q000 > qInit > q075 , 那為什麼這樣設計呢 ?

abstract class PoolArena<T> {
    // 分配 Page 級別的內存塊
    private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache threadCache) {
        assert lock.isHeldByCurrentThread();
        // PoolChunkList 內存分配的優先級:q050 > q025 > q000 > qInit > q075
        if (q050.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q025.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q000.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            qInit.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q075.allocate(buf, reqCapacity, sizeIdx, threadCache)) {
            return;
        }

        // 5 個 PoolChunkList 中沒有可用的 PoolChunk,重新向 OS 申請一個新的 PoolChunk(4M)
        PoolChunk<T> c = newChunk(sizeClass.pageSize, sizeClass.nPSizes, sizeClass.pageShifts, sizeClass.chunkSize);
        // 從新的 PoolChunk 中分配內存
        boolean success = c.allocate(buf, reqCapacity, sizeIdx, threadCache);
        assert success;
        // 將剛剛創建的 PoolChunk 加入到 qInit 中
        qInit.add(c);
    }
}

這裏有四點核心設計原則需要考慮:

  1. Netty 需要儘量控制內存的消耗,儘可能用少量的 PoolChunk 滿足大量的內存分配需求,避免創建新的 PoolChunk,提高每個 PoolChunk 的內存使用率。
  2. 而對於現有的 PoolChunk 來説,Netty 則需要儘量避免將其回收,讓它的服務週期儘可能長一些。
  3. 在此基礎之上,Netty 需要兼顧內存分配的性能。
  4. Netty 需要在內存池的整個生命週期中,從總體上做到讓 PoolArena 中的這些 PoolChunk 儘量均衡地承擔內存分配的工作,做到雨露均沾。

那麼 Netty 採用這樣的分配順序 —— q050 > q025 > q000 > qInit > q075 ,如何保證上述四點核心設計原則呢 ?

首先前面我們已經分析過了,在內存頻繁使用的場景中,內存池 PoolArena 中的 PoolChunks 大概率會集中停留在 q050 和 q075 這兩個 PoolChunkList 中。由於 q050 和 q075 中集中了大量的 PoolChunks,所以我們肯定會先從這兩個 PoolChunkList 查找,一下子就能找到一個 PoolChunk,保證了第三點原則 —— 內存分配的性能。

而 q075 中的 PoolChunk 內存使用率已經很高了,在 75% 到 100% 之間,很可能容量不能滿足內存分配的需求導致申請內存失敗,所以我們優先從 q050 開始。

由於 q050 [50% , 100%) 中同樣集中了大量的 PoolChunks,優先從 q050 開始分配可以做到儘可能的使用現有的 PoolChunk,避免了這些 PoolChunk 由於長期不被使用而被釋放回 OS , 保證了第二點設計原則。

當 q050 中沒有 PoolChunk 時,同樣是根據第二點設計原則,Netty 需要儘量優先選擇內存使用率高的 PoolChunk,所以優先從 q025 [25% , 75%) 進行分配。q025 中沒有則優先從 q000 [1% , 50%) 中分配,儘量避免 PoolChunk 的回收。

當 q000 中沒有 PoolChunk 時,那説明此時內存池中的內存容量已經不太夠了,但是根據第一點設計原則,在這種情況下,仍然需要避免創建新的 PoolChunk,所以下一個優先選擇的 PoolChunkList 應該是 qInit [0% , 25%) ,而前面我們也介紹過了,Netty 設計 qInit 的目的就是為了避免頻繁創建不必要的 PoolChunk。

當 qInit 沒有 PoolChunk 時,仍然不會貿然創建新的 PoolChunk,而是到 q075 中去尋找 PoolChunk 。之所以最後才輪到 q075,這是為了保證第四點設計原則,因為 q075 中的內存使用率已經很高了,為了總體上保證 PoolChunk 均衡地承擔內存分配的工作,所有優先到其他內存使用率相對較低的 PoolChunkList 中分配。

以上是筆者要為大家介紹的 Netty 針對 PoolChunkList 的第二個設計,下面我們接着來看第三個設計。大家可能注意到,PoolArena 中的這六個 PoolChunkList 在內存使用率區間的設計上有很多重疊的部分,比如內存使用率是 30% 的 PoolChunk 既可以在 q000 中也可以在 q025 中,55% 既可以在 q025 中也可以在 q050 中,Netty 為什麼要將 PoolChunkList 的內存使用率區間設計成這麼多的重疊區間 ? 為什麼不設計成恰好連續銜接的區間呢

image

我們可以反過來思考一下,假如 Netty 將 PoolChunkList 的內存使用率區間設計成恰好連續銜接的區間,那麼會發生什麼情況 ?

我們現在拿 q025 和 q050 這兩個 PoolChunkList 舉例説明,假設現在我們將 q025 的內存使用率區間設計成 [25% , 50%) , q050 的內存使用率區間設計成 [50% , 75%),這樣一來,q025 , q050 , q075 這三個 PoolChunkList 的內存使用率區間的上限和下限就是恰好連續銜接的了。

那麼隨着 PoolChunk 中內存的申請與釋放,會導致 PoolChunk 的內存使用率在不斷的發生變化,假設現在有一個 PoolChunk 的內存使用率是 45% ,當前停留在 q025 中,當分配內存之後,內存使用率上升至 50% ,那麼該 PoolChunk 就需要立即移動到 q050 中。

當釋放內存之後,這個剛剛移動到 q050 中的 PoolChunk,它的內存使用率下降到 49% ,那麼又會馬不停蹄地移動到 q025 ,也就是説只要這個 PoolChunk 的內存使用率在 q025 與 q050 的交界處 50% 附近來回徘徊的話,每次的內存申請與釋放都會導致這個 PoolChunk 在 q025 與 q050 之間不停地來回移動

同樣的道理,只要一個 PoolChunk 的內存使用率在 75% 左右來回徘徊的話,那麼每次內存的申請與釋放也都會導致這個 PoolChunk 在 q050 與 q075 之間不停地來回移動,這樣會造成一定的性能下降。

但是如果各個 PoolChunkList 之間的內存使用率區間設計成重疊區間的話,那麼 PoolChunk 的可調節範圍就會很廣,不會頻繁地在前後不同的 PoolChunkList 之間來回移動。

我們還是拿 q025 [25% , 75%) 和 q050 [50% , 100%) 來舉例説明,現在 q025 中有一個內存使用率為 45% 的 PoolChunk , 當分配內存之後,內存使用率上升至 50% ,該 PoolChunk 仍然會繼續停留在 q025 中,後續隨着內存分配的不斷進行,當內存使用率達到 75% 的時候才會移動到 q050 中。

還是這個 PoolChunk , 當釋放內存之後,PoolChunk 的使用率下降到了 70%,那麼它仍然會停留在 q050 中,後續隨着內存釋放的不斷進行,當內存使用率低於 50% 的時候才會移動到 q025 中。這種重疊區間的設計有效的避免了 PoolChunk 頻繁的在兩個 PoolChunkList 之間來回移動。

好了,到現在為止,我們已經明白了內存池所有的核心組件設計,基於本小節中介紹的 6 個模型:PoolArena,PoolThreadCache,SizeClasses,PoolChunk ,PoolSubpage,PoolChunkList 。我們可以得出內存池的完整架構如下圖所示:

image

2. Netty 內存池的創建與初始化

在清楚了內存池的總體架構設計之後,本小節我們就來看一下整個內存池的骨架是如何被創建出來的,Netty 將整個內存池的實現封裝在 PooledByteBufAllocator 類中。

public class PooledByteBufAllocator {
    public static final PooledByteBufAllocator DEFAULT =
            new PooledByteBufAllocator(PlatformDependent.directBufferPreferred());
}

創建內存池所需要的幾個核心參數我們需要提前瞭解下:

  • preferDirect 默認為 true , 用於指定該 Allocator 是否偏向於分配 Direct Memory,其值由 PlatformDependent.directBufferPreferred() 方法決定,相關的判斷邏輯可以回看下 《聊一聊 Netty 數據搬運工 ByteBuf 體系的設計與實現》 一文中的第三小節。
  • nHeapArena , nDirectArena 用於指定內存池中包含的 HeapArena , DirectArena 個數,它們分別用於池化 Heap Memory 以及 Direct Memory 。默認個數分別為 availableProcessors * 2 , 可由參數 -Dio.netty.allocator.numHeapArenas-Dio.netty.allocator.numDirectArenas 指定。
  • pageSize 默認為 8K ,用於指定內存池中的 Page 大小。可由參數 -Dio.netty.allocator.pageSize 指定,但不能低於 4K 。
  • maxOrder 默認為 9 , 用於指定內存池中 PoolChunk 尺寸,默認 4M ,由 pageSize << maxOrder 計算得出。可由參數 -Dio.netty.allocator.maxOrder 指定,但不能超過 14 。
  • smallCacheSize 默認 256 , 可由參數 -Dio.netty.allocator.smallCacheSize 指定,用於表示每一個 small 內存規格尺寸可以在 PoolThreadCache 中緩存的 small 內存塊個數。
  • normalCacheSize 默認 64 , 可由參數 -Dio.netty.allocator.normalCacheSize 指定,用於表示每一個 Normal 內存規格尺寸可以在 PoolThreadCache 中緩存的 Normal 內存塊個數。
  • useCacheForAllThreads 默認為 false , 可由參數 -Dio.netty.allocator.useCacheForAllThreads 指定。用於表示是否為所有線程創建 PoolThreadCache。
  • directMemoryCacheAlignment 默認為 0 ,可由參數 -Dio.netty.allocator.directMemoryCacheAlignment 指定 , 用於表示內存池中內存塊尺寸的對齊粒度。
    private final PoolThreadLocalCache threadCache;
    private final int smallCacheSize;
    private final int normalCacheSize;
    private final int chunkSize;
    // 保存所有 DirectArena
    private final PoolArena<ByteBuffer>[] directArenas;

    public PooledByteBufAllocator(boolean preferDirect, int nHeapArena, int nDirectArena, int pageSize, int maxOrder,
                                  int smallCacheSize, int normalCacheSize,
                                  boolean useCacheForAllThreads, int directMemoryCacheAlignment) {
        // 默認偏向於分配 Direct Memory
        super(preferDirect);
        // 創建 PoolThreadLocalCache ,後續用於將線程與 PoolArena 綁定
        // 併為線程創建 PoolThreadCache
        threadCache = new PoolThreadLocalCache(useCacheForAllThreads);
        // PoolThreadCache 中,針對每一個 Small 規格的尺寸可以緩存 256 個內存塊
        this.smallCacheSize = smallCacheSize;
        // PoolThreadCache 中,針對每一個 Normal 規格的尺寸可以緩存 64 個內存塊
        this.normalCacheSize = normalCacheSize;
        // PoolChunk 的尺寸
        // pageSize << maxOrder = 4M
        chunkSize = validateAndCalculateChunkSize(pageSize, maxOrder);
        // 13 , pageSize 為 8K
        int pageShifts = validateAndCalculatePageShifts(pageSize, directMemoryCacheAlignment);
        // 依次創建 nDirectArena 個 DirectArena(省略 HeapArena)
        if (nDirectArena > 0) {
            // 創建 PoolArena 數組,個數為 2 * processors
            directArenas = newArenaArray(nDirectArena);
            // 劃分內存規格,建立內存規格索引表
            final SizeClasses sizeClasses = new SizeClasses(pageSize, pageShifts, chunkSize,
                    directMemoryCacheAlignment);
            // 初始化 PoolArena 數組
            for (int i = 0; i < directArenas.length; i ++) {
                // 創建 DirectArena
                PoolArena.DirectArena arena = new PoolArena.DirectArena(this, sizeClasses);
                // 保存在 directArenas 數組中
                directArenas[i] = arena;
            }
        } else {
            directArenas = null;
        }
    }

當我們明白了內存池的總體架構之後,再來看內存池的創建過程就會覺得非常簡單了,核心點主要有三個:

首先會創建 PoolThreadLocalCache,它是一個 FastThreadLocal 類型的成員變量,主要作用是用於後續實現線程與 PoolArena 之間的綁定,併為線程創建本地緩存 PoolThreadCache。

    private final class PoolThreadLocalCache extends FastThreadLocal<PoolThreadCache> {
        private final boolean useCacheForAllThreads;

        PoolThreadLocalCache(boolean useCacheForAllThreads) {
            this.useCacheForAllThreads = useCacheForAllThreads;
        }

        @Override
        protected synchronized PoolThreadCache initialValue() {
            實現線程與 PoolArena 之間的綁定
            為線程創建本地緩存 PoolThreadCache
        }
    }

其次是根據 nDirectArena 的個數,創建 PoolArena 數組,用於保存內存池中所有的 PoolArena。

    private static <T> PoolArena<T>[] newArenaArray(int size) {
        return new PoolArena[size];
    }

隨後會創建 SizeClasses , Netty 內存規格的劃分就是在這裏進行的,上一小節中展示的 Netty 內存規格索引表就是在這裏創建的。這一塊的內容比較多,筆者放在下一小節中介紹。

最後根據 SizeClasses 創建 nDirectArena 個 PoolArena 實例,並依次保存在 directArenas 數組中。內存池的創建核心關鍵在於創建 PoolArena 結構,PoolArena 中管理了 Small 規格的內存塊與 PoolChunk。其中管理 Small 規格內存塊的數據結構是 smallSubpagePools 數組,管理 PoolChunk 的數據結構是六個 PoolChunkList ,分別按照不同的內存使用率進行劃分。

abstract class PoolArena<T> {
    // Small 規格的內存塊組織在這裏,類似內核的 kmalloc
    final PoolSubpage<T>[] smallSubpagePools;
    // 按照不同內存使用率組織 PoolChunk
    private final PoolChunkList<T> q050;  // [50% , 100%) 
    private final PoolChunkList<T> q025;  // [25% , 75%) 
    private final PoolChunkList<T> q000;  // [1% , 50%)
    private final PoolChunkList<T> qInit; // [0% , 25%) 
    private final PoolChunkList<T> q075;  // [75% , 100%)
    private final PoolChunkList<T> q100;  // 100%

    protected PoolArena(PooledByteBufAllocator parent, SizeClasses sizeClass) {
        // PoolArena 所屬的 PooledByteBufAllocator
        this.parent = parent;
        // Netty 內存規格索引表
        this.sizeClass = sizeClass;
        // small 內存規格將會在這裏分配 —— 類似 kmalloc
        // 每一種 small 內存規格都會對應一個 PoolSubpage 鏈表(類似 slab)
        smallSubpagePools = newSubpagePoolArray(sizeClass.nSubpages);
        for (int i = 0; i < smallSubpagePools.length; i ++) {
            // smallSubpagePools 數組中的每一項是一個帶有頭結點的 PoolSubpage 結構雙向鏈表
            // 雙向鏈表的頭結點是 SubpagePoolHead
            smallSubpagePools[i] = newSubpagePoolHead(i);
        }

        // 按照不同內存使用率範圍劃分 PoolChunkList
        q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, sizeClass.chunkSize);// [100 , 2147483647]
        q075 = new PoolChunkList<T>(this, q100, 75, 100, sizeClass.chunkSize);
        q050 = new PoolChunkList<T>(this, q075, 50, 100, sizeClass.chunkSize);
        q025 = new PoolChunkList<T>(this, q050, 25, 75, sizeClass.chunkSize);
        q000 = new PoolChunkList<T>(this, q025, 1, 50, sizeClass.chunkSize);
        qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, sizeClass.chunkSize);// [-2147483648 , 25]
        // 雙向鏈表組織 PoolChunkList
        // 其中比較特殊的是 q000 的前驅節點指向 NULL
        // qInit 的前驅節點指向它自己
        q100.prevList(q075);
        q075.prevList(q050);
        q050.prevList(q025);
        q025.prevList(q000);
        q000.prevList(null);
        qInit.prevList(qInit);
    }
}

首先就是創建 smallSubpagePools 數組,數組中的每一個元素是一個帶有頭結點的 PoolSubpage 類型的雙向循環鏈表結構,PoolSubpage 類似內核中的 slab ,其中管理着對應 Small 規格的小內存塊。

Netty 內存池中一共設計了 39 個 Small 規格尺寸 —— [16B , 28k],所以 smallSubpagePools 數組的長度就是 39 (sizeClass.nSubpages),數組中的每一項負責管理一種 Small 規格的內存塊。

    private PoolSubpage<T>[] newSubpagePoolArray(int size) {
        return new PoolSubpage[size];
    }

smallSubpagePools 數組中保存就是對應 Small 規格尺寸的 PoolSubpage 鏈表的頭結點 SubpagePoolHead。在內存池剛被創建出來的時候,鏈表中還是空的,只有一個頭結點。

    private PoolSubpage<T> newSubpagePoolHead(int index) {
        PoolSubpage<T> head = new PoolSubpage<T>(index);
        head.prev = head;
        head.next = head;
        return head;
    }

image

隨後 Netty 會按照 PoolChunk 的不同內存使用率範圍劃分出六個 PoolChunkList :qInit [0% , 25%) ,q000 [1% , 50%),q025 [25% , 75%),q050 [50% , 100%) ,q075 [75% , 100%),q100 [100%]。它們分別管理着不同內存使用率的 PoolChunk。由於現在內存池剛剛被創建出來,所以這些 PoolChunkList 中還是空的,

image

這些 PoolChunkLists 通過雙向鏈表的結構相互串聯起來,其中比較特殊的是 q000 和 qInit。 q000 它的前驅節點 prevList 指向 NULL ,目的是當 q000 中的 PoolChunk 內存使用率低於 1% 時,Netty 就會將其釋放回 OS , 不會繼續向前移動到 qInit 中,減少不必要的內存消耗。

qInit 它的前驅節點 prevList 指向它自己,這麼做的目的是,使得內存使用率低於 25% 的 PoolChunk 能夠一直停留在 qInit 中,避免後續需要內存的時候還需要在重新創建 PoolChunk。

現在一個完整的內存池就被我們創建出來了,但此時它還只是一個基本的骨架,內存池裏面的 PoolArena 還沒有和任何線程進行綁定,線程中的本地緩存 PoolThreadCache 還是空的。PoolArena 中的 smallSubpagePools 以及六個 PoolChunkLists 裏也都是空的。

image

在後面的小節中,筆者將基於這個基本的骨架,讓內存池動態地運轉起來,一步一步豐滿填充裏面的內容。但內存池運轉的核心是圍繞着對 Small 規格以及 Normal 規格內存塊的管理進行的。

image

所以在核心內容開始之前,我們需要知道 Netty 究竟是如何劃分這些不同規格尺寸的內存塊的。

3. Netty 內存規格的劃分

image

如上圖所示,Netty 的內存規格從 16B 到 4M 一共劃分成了 68 種規格,內存規格表在 Netty 中使用了一個二維數組來存儲。

short[][] sizeClasses

其中第一維是按照內存規格的粒度來存儲每一種內存規格,一共有 68 種規格,一維數組的大小也是 68 。第二維存儲的是每一種內存規格的詳細信息,一共有 7 列,分別是 index,log2Group,log2Delta,nDelta,isMultiPageSize,isSubpage,log2DeltaLookup。

    private static final int LOG2GROUP_IDX = 1;
    private static final int LOG2DELTA_IDX = 2;
    private static final int NDELTA_IDX = 3;
    private static final int PAGESIZE_IDX = 4;
    private static final int SUBPAGE_IDX = 5;
    private static final int LOG2_DELTA_LOOKUP_IDX = 6;

其中 index 表示每一種內存規格在 sizeClasses 中的索引,從 0 到 67 表示 68 種內存規格。

後面的 log2Group ,log2Delta,nDelta 都是為了計算對應的內存規格 size 而設計的,計算公式如下:

    private static int calculateSize(int log2Group, int nDelta, int log2Delta) {
        return (1 << log2Group) + (nDelta << log2Delta);
    }

Netty 按照 log2Group 將內存規格表中的 68 種規格一共分成了 17 組,每組 4 個規格,log2Group 用於表示在同一個內存規格組內的 4 個規格的基準 size —— base size 的對數, 後續規格 size 將會在 base size (1 << log2Group)的基礎上進行擴充。

那麼如何擴充呢 ?這就用到了 log2Delta 和 nDelta,每一個內存規格與其上一個規格的差值為 1 << log2Delta,同一個內存規格組內的規格相當於是一個等差數列(log2Delta 都是相同的)。Netty 會按照 log2Delta 的倍數對內存規格進行擴充,那麼擴充多少倍呢 ? 這個就是 nDelta。

所以一個內存規格 size 的計算方式就是基準 size (1 << log2Group) 加上擴充的大小(nDelta << log2Delta)。下面筆者用第一個內存規格組 [16B, 32B, 48B , 64B] 進行具體説明:

image

首先第一個內存規格組的 log2Group 為 4 ,它的基準 size = 1 << log2Delta = 16B。log2Delta 為 4 ,表示組內的 4 個內存規格之間的差值為 1 << log2Delta = 16B

好了,接下來我們看第一個內存規格組內每一種規格的計算方式,Netty 內存池的第一個內存規格是 16B , 由於它是第一個規格,所以 nDelta 為 0 ,我們通過公式 (1 << log2Group) + (nDelta << log2Delta) = (1 << 4) + (0 << 4) 得出第一個內存規格 size 為 16B。

後續其他內存規格組內第一個規格的 nDelta 均是為 1 。

第二個內存規格是 32B , 它對應的 nDelta 為 1,規格 size = (1 << 4) + (1 << 4) = 32

第三個內存規格是 48B , 它對應的 nDelta 為 2,規格 size = (1 << 4) + (2 << 4) = 48

第四個內存規格是 64B , 它對應的 nDelta 為 3,規格 size = (1 << 4) + (3 << 4) = 64

每一個內存規格組內的最後一個規格 size 恰好都是 2 的次冪 。同時它也是下一個內存規格組的 log2Group = log2(size) 。

sizeClasses 中的 isMultiPageSize 表示該內存規格是否是 Page(8k) 的倍數,用於後續索引 Page 級別的內存規格。isSubpage 表示該內存規格的內存塊是否由 PoolSubpage 進行管理,從內存規格表 sizeClasses 中我們可以看出,Small 規格 [16B , 28k] 範圍內的內存規格,它們的 isSubpage 全都是 true 。

log2DeltaLookup 的用處不大,這裏大家可以忽略,4K 以下的內存規格,它們的 log2DeltaLookup 就是 log2Delta, 4K 以上的內存規格,它們的 log2DeltaLookup 都是 0 。這個設計主要是後面用來建立內存規格 size 與其對應的 index 之間的映射索引表 —— size2idxTab。

它的作用就是給定一個內存尺寸 size ,返回其對應的內存規格 index 。內存尺寸在 4K 以下直接查找 size2idxTab,4K 以上通過計算得出。這裏大家只做簡單瞭解即可,後面筆者會介紹這部分的計算邏輯。

好了,現在我們已經看懂了這張 SizeClasses 內存規格表,接下來我們就來看一下 SizeClasses 是如何被構建出來的。

final class SizeClasses {
    // 第一種內存規格的基準 size  —— 16B 
    // 以及第一個內存規格增長間距 —— 16B
    static final int LOG2_QUANTUM = 4;
    // 每個內存規格組內,規格的個數 —— 4 個
    private static final int LOG2_SIZE_CLASS_GROUP = 2;
    // size2idxTab 中索引的最大內存規格 —— 4K
    private static final int LOG2_MAX_LOOKUP_SIZE = 12;
}

我們在內存規格表中看到的第一組內存規格基準 size —— 16B , 以及第一種內存規格的增長間隔 —— 16B ,就是由 LOG2_QUANTUM 常量決定的。

內存規格表中一共分為了 17 組內存規格,每組包含的規格個數由 LOG2_SIZE_CLASS_GROUP 決定。

// 22 - 4 -2 + 1 = 17
int group = log2(chunkSize) - LOG2_QUANTUM - LOG2_SIZE_CLASS_GROUP + 1;

size2idxTab 中索引的最大內存規格是由 LOG2_MAX_LOOKUP_SIZE 決定的,如果給定的內存尺寸小於等於 1 << LOG2_MAX_LOOKUP_SIZE,那麼直接查找 size2idxTab 獲取其對應的內存規格 index。內存尺寸大於 1 << LOG2_MAX_LOOKUP_SIZE,則通過計算得出對應的 index , 不會建立這部分索引。

    SizeClasses(int pageSize, int pageShifts, int chunkSize, int directMemoryCacheAlignment) {
        // 一共分為 17 個內存規格組
        int group = log2(chunkSize) - LOG2_QUANTUM - LOG2_SIZE_CLASS_GROUP + 1;

        // 創建內存規格表 sizeClasses
        // 每個內存規格組內有 4 個規格,一共 68 個內存規格,一維數組長度為 68
        // 二維數組的長度為 7
        // 保存的內存規格信息為:index, log2Group, log2Delta, nDelta, isMultiPageSize, isSubPage, log2DeltaLookup
        short[][] sizeClasses = new short[group << LOG2_SIZE_CLASS_GROUP][7];

        int normalMaxSize = -1;
        // 內存規格 index , 初始為 0 
        int nSizes = 0;
        // 內存規格 size 
        int size = 0;
        // 第一組內存規格的基準 size 為 16B
        int log2Group = LOG2_QUANTUM;
        // 第一組內存規格之間的間隔為 16B
        int log2Delta = LOG2_QUANTUM;
        // 每個內存規格組內限定為 4 個規格
        int ndeltaLimit = 1 << LOG2_SIZE_CLASS_GROUP;

        // 初始化第一個內存規格組 [16B , 64B],nDelta 從 0 開始
        for (int nDelta = 0; nDelta < ndeltaLimit; nDelta++, nSizes++) {
            // 初始化對應內存規格的 7 個信息
            short[] sizeClass = newSizeClass(nSizes, log2Group, log2Delta, nDelta, pageShifts);
            // nSizes 為該內存規格的 index
            sizeClasses[nSizes] = sizeClass;
            // 通過 sizeClass 計算該內存規格的 size ,然後將 size 向上對齊至 directMemoryCacheAlignment 的最小整數倍
            size = sizeOf(sizeClass, directMemoryCacheAlignment);
        }

        // 每個內存規格組內的最後一個規格,往往是下一個內存規格組的基準 size
        // 比如第一個內存規格組內最後一個規格 64B , 它是第二個內存規格組的基準 size
        // 4 + 2 = 6,第二個內存規格組的基準 size 為 64B
        log2Group += LOG2_SIZE_CLASS_GROUP;

        // 初始化剩下的 16 個內存規格組
        // 後一個內存規格組的 log2Group,log2Delta 比前一個內存規格組的 log2Group ,log2Delta 多 1
        for (; size < chunkSize; log2Group++, log2Delta++) {
            // 每個內存規格組內的 nDelta 從 1 到 4 ,最大內存規格不能超過 chunkSize(4M)
            for (int nDelta = 1; nDelta <= ndeltaLimit && size < chunkSize; nDelta++, nSizes++) {
                // 初始化對應內存規格的 7 個信息
                short[] sizeClass = newSizeClass(nSizes, log2Group, log2Delta, nDelta, pageShifts);
                // nSizes 為該內存規格的 index
                sizeClasses[nSizes] = sizeClass;
                size = normalMaxSize = sizeOf(sizeClass, directMemoryCacheAlignment);
            }
        }

        // 最大內存規格不能超過 chunkSize(4M)
        // 超過 4M 就是 Huge 內存規格,直接分配不進行池化管理
        assert chunkSize == normalMaxSize;

              ...... 省略 ......
    }

本小節一開始貼出來的那張內存規格表就是通過上面這段代碼創建出來的,創建邏輯不算太複雜。首先創建一個空的二維數組 —— sizeClasses,後續用它來保存內存規格信息。

Netty 將 chunkSize(4M)一共分為了 17 組,每組 4 個規格,一共 68 種內存規格,所以 sizeClasses 的一維數組大小就是 68,保存每一個內存規格信息。二維數組大小是 7, 也就是上面筆者介紹的 7 種具體的內存規格信息。

short[][] sizeClasses = new short[group << LOG2_SIZE_CLASS_GROUP][7];

首先在第一個 for 循環中初始化第一個內存規格組,它的起始 log2Group,log2Delta 為 4,也就是説第一個內存規格組內的基準 size 為 16B , 組內規格之間的差值為 16B ,由於是第一組內存規格,所以 nDelta 從 0 開始遞增。

image

接着在第二個雙重 for 循環中初始化剩下的 16 組內存規格,從 80B 一直到 4M 。Netty 在劃分內存規格的時候有一個特點,就是每個內存規格組內最後一個規格 size 一定是 2 的次冪,同時它也是下一個內存規格組的基準 size

image

比如第一個內存規格組內最後一個規格為 64B , 那麼第二個內存規格組的 log2Group 就應該是 6 。也就是從基準 size —— 64B 開始擴充組內的規格。

// 4 + 2 = 6
log2Group += LOG2_SIZE_CLASS_GROUP;

除去第一組內存規格之外,我們看到剩下的 16 組內存規格,後一個內存規格組內的 log2Group 往往比前一個內存規格組的 log2Group 多 1 。也就是説內存規格組的基準 size 是按照 2 倍遞增。以 64B , 128B , ...... ,1M , 2M 這樣遞增。

同時後一個內存規格組內的 log2Delta 往往比前一個內存規格組的 log2Delta 多 1 。也就是不同內存規格組內規格之間的差值也是按照 2 倍遞增。規格之間的間距分別按照 16B , 32B , 64B ,...... , 0.25M , 0.5M 這樣遞增。但同一內存規格組內的差值永遠都是相同的。

現在我們已經清楚了內存規格組的劃分邏輯,那麼具體的內存規格信息是如何初始化的呢 ?這部分邏輯在 newSizeClass 函數中實現。

    private static short[] newSizeClass(int index, int log2Group, int log2Delta, int nDelta, int pageShifts) {
        // 判斷規格尺寸是否是 Page 的整數倍
        short isMultiPageSize;
        if (log2Delta >= pageShifts) {
            // 尺寸按照 Page 的倍數遞增了,那麼一定是  Page 的整數倍
            isMultiPageSize = yes;
        } else {
            int pageSize = 1 << pageShifts;
            // size = 1 << log2Group + nDelta * (1 << log2Delta)
            int size = calculateSize(log2Group, nDelta, log2Delta);
            // 是否能被 pagesize(8k) 整除
            isMultiPageSize = size == size / pageSize * pageSize? yes : no;
        }
        // 規格尺寸小於 32K ,那麼就屬於 Small 規格,對應的內存塊會被 PoolSubpage 管理
        short isSubpage = log2Size < pageShifts + LOG2_SIZE_CLASS_GROUP? yes : no;

        // 如果內存規格 size 小於等於 MAX_LOOKUP_SIZE(4K),那麼 log2DeltaLookup 為 log2Delta
        // 如果內存規格 size 大於 MAX_LOOKUP_SIZE(4K),則為 0
        // Netty 只會為 4K 以下的內存規格建立 size2idxTab 索引
        int log2DeltaLookup = log2Size < LOG2_MAX_LOOKUP_SIZE ||
                              log2Size == LOG2_MAX_LOOKUP_SIZE && remove == no
                ? log2Delta : no;
        // 初始化內存規格信息
        return new short[] {
                (short) index, (short) log2Group, (short) log2Delta,
                (short) nDelta, isMultiPageSize, isSubpage, (short) log2DeltaLookup
        };
    }

現在整個內存規格表就算初始化完了,後面的工作比較簡單,就是遍歷內存規格表,初始化一些統計信息,比如:

  • nPSizes,表示 Page 級別的內存規格個數,一共有 32 個 Page 級別的內存規格。
  • nSubpages , 表示 Small 內存規格的個數,從 16B 到 28K 一共 39 個
  • smallMaxSizeIdx ,最大的 Small 內存規格對應的 index 。 Small 內存規格中的最大尺寸為 28K ,對應的 sizeIndex = 38。
  • lookupMaxSize , 表示 size2idxTab 中索引的最大尺寸為 4K 。
        // Small 規格中最大的規格尺寸對應的 index (38)
        int smallMaxSizeIdx = 0;
        // size2idxTab 中最大的 lookup size (4K)
        int lookupMaxSize = 0;
        // Page 級別內存規格的個數(32)
        int nPSizes = 0;
        // Small 內存規格的個數(39)
        int nSubpages = 0;
        // 遍歷內存規格表 sizeClasses,統計 nPSizes , nSubpages,smallMaxSizeIdx,lookupMaxSize
        for (int idx = 0; idx < nSizes; idx++) {
            short[] sz = sizeClasses[idx];
            // 只要 size 可以被 pagesize 整除,那麼就屬於 MultiPageSize
            if (sz[PAGESIZE_IDX] == yes) {
                nPSizes++;
            }
            // 只要 size 小於 32K 則為 Subpage 的規格
            if (sz[SUBPAGE_IDX] == yes) {
                nSubpages++;
                // small 內存規格中的最大尺寸 28K ,對應的 sizeIndex = 38
                smallMaxSizeIdx = idx;
            }
            // 內存規格小於等於 4K 的都屬於 lookup size
            if (sz[LOG2_DELTA_LOOKUP_IDX] != no) {
                // 4K
                lookupMaxSize = sizeOf(sz, directMemoryCacheAlignment);
            }
        }
        // 38
        this.smallMaxSizeIdx = smallMaxSizeIdx;
        // 4086(4K)
        this.lookupMaxSize = lookupMaxSize;
        // 32
        this.nPSizes = nPSizes;
        // 39
        this.nSubpages = nSubpages;
        // 68
        this.nSizes = nSizes;
        // 8192(8K)
        this.pageSize = pageSize;
        // 13
        this.pageShifts = pageShifts;
        // 4M
        this.chunkSize = chunkSize;
        // 0
        this.directMemoryCacheAlignment = directMemoryCacheAlignment;

現在 Netty 中所有的內存規格尺寸就已經全部確定下來了,包括 68 種內存規格,8K 的 PageSize , 4M 的 ChunkSize。接下來最後一項任務就是根據原始的內存規格表 sizeClasses 建立相關的索引表。

        // sizeIndex 與 size 之間的映射
        this.sizeIdx2sizeTab = newIdx2SizeTab(sizeClasses, nSizes, directMemoryCacheAlignment);
        // 根據 sizeClass 生成 page 級的內存規格表
        // pageIndex 到對應的 size 之間的映射
        this.pageIdx2sizeTab = newPageIdx2sizeTab(sizeClasses, nSizes, nPSizes, directMemoryCacheAlignment);
        // 4k 之內,給定一個 size 轉換為 sizeIndex
        this.size2idxTab = newSize2idxTab(lookupMaxSize, sizeClasses);

3.1 sizeIdx2sizeTab

sizeIdx2sizeTab 主要是建立內存規格 index 到對應規格 size 之間的映射,這裏的 index 就是內存規格表 sizeClasses 中的 index 。

    private static int[] newIdx2SizeTab(short[][] sizeClasses, int nSizes, int directMemoryCacheAlignment) {
        // 68 種內存規格,映射條目也是 68
        int[] sizeIdx2sizeTab = new int[nSizes];
        // 遍歷內存規格表,建立 index 與規格 size 之間的映射
        for (int i = 0; i < nSizes; i++) {
            short[] sizeClass = sizeClasses[i];
            // size = 1 << log2Group + nDelta * (1 << log2Delta)
            sizeIdx2sizeTab[i] = sizeOf(sizeClass, directMemoryCacheAlignment);
        }
        return sizeIdx2sizeTab;
    }

image

3.2 pageIdx2sizeTab

pageIdx2sizeTab 建立的是 Page 級別內存規格的索引表,pageIndex 到對應 Page 級內存規格 size 之間的映射。這裏的 pageIndex 從 0 開始一直到 31。

    private static int[] newPageIdx2sizeTab(short[][] sizeClasses, int nSizes, int nPSizes,
                                            int directMemoryCacheAlignment) {
        // page 級的內存規格,個數為 32
        int[] pageIdx2sizeTab = new int[nPSizes];
        int pageIdx = 0;
        // 遍歷內存規格表,建立 pageIdx 與對應 Page 級內存規格 size 之間的映射
        for (int i = 0; i < nSizes; i++) {
            short[] sizeClass = sizeClasses[i];
            if (sizeClass[PAGESIZE_IDX] == yes) {
                pageIdx2sizeTab[pageIdx++] = sizeOf(sizeClass, directMemoryCacheAlignment);
            }
        }
        return pageIdx2sizeTab;
    }

image

3.3 size2idxTab

size2idxTab 是建立 request size 與內存規格 index 之間的映射關係,那什麼是 request size 呢 ? 注意這裏的 request size 並不是內存規格表中固定的規格 size , 因為內存規格表是 Netty 提前規劃好的,對於用户來説,可能並不知道 Netty 究竟劃分了哪些固定的內存規格,用户不一定會按照 Netty 規定的 size 進行內存申請,申請的內存尺寸可能是隨意的。

比如,內存規格表中的前兩個規格是 16B , 32B。但用户實際申請的可能是 6B ,8B , 29B , 30B 這樣子的尺寸。

image

當用户向內存池申請 6B 或者 8B 的內存塊時,那麼 Netty 就需要找到與其最接近的內存規格,也就是 16B,對應的規格 index 是 0。當用户申請 29B 或者 30B 的內存塊時,與其最接近的內存規格就是 32B , 對應的規格 index 是 1 。

針對上面的例子來説,用户實際申請的內存尺寸就是 request size,在 size2idxTab 中的概念是 lookup size 。而 size2idxTab 的作用就是建立 lookup size 與其對應內存規格 index 之間的映射。這樣一來,Netty 就可以通過任意一個 lookup size 迅速找到與其最接近的內存規格了。

那麼這個映射如何建立呢 ?我們看到 size2idxTab 的結構只是一個 int 型的數組,怎麼存放 lookup size 與內存規格 index 的映射關係呢 ?

int[] size2idxTab

説起映射,我們很容易想起 Hash 表對吧,我們可以將內存規格 index 存儲在 size2idxTab 數組 , size2idxTab 數組的 index 我們可以設計成 lookup size 的 hash code 。這樣一來,給定一個任意的 lookup size,我們通過一個哈希函數計算出它的 hash code,這個 hash code 也就是 size2idxTab 數組的 index,從而通過 size2idxTab[index] 找到映射的內存規格 index。

那麼我們該如何設計一個這樣的哈希函數呢 ? 能不能從 Netty 的內存規格表中找找規律,看看有沒有什麼靈感、我們知道 Netty 的基礎內存規格為 16B ,從 16B 開始先是按照 16B 這樣的間隔開始慢慢擴充內存規格,隨後依次按照 32B ,64B, 128B , 256B , ...... , 0.5M 這樣 2 的次冪的倍數間隔逐漸慢慢擴充成 68 種內存規格。

 // 基礎內存規格
 static final int LOG2_QUANTUM = 4;

這 68 種內存規格都是在 16B 的基礎上擴充而來的,規格之間的差值也都是 16 的倍數,因此任何一種內存規格一定是 16 的倍數。根據這個特點,我們將 4M 的內存空間按照 16B 這樣的間隔將 lookup size 的尺寸切分為,16B , 32B , 48B , 64B , 80B , ...... 等等這樣的 lookup 尺寸,它們之間的間隔都是 16B,不會像內存規格那樣 2 倍 2 倍的遞增。

如果 lookup size 在(0 , 16B] 範圍內,那麼對應的規格 index 就是 0 ,內存規格為 16B , 如果 lookup size 在(16B , 32B] 範圍內,那麼對應的規格 index 就是 1 , 內存規格為 32B,如下圖所示這樣以此類推:

image

按照這樣的規律,我們就可以設計一個這樣的哈希函數:

lookupSize - 1 >> LOG2_QUANTUM

比如,9B 通過上面的哈希函數計算出來的就是 0 ,恰好是內存規格 16B 的 index (0) , 31B 計算出來的就是 1 ,恰好是內存規格 32B 的 index(1),100B 計算出來的就是 6 ,恰好是內存規格 112B 的 index (6) 。

但如果我們像這樣將 ChunkSize(4M) 按照 16B 的間隔進行劃分,就會劃分出 262144 個 lookup size 尺寸,這樣就會導致 size2idxTab 這張索引表非常的大,而且也沒這必要。

其實我們只需要為那些使用頻率最高的內存規格範圍建立索引就好了,剩下低頻使用的內存規格我們直接通過計算得出,不走索引。那麼究竟為哪些內存規格建立 lookup 索引呢 ?

這就用到了前面介紹的 lookupMaxSize(4K),Netty 只會為 4K 以下的內存規格建立索引,4K 按照 16 的間隔可以劃分出 256 個 lookup size 尺寸,大小剛好合適,而且都是高頻使用的內存規格。

image

這樣一來,只要是 4K 以下的任意 lookupSize,Netty 都可以通過 size2idxTab 索引表在 O(1) 的複雜度下迅速找到與其最接近的內存規格。

但在構建 size2idxTab 索引的時候有一個特殊的點需要注意,在內存規格表中,規格 index 7 之後的內存規格之間的差值並不是恰好是 16 ,而是 16 的 2 的次冪倍數。

比如 sizeIndex 7 和 8 對應的內存規格之間差值是 32 (2 16),sizeIndex 11 和 12 對應的內存規格之間差值是 64 (4 16),sizeIndex 26 和 27 對應的內存規格之間差值是 512 (32 * 16)。

而 size2idxTab 中規劃的 lookupSize 尺寸是按照 16 遞增的,所以在 sizeIndex 7 和 8 之間,我們需要劃分出兩個 lookupSize:144 , 160 , 對應的 lookupIndex 是 8 , 9 ,它們對應的內存規格都是 160B(sizeIndex = 8)。

同樣的道理, sizeIndex 11 和 12 之間,我們需要劃分出四個 lookupSize:272 , 288 , 304 , 320 。對應的 lookupIndex 是 16 , 17 , 18 , 19 。它們對應的內存規格都是 320B(sizeIndex = 12)。

sizeIndex 26 和 27 之間需要劃分出 32 個 lookupSize,對應的內存規格都是 4K (sizeIndex = 27)。

    private static int[] newSize2idxTab(int lookupMaxSize, short[][] sizeClasses) {
        // size2idxTab 中的 lookupSize 按照 16 依次遞增,最大為 4K
        // 因此 size2idxTab 大小為 4K / 16
        int[] size2idxTab = new int[lookupMaxSize >> LOG2_QUANTUM];
        // lookupIndex
        int idx = 0;
        // lookupSize
        int size = 0;
        // 遍歷 4K 以下的內存規格表 sizeClasses,建立 size2idxTab
        for (int i = 0; size <= lookupMaxSize; i++) {
            int log2Delta = sizeClasses[i][LOG2DELTA_IDX];
            // 計算規格之間的差值是 16 的幾倍
            // 比如 sizeIndex 7 和 8 對應的內存規格之間差值是 32 (2 * 16)
            // 那麼這兩個內存規格之間就需要劃分出 times 個 lookupSize
            int times = 1 << log2Delta - LOG2_QUANTUM;
            // 構建 size2idxTab
            while (size <= lookupMaxSize && times-- > 0) {
                // lookupIndex 與 sizeIndex 之間的映射
                size2idxTab[idx++] = i;
                // lookupSize 按照 16 依次遞增
                size = idx + 1 << LOG2_QUANTUM;
            }
        }
        return size2idxTab;
    }

好了,現在 lookupSize 在 4K 以下,我們可以通過 size2idxTab 找到與其最接近的內存規格,那麼 5K 到 4M 之間的 lookupSize,我們又該如何查找其對應的內存規格呢 ?

前面筆者提到過,Netty 將 68 種內存規格劃分成了 17 個內存規格組,內存規格組編號從 0 到 16 。每個內存規格組內有四個規格。給定一個任意的 lookupSize,我們首先的思路是不是要確定這個 lookupSize 到底是屬於哪一個內存規格組 ?然後在確定這個 lookupSize 最接近組內第幾個規格 ?

現在思路有了,下面我們來看第一個問題,如何確定 lookupSize 究竟屬於哪一個內存規格組 ?

還記不記得筆者之前反覆強調過的一個特性 —— 每個內存規格組內最後一個規格都是 2 的次冪,第 0 個內存規格組最後一個規格是 64B,第 1 個內存規格組最後一個規格是 128B , 第 2 個是 256B , 第 3 個是 512B,第 4 個是 1K, ...... ,第 16 個是 4M 。

我們根據每組最後一個規格的尺寸,就可以得到這樣一個數列 —— 64 , 128 , 256 , 512 , 1K , ....... , 4M。這個數列有一個特點就是從 64 開始逐漸按照 2 的次冪倍數增長。因此,我們將數列中的每項除以 64 就得到一個新的數列 —— 2^0 , 2^1 , 2^2 , 2^3 , 2^4 , 2^5 .........。而新數列中,每一項的對數就是內存規格組的編號了。

這個邏輯明確之後,剩下的實現就很簡單了,首先我們需要找到 lookupSize 所在內存規格組的最後一個規格 , 直接對 lookupSize 向上取最接近的 2 的次冪。

// 組內最後一個內存規格的對數
int x = log2((lookupSize << 1) - 1);

在組內最後一個內存規格現在明確了,我們將它除以 64 ,然後取商的對數就得到了 shift —— 內存規格組編號。

// lookupSize 所在內存規格組編號
int shift = x - (LOG2_SIZE_CLASS_GROUP + LOG2_QUANTUM)

有了 shift 之後,我們很容易就能確定出組內第一個規格的 index , 每個內存規格組內有 4 個規格,現在我們是第 shift 個內存規格組,該組第一個規格的 index 就是 shift * 4 。

// 組內第一個規格的 index
int group = shift << LOG2_SIZE_CLASS_GROUP;

而每個內存規格組內,規格之間的間隔都是相同的,通過 x - LOG2_SIZE_CLASS_GROUP - 1 獲取組內間隔 log2Delta。

// 組內規格間隔
int log2Delta =  x - LOG2_SIZE_CLASS_GROUP - 1;

在有了 group 和 log2Delta 之後,我們很容易就能確定這個 lookupSize 最接近組內第幾個規格 —— lookupSize - 1 >> log2Delta & 3

int mod = lookupSize - 1 >> log2Delta & (1 << LOG2_SIZE_CLASS_GROUP) - 1;

最後 group + mod 就是該 lookupSize 對應的內存規格 index 。下面筆者用一個具體的例子進行説明,假設我們現在要向內存池申請 5000B 的內存塊。

image

5000B 所在內存規格組最後一個規格是 8K,8K 除以 64 得到商的對數就是 7 ,説明 5000B 這個內存尺寸位於第 7 個內存規格組內。組內第一個規格 index
是 28 ,組內間距 log2Delta = 10 。計算出的 mod 恰好是 0 。也就是説與 5000B 最貼近的內存規格是 5K , 對應的規格 index 是 28 。

final class SizeClasses {

    @Override
    public int size2SizeIdx(int size) {
        if (size == 0) {
            return 0;
        }
        // Netty 只會池化 4M 以下的內存塊
        if (size > chunkSize) {
            return nSizes;
        }
        // 將 lookupSize 與 Alignment 進行對齊
        size = alignSizeIfNeeded(size, directMemoryCacheAlignment);
        // lookupSize 在 4K 以下直接去 size2idxTab 中去查
        if (size <= lookupMaxSize) {
            return size2idxTab[size - 1 >> LOG2_QUANTUM];
        }
        // 向上取 size 最接近的 2 的次冪,目的是獲取所屬內存規格組的最後一個規格尺寸
        int x = log2((size << 1) - 1);
        // size 所在內存規格組編號,最後一個規格尺寸除以 64 得到商的對數
        int shift = x < LOG2_SIZE_CLASS_GROUP + LOG2_QUANTUM + 1
                ? 0 : x - (LOG2_SIZE_CLASS_GROUP + LOG2_QUANTUM);
        // 內存規格組內第一個規格 index
        int group = shift << LOG2_SIZE_CLASS_GROUP;
        // 組內規格之間的間隔
        int log2Delta = x < LOG2_SIZE_CLASS_GROUP + LOG2_QUANTUM + 1
                ? LOG2_QUANTUM : x - LOG2_SIZE_CLASS_GROUP - 1;
        // size 最貼近組內哪一個規格
        int mod = size - 1 >> log2Delta & (1 << LOG2_SIZE_CLASS_GROUP) - 1;
        // 返回對應內存規格 index
        return group + mod;
    }
}

4. PoolChunk 的設計與實現

image

如上圖所示,PoolChunk 在整個內存池的架構設計中是屬於最基礎的數據結構,負責管理 Page 級別的內存塊,Netty 中一個 Page 大小為 8K ,一個 PoolChunk 的大小為 4M , 也就是説,一個 PoolChunk 管理着 512 個 Page 。

static final class DirectArena extends PoolArena<ByteBuffer> {
        @Override
        protected PoolChunk<ByteBuffer> newChunk(int pageSize, int maxPageIdx,
            int pageShifts, int chunkSize) {
            if (sizeClass.directMemoryCacheAlignment == 0) {
                // 分配一個 4M 大小的 DirectByteBuffer
                ByteBuffer memory = allocateDirect(chunkSize);
                // 創建 PoolChunk,管理這 4M 的內存空間
                return new PoolChunk<ByteBuffer>(this, memory, memory, pageSize, pageShifts,
                        chunkSize, maxPageIdx);
            }
            // 如果是需要按照指定的 Alignment 對齊的話
            // 則申請 4M +  directMemoryCacheAlignment 大小的 DirectByteBuffer
            final ByteBuffer base = allocateDirect(chunkSize + sizeClass.directMemoryCacheAlignment);
            // 將 DirectByteBuffer 的 position 位置與 directMemoryCacheAlignment 對齊
            final ByteBuffer memory = PlatformDependent.alignDirectBuffer(base, sizeClass.directMemoryCacheAlignment);
            // 地址對齊之後,創建 PoolChunk
            return new PoolChunk<ByteBuffer>(this, base, memory, pageSize,
                    pageShifts, chunkSize, maxPageIdx);
        }
}

在創建 PoolChunk 的時候,Netty 首先會向 OS 申請一段 4M 大小的內存空間,然後由 JDK 將這 4M 的內存空間包裝成 DirectByteBuffer,封裝在 PoolChunk 的 base , memory 字段中。

final class PoolChunk<T> {
    // PoolChunk 底層依賴的這 4M 內存空間用 JDK 的 ByteBuffer 包裝
    final Object base;
    // 內存地址對齊之後的 ByteBuffer
    final T memory;
}

有了這基礎的 4M 內存空間之後,Netty 會在這個基礎之上近一步建立核心的管理結構,比如第一小節中介紹的核心數據結構 runsAvail ,來組織不同 Page 級別的內存塊。

 private final IntPriorityQueue[] runsAvail;

除此之外,Netty 還定義了一些概念用於從不同的角度上來描述內存塊,並在此基礎上設計了一些輔助管理結構。下面我們先將這些基礎概念一一梳理清楚:

  1. Page 這個概念我們前面已經多次提到了,它是內存池的基本管理單位,PoolChunk 就是按照 Page 為粒度來管理內存的。一個 Page 尺寸為 8K 。
  2. Run 指的是一個或者多個 Page , 它是 PoolChunk 的基本管理單位,可以用來分配 Normal 規格的內存塊,以及分配 Subpage。
  3. Subpage 本質上也是一個 Run,內部包含了一個或者多個 Page , 負責管理 Small 規格的內存塊,對應的管理結構就是第一小節中介紹的 PoolSubpage,沿襲了內核中的 slab 設計思想,將一個 Run 按照 Small 規格切分成多個小內存塊。
  4. Handle 用於描述一個內存塊,其實 Run , Subpage 也表示的是內存塊,只不過它們是在內存管理的角度上對內存塊的描述,而 Handle 特指的是從內存池中分配出去的內存塊,對於 Normal 規格的內存塊來説,這個 Handle 其實就是一個 Run , 對於 Small 規格的內存塊來説,這個 Handle 就是從 Subpage 中分配出去的一個小內存塊。Netty 會將 Handle 轉換成 PooledByteBuf 返回給用户使用。

PoolChunk 中的 4M 內存空間佈局如下圖所示:

image

Run 和 Subpage 究其本質而言其實都是一個內存塊,Netty 用一個叫做 Handle 的結構來描述所有不同尺寸的內存塊,那麼這個 Handle 到底是一個什麼樣的數據結構呢 ? 其實很簡單,它就是一個 long 型的整數,其 64 位 bit 佈局如下圖所示:

image

Handle 的這 64 位 bit 分別描述了內存塊的五個信息,筆者從高位到低位依次介紹一下它們的含義:

首先 PoolChunk 是按照 Page 為粒度來管理內存的,而 Run 用於描述一個或者多個 Page,因此 PoolChunk 中內存管理的基本單位是 Run 。

其次 Normal 內存規格以及 Small 內存規格的內存塊全部來自於 PoolChunk,對於 Normal 規格來説其實就是 PoolChunk 中的一個 Run , 對於 Small 規格來説就是 PoolSubpage 中的一個小內存塊,而 PoolSubpage 本身也是一個 Run。

所以對於一個內存塊來説,我們首先需要清楚它是來自於 PoolChunk 中的哪一個 Run , 這就用到了 Handle 中的 runOffset,用第 49 到 63 位共 15 個 bits 表示。runOffset 指的是該內存塊在 PoolChunk 中的偏移,注意這裏的偏移單位是 Page 。

第 34 到 48 位共 15 個 bits 表示 size , size 指的是該內存塊包含的 Page 個數。

 private static final int SIZE_BIT_LENGTH = 15;

第 33 位共 1 個 bit 表示該內存塊是否已經被分配了(isUsed)。

private static final int INUSED_BIT_LENGTH = 1;

第 32 位共 1 個 bit 表示該內存塊是否作為 PoolSubpage 來管理 Small 內存規格 。

private static final int SUBPAGE_BIT_LENGTH = 1;

到目前為止,這些信息就足夠表示一個 Normal 規格的內存塊了。有了 runOffset,我們可以知道這個內存塊的起始位置,也就是內存塊中第一個 Page 在 PoolChunk 中的偏移。有了 size ,我們就可以知道這個內存塊包含的 Page 個數。

那麼對於 Small 規格的內存塊來説,Handle 結構又該如何表示呢 ? 我們知道 Small 規格的內存塊是被 PoolSubpage 管理的,PoolSubpage 會將一個完整的 Run 按照 Small 規格的尺寸切分成多個大小相等的小內存塊。

image

這些小內存塊在 PoolSubpage 中用一個 bitmap 來描述,因此當我們用 Handle 結構來描述 Small 規格的內存塊時,我們需要知道這個 Handle 具體表示的是 PoolSubpage 中哪一個小內存塊,所以我們需要將這個小內存塊在 bitmap 中的 index 記錄在 Handle 結構中。

Handle 中的第 0 到 31 位共 32 個 bits 就是用來記錄 bitmapIdx 的, 對於 Normal 規格的內存塊來説(isSubpage = false), 這 32 位 bit 全部是零。

private static final int BITMAP_IDX_BIT_LENGTH = 32;

為了快速地從 Handle 的 64 位 bits 中提取上述五種信息,Netty 定義了相關的 SHIFT 偏移。

image

    static final int IS_SUBPAGE_SHIFT = BITMAP_IDX_BIT_LENGTH;
    static final int IS_USED_SHIFT = SUBPAGE_BIT_LENGTH + IS_SUBPAGE_SHIFT;
    static final int SIZE_SHIFT = INUSED_BIT_LENGTH + IS_USED_SHIFT;
    static final int RUN_OFFSET_SHIFT = SIZE_BIT_LENGTH + SIZE_SHIFT;

將 handle 右移相關的 SHIFT 位就得到了相應的內存塊信息:

    static int runOffset(long handle) {
        return (int) (handle >> RUN_OFFSET_SHIFT);
    }

    static int runPages(long handle) {
        return (int) (handle >> SIZE_SHIFT & 0x7fff);
    }

    static boolean isUsed(long handle) {
        return (handle >> IS_USED_SHIFT & 1) == 1L;
    }

    static boolean isSubpage(long handle) {
        return (handle >> IS_SUBPAGE_SHIFT & 1) == 1L;
    }

    static int bitmapIdx(long handle) {
        return (int) handle;
    }

當我們從 PoolChunk 中申請一個 Run 時(Normal 規格的內存塊),Netty 會通過 toRunHandle 將 Run 信息轉換為 Handle 。

    private static long toRunHandle(int runOffset, int runPages, int inUsed) {
        return (long) runOffset << RUN_OFFSET_SHIFT
               | (long) runPages << SIZE_SHIFT
               | (long) inUsed << IS_USED_SHIFT;
    }

當我們從 PoolSubpage 中申請一個 Small 規格的內存塊時,Netty 會通過 toHandle 將小內存塊信息轉換為 Handle。

    private long toHandle(int bitmapIdx) {
        // subPage 中包含的 page 個數
        int pages = runSize >> pageShifts;
        // 低 32 位保存 bitmapIdx
        return (long) runOffset << RUN_OFFSET_SHIFT
               | (long) pages << SIZE_SHIFT
               | 1L << IS_USED_SHIFT
               | 1L << IS_SUBPAGE_SHIFT
               | bitmapIdx;
    }

好了,現在 Run 的概念我們清楚了,它的本質就是 PoolChunk 這 4M 的內存空間中由一個或者多個 Page 組成的內存塊,PoolChunk 中管理了多個不同尺寸的 Run。同時我們也明白瞭如何用 Handle 結構來表示一個 Run。那麼接下來 PoolChunk 是如何管理這些 Run 呢 ? 這就用到了第一小節中我們介紹的 runsAvail 數組,它是 Netty 中的夥伴系統實現。

 private final IntPriorityQueue[] runsAvail;

在第三小節介紹 Netty 內存規格劃分的時候,我們看到 Netty 一共劃分了 32 種不同 Page 級別的內存塊尺寸,當然了,現在我們應該用 Run 這個概念來描述這些內存塊。相關的索引建立在 pageIdx2sizeTab 中。Run 的尺寸分別為:1 個 Page , 2 個 Page , ....... , 512 個 Page,共 32 種 Run 尺寸。

image

runsAvail 數組的大小也是 32 ,很容易理解,數組中的每一個 IntPriorityQueue 用於組織相同尺寸的 Run,而且這些 Run 是按照內存地址從低到高的順序組織在這個 IntPriorityQueue 中。這樣我們每次向 PoolChunk 申請的 Run 都是從低地址開始。runsAvail 的下標對應的就是 pageIdx2sizeTab 中的 pageIndex 。

    // 參數 size 就是 SizeClasses 中計算出的 nPSizes,共 32 種 Run 尺寸
    private static IntPriorityQueue[] newRunsAvailqueueArray(int size) {
        IntPriorityQueue[] queueArray = new IntPriorityQueue[size];
        for (int i = 0; i < queueArray.length; i++) {
            queueArray[i] = new IntPriorityQueue();
        }
        return queueArray;
    }

除此之外,Netty 還設計了一個輔助性的數據結構 —— runsAvailMap,runsAvail 我們知道,它是 Netty 中的夥伴系統,用於組織管理 PoolChunk 中不同大小尺寸的 Run 。而 runsAvailMap 則是建立 Run 在 PoolChunk 中的 Page 偏移索引。Key 是 runOffset , Value 則是 Run 對應的 Handle 結構。

 private final LongLongHashMap runsAvailMap;

每當一個新的 Run 被加入到 runsAvail 之後,Netty 都會將這個 Run 中第一個 Page 的 runOfffset 以及最後一個 Page 的 runOfffset 添加到 runsAvailMap 中。也就是説 PoolChunk 中的任意一個 Run, 對應到 runsAvailMap 中都會有相應的兩條索引。

  1. key : 第一個 Page 的 runOffset ,value : Run 對應的 Handle 結構
  2. key : 最後一個 Page 的 runOffset ,value : Run 對應的 Handle 結構

這裏的 runOffset 就是相關 Page 在 PoolChunk 中的偏移。

image

Netty 這裏設計這個 runsAvailMap 的目的是用來做什麼的呢 ? 經過第一小節的內容介紹我們知道,當我們將一個內存塊 Run 釋放回 PoolChunk 的時候會涉及到連續內存塊合併的過程,內核中的夥伴系統也是這樣。

    private long collapseRuns(long handle) {
        // 在 PoolChunk 中首先不斷地向前合併與 handle 內存連續的 Run
        // 然後在不斷地向後合併內存連續的 Run,最終形成一個更大的 Run 存儲到 runsAvail 中
        return collapseNext(collapsePast(handle));
    }

那 Netty 如何判斷哪些內存塊是連續的呢 ?這就用到了 runsAvailMap,因為它索引了每個內存塊的 first page runOffset 和 last page runOffset , 這樣 Netty 就可以用 O(1) 的複雜度迅速找到在 PoolChunk 中連續的內存塊 Run 了。

比如説,我們現在要將下圖中白色的 Run 釋放回 PoolChunk,那麼 Netty 就需要找到所有與白色 Run 連續的 Run。很明顯,綠色的 Run 與白色 Run 前面連續,藍色的 Run 與白色 Run 後面連續。

image

首先 Netty 會在 collapsePast 中不斷的向前合併與白色 Run 連續的 Run,白色 Run 的 runOffset 我們可以通過前面介紹的 runOffset(handle) 獲得,如果白色 Run 前面有連續的 Run , 那麼這個 Run 的 last page runOffset 一定是 runOffset - 1 。這時我們就可以通過 runOffset - 1 到 runsAvailMap 中去查找是否有這條索引。

int runOffset = runOffset(handle);
long pastRun = getAvailRunByOffset(runOffset - 1);

比如,白色 Run 的 runOffset 是 5 , 我們通過 4 去 runsAvailMap 中一下就找到了綠色 Run 的 Handle 結構,然後我們將白色 Run 與綠色 Run 合併成一個更大的 Run。合併之後的 runOffset 就是綠色 Run 的 runOffset (3) , 合併之後的 size 就是原來白色 Run 和綠色 Run 的 size 之和(5)。

image

           // 白色 Run 的 size (包含 Page 的個數)
           int runPages = runPages(handle);
           // 綠色 Run 的 runOffset
           int pastOffset = runOffset(pastRun);
           // 綠色 Run 的 size
           int pastPages = runPages(pastRun);

           if (pastRun != handle && pastOffset + pastPages == runOffset) {
                // 清除綠色 Run 在 runsAvailMap 中的相關索引
                removeAvailRun(pastRun);
                // 白色,綠色合併成一個更大的 Run,
                // 新的 runOffset 為綠色 Run 的 runOffset
                // 新的 size 為兩者之和
                handle = toRunHandle(pastOffset, pastPages + runPages, 0);
            }

當第一輪合併結束之後,我們還需要繼續向前不斷的合併,因為有可能還存在與新的 Run 內存連續的 Run(黃色),於是重複上述合併過程,用新的 runOffset - 1 (2) 再去 runsAvailMap 中查找,發現有一個黃色的 Run。繼續合併,直到前面完全沒有連續的 Run 為止。

image

    private long collapsePast(long handle) {
        // 不斷地向前合併內存連續的 Run
        for (;;) {
            // 釋放內存塊的 runOffset
            int runOffset = runOffset(handle);
            // 釋放內存塊 size (包含 Page 的個數)
            int runPages = runPages(handle);
            // 查看該內存塊前面是否有連續的 Run
            // 如果有 pastRun 中的 lastPageOffset 一定是 runOffset - 1
            long pastRun = getAvailRunByOffset(runOffset - 1);
            if (pastRun == -1) {
                // 前面不存在連續的 Run 則停止向前合併
                return handle;
            }
            // 連續內存塊的 runOffset
            int pastOffset = runOffset(pastRun);
            // 連續內存塊的 size
            int pastPages = runPages(pastRun);

            // is continuous
            if (pastRun != handle && pastOffset + pastPages == runOffset) {
                // 將 pastRun 在 runsAvailMap 中的相關索引刪除
                removeAvailRun(pastRun);
                // 重新合併成一個更大的 Run
                handle = toRunHandle(pastOffset, pastPages + runPages, 0);
            } else {
                // 前面沒有連續的 Run , 停止向前合併
                return handle;
            }
        }
    }

當向前合併的過程結束之後,Netty 緊接着就會向後繼續合併,如果後面存在連續的 Run, 那麼這個 nextRun 的 runOffset 一定是待合併 Run 的 runOffset 加上 runPages。比如上圖中展示的白色 Run , 它的 runOffset = 0 , runPages = 8。藍色 Run 與它連續,runOffset 為 8 。

當白色 Run 與藍色 Run 合併之後,就形成了一個新的更大的 Run,它的 runOffset 就是白色 Run 的 runOffset (0) , 它的 size 就是兩者之和。

image

    private long collapseNext(long handle) {
        // 這裏的 handle 就是向前合併之後新的 Run
        // 不斷的向後合併連續的 Run
        for (;;) {
            int runOffset = runOffset(handle);
            int runPages = runPages(handle);
            // 向後查找內存連續的 Run, nextRun 的 firstPageOffset = runOffset + runPages
            long nextRun = getAvailRunByOffset(runOffset + runPages);
            if (nextRun == -1) {
                // 後面不存在內存連續的 Run
                return handle;
            }

            int nextOffset = runOffset(nextRun);
            int nextPages = runPages(nextRun);

            //is continuous
            if (nextRun != handle && runOffset + runPages == nextOffset) {
                //remove next run
                removeAvailRun(nextRun);
                handle = toRunHandle(runOffset, runPages + nextPages, 0);
            } else {
                return handle;
            }
        }
    }

當所有連續的 Run 全部合併之後,Netty 就會將這個更大的 Run 放入 runsAvail 中緩存起來。

以上就是 PoolChunk 關於 Run 管理的核心內容,但 PoolChunk 除了負責分配 Run 之外,還會分配 PoolSubpage。由這個 PoolChunk 分配出去的所有 PoolSubpage 都會被組織在 subpages 數組中。

    /**
     * manage all subpages in this chunk
     */
    private final PoolSubpage<T>[] subpages;

而 subpages 數組中的索引就是對應 PoolSubpage 的 runOffset ,一個 PoolChunk 中一共有 512 個 Page , 相應的 runOffset 就會有 512 種,所以 subpages 數組的長度為 512 。

subpages = new PoolSubpage[chunkSize >> pageShifts];

image

那麼 Netty 設計這個 subpages 數組的目的又是什麼呢 ?我們都知道 PoolSubpage 主要是負責分配 Small 規格的小內存塊的,那麼當我們要釋放一個 Small 規格的小內存回內存池的時候,我們該如何判斷這個小內存塊到底屬於哪個 PoolSubpage 呢 ?

這就用到了這裏的 subpages 數組,經過前面的介紹我們知道,Netty 中所有尺寸的內存塊都會用一個 Handle 結構來描述,我們可以通過 runOffset(long handle) 找到該內存塊在 PoolChunk 中的 runOffset,有了 runOffset 就可以到 subpages 數組中找到對應的 PoolSubpage 了。然後將這個小內存塊釋放回對應的 PoolSubpage 中。

現在 PoolChunk 的整個管理架構筆者就介紹完了,除去整個架構之外,這裏要額外提一點的是 PoolChunk 還有一個 cachedNioBuffers 緩存結構,它裏面緩存的是 ByteBuffer。

private final Deque<ByteBuffer> cachedNioBuffers;

默認情況下,cachedNioBuffers 可以緩存 1023 個 ByteBuffer,我們可以通過 -Dio.netty.allocator.maxCachedByteBuffersPerChunk 參數來進行調節。

      DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK = SystemPropertyUtil.getInt(
                "io.netty.allocator.maxCachedByteBuffersPerChunk", 1023);

那麼這裏的 cachedNioBuffers 是幹什麼的 ? 它裏面緩存的這些 ByteBuffer 又是什麼呢 ?

在本小節的開始,筆者介紹過,當我們創建一個 PoolChunk 的時候,Netty 首先會向 OS 申請一段 4M 大小的內存空間,隨後由 JDK 將這 4M 的內存空間封裝成 DirectByteBuffer,保存在 PoolChunk 的 memory 字段中。也就是説,PoolChunk 中的這 4M 內存空間是由 JDK 的 ByteBuffer 來描述的。

final class PoolChunk<T> {
    // PoolChunk 底層依賴的這 4M 內存空間用 JDK 的 ByteBuffer 包裝
    // memory 就是 PoolChunk 底層的 ByteBuffer(4M)
    final T memory;
}

image

當我們向 PoolChunk 申請到一個 Run (下圖綠色部分)之後,注意,這個 Run 現在的表現形式只是一個 Handle 結構,我們需要將其包裝 PooledByteBuf 返回給用户使用。

image

而這個 PooledByteBuf 其實直接操作的是 PoolChunk 中的 memory(ByteBuffer),只不過 PooledByteBuf 擁有自己獨立的相關 index , 這些 index 將這個 PooledByteBuf 的可操作內存範圍控制在上圖中 readerIndex 到 length 之間。

abstract class PooledByteBuf<T> {
    int readerIndex;
    int writerIndex;
    // 我們向內存池請求的內存大小 , 其實就是 ByteBuf 的 capacity
    protected int length;
    // 內存池實際分配給我們的內存大小
    int maxLength;
    // PoolChunk 中 memory 的 duplicate 視圖
    ByteBuffer tmpNioBuf;
}

因此每一個 PooledByteBuf 都需要依賴一個 tmpNioBuf,這個 tmpNioBuf 正是 PoolChunk 中 memory 的 duplicate 視圖,其底層依賴的 4M 內存空間和 PoolChunk 是一模一樣的。

    @Override
    protected ByteBuffer newInternalNioBuffer(ByteBuffer memory) {
        return memory.duplicate();
    }

當內存池創建 PooledByteBuf 的時候都需要傳入一個完整的 PoolChunk 內存視圖(memory.duplicate) ,這些內存視圖就緩存在 cachedNioBuffers 中,裏面的 ByteBuffer 正是 PoolChunk 中 memory 的 duplicate 視圖。

好了,到現在為止,PoolChunk 中所有的核心組件設計,筆者就全部介紹完了,但目前的 PoolChunk 只是剛剛被創建出來,還是一個空的 PoolChunk,其內部夥伴系統 runsAvail 中沒有任何的 Run 。

image

那麼在初始狀態下,Netty 會將 PoolChunk 的這 4M 內存空間組裝成一個大的 Run,放入到 runsAvail 中。

image

該 Run (initHandle)的 runOffset = 0 , size = 512 , isUsed = 0 , isSubpage = 0 , bitmapIdx = 0。

int pages = chunkSize >> pageShifts;
long initHandle = (long) pages << SIZE_SHIFT;
// 插入到 runsAvail 中
insertAvailRun(0, pages, initHandle);

隨後通過 insertAvailRun 方法將 initHandle 插入到中 runsAvail 中,但在插入之前我們需要知道這個 initHandle 應該插入到哪一個 IntPriorityQueue 中。

initHandle 的尺寸 size 是 512 個 Page (4M) , 在第三小節介紹的 Page 規格表 pageIdx2sizeTab 中,我們可以看到 4M 對應的 pageIndex 為 31。所以這個 initHandle 最終會被插入到 runsAvail[31] 中。

image

    private void insertAvailRun(int runOffset, int pages, long handle) {
        // 512 個 Page 在 Page 規格表中對應的 pageIdx 為 31
        int pageIdxFloor = arena.sizeClass.pages2pageIdxFloor(pages);        
        IntPriorityQueue queue = runsAvail[pageIdxFloor];
        // 將 4M 的 Run 插入到 runsAvail[31] 中
        queue.offer((int) (handle >> BITMAP_IDX_BIT_LENGTH));
        // 將 Run 中第一個 Page 的 runOffset 以及最後一個 Page 的 runOffset 插入到 runsAvailMap 中
        insertAvailRun0(runOffset, handle);
        if (pages > 1) {
            insertAvailRun0(lastPage(runOffset, pages), handle);
        }
    }

每當向 runsAvail 中插入一個 Run 之後,Netty 都會將該 Run 中第一個 Page 的 runOffset (0) 以及最後一個 Page 的 runOffset (511) 插入到 runsAvailMap 中。

    private void insertAvailRun0(int runOffset, long handle) {
        long pre = runsAvailMap.put(runOffset, handle);
        assert pre == -1;
    }

現在一個完整的 PoolChunk 就被初始化好了,下面是 PoolChunk 的完整創建過程:

    PoolChunk(PoolArena<T> arena, Object base, T memory, int pageSize, int pageShifts, int chunkSize, int maxPageIdx) {
        // 只對 4M 的 PoolChunk 進行池化
        unpooled = false;
        // PoolChunk 所屬的 PoolArena
        this.arena = arena;
        // PoolChunk 底層依賴的 JDK ByteBuffer (4M)
        this.base = base;
        this.memory = memory;
        // 8K
        this.pageSize = pageSize;
        // 13
        this.pageShifts = pageShifts;
        // 4M
        this.chunkSize = chunkSize;
        // PoolChunk 剩餘的內存空間,初始為 4M
        freeBytes = chunkSize;
        // 創建 runsAvail 數組,Netty 中的夥伴系統
        // index 為 PageIndex
        runsAvail = newRunsAvailqueueArray(maxPageIdx);
        runsAvailLock = new ReentrantLock();
        // runsAvail 中所有 Run 的 first page runOffset 以及 last page runOffset
        runsAvailMap = new LongLongHashMap(-1);
        // 負責組織所有由這個 PoolChunk 分配出去的 PoolSubpage
        // index 為 PoolSubpage 的 runOffset
        subpages = new PoolSubpage[chunkSize >> pageShifts];
        // PoolChunk 在初始狀態下只有一個 Run
        // size 為 512 個 Page(4M)
        int pages = chunkSize >> pageShifts;
        // 初始run : runOffset = 0 (15 bit) , size = 512(15 bits) , isUsed = 0 (1bit) , isSubpage = 0 (1bit), bitmapIdx = 0 (32bits)
        long initHandle = (long) pages << SIZE_SHIFT;
        // 將初始 run 插入到 runsAvail 數組中
        insertAvailRun(0, pages, initHandle);
        // 可緩存 1023 個 memory 的 duplicate 視圖
        cachedNioBuffers = new ArrayDeque<ByteBuffer>(8);
    }

PoolChunk 裏面還有一個 unpooled 屬性,用來指定該 PoolChunk 是否加入到內存池中管理。

final boolean unpooled;

一個普通的 PoolChunk 大小為 4M , 負責分配 PoolSubpage(管理 Small 規格的內存塊)以及 Normal 規格的內存塊,那麼這種類型的 PoolChunk 肯定是要被內存池管理的(unpooled = false)。

image

但除了 Small 規格和 Normal 規格之外,Netty 還有一種 Huge 規格(超過 4M),而內存池並不會管理 Huge 規格的內存塊,當我們申請的內存超過 4M 的時候,Netty 會直接向 OS 進行申請,並不會經過內存池。釋放的時候也是直接釋放回 OS 中。

abstract class PoolArena<T> {

    private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
        // 獲取 reqCapacity 在 sizeIdx2sizeTab 中的 index(對應的內存規格 index)
        final int sizeIdx = sizeClass.size2SizeIdx(reqCapacity);
        // [16B , 28K] 之間是 small 規格的內存
        if (sizeIdx <= sizeClass.smallMaxSizeIdx) {
            tcacheAllocateSmall(cache, buf, reqCapacity, sizeIdx);
        } else if (sizeIdx < sizeClass.nSizes) {
            // [32K , 4M] 之間是 normal 規格的內存
            tcacheAllocateNormal(cache, buf, reqCapacity, sizeIdx);
        } else {
            // 超過 4M 就是 Huge 規格
            int normCapacity = sizeClass.directMemoryCacheAlignment > 0
                    ? sizeClass.normalizeSize(reqCapacity) : reqCapacity;
            // huge 內存規格直接向操作系統申請,不會被內存池管理
            allocateHuge(buf, normCapacity);
        }
    }
}

對於 Huge 規格的內存塊的來説,Netty 也會用 PoolChunk 這個結構來描述,但很明顯 Huge 規格並不會被內存池管理,所以在 allocateHuge 方法分配 Huge 規格的 PoolChunk 時,這裏的 unpooled 就會指定為 true。

    /** Creates a special chunk that is not pooled. */
    PoolChunk(PoolArena<T> arena, Object base, T memory, int size) {
        // Huge 規格的內存塊不會被內存池管理
        unpooled = true;
        this.arena = arena;
        this.base = base;
        this.memory = memory;
        pageSize = 0;
        pageShifts = 0;
        runsAvailMap = null;
        runsAvail = null;
        runsAvailLock = null;
        subpages = null;
        chunkSize = size;
        cachedNioBuffers = null;
    }

4.1 PoolChunk 的內存分配流程

PoolChunk 在內存池中的作用主要是負責分配 Page 級別的內存規格尺寸(Run),其中包括 Normal 規格的內存塊,以及負責組織管理 Small 規格內存塊的 PoolSubpage。

每一種 Run 尺寸,在 PoolChunk 中都會有一個專門的 IntPriorityQueue 來組織管理,Netty 一共劃分了 32 種不同的 Run 尺寸,分別是:1 個 Page , 2 個 Page , ....... , 512 個 Page。每一種 Run 尺寸都會對應一個 pageIndex , 也就是第三小節中,筆者介紹的 Page 規格表 —— pageIdx2sizeTab。

32 種 Run 尺寸就對應 32 個 IntPriorityQueue,它們組織在 PoolChunk 中的 runsAvail 數組中,數組的 index 就是對應的 pageIndex。

image

當我們向 PoolChunk 申請一個 Run 的時候,我們就需要先到 pageIdx2sizeTab 中找到該 Run 尺寸對應的 pageIndex。比如,當我們申請 8K 的內存塊時,對應的 pageIndex 就是 0 。

隨後 Netty 就會根據這個 pageIndex 到 runsAvail 中找到對應 Run 尺寸的 IntPriorityQueue —— runsAvail[pageIndex] , 這個 IntPriorityQueue 中管理的全部是相同尺寸的 Run 。剩下的事情就好辦了,我們直接從 IntPriorityQueue 中獲取一個內存地址最低的 Run 分配出去就好了。

如果不巧 runsAvail[pageIndex] 是空的,那我們就繼續到上一層 runsAvail[pageIndex+1] 中去找,如果還是空的,那就繼續逐級向上去找,直到找到一個不為空的 IntPriorityQueue。

    private int runFirstBestFit(int pageIdx) {
        // 如果該 PoolChunk 是一個全新的,那麼直接就到 runsAvail[31] 中去找
        // 因為此時 PoolChunk 中只會包含一個 Run (大小為 4M)
        if (freeBytes == chunkSize) {
            return arena.sizeClass.nPSizes - 1;
        }
        // 按照夥伴查找算法,先從 pageIdx 規格開始查找對應的 IntPriorityQueue 是否有內存塊
        // 如果沒有就一直向後查找,直到找到一個不為空的 IntPriorityQueue
        for (int i = pageIdx; i < arena.sizeClass.nPSizes; i++) {
            IntPriorityQueue queue = runsAvail[i];
            if (queue != null && !queue.isEmpty()) {
                return i;
            }
        }
        // 如果 chunk 全部分配出去了,則返回 -1
        return -1;
    }

但是這樣一來,我們在 runsAvail[pageIndex + n] 中獲取到的 Run 尺寸一定大於我們請求的 runSize,所以需要近一步將這個 Run 進行切分,切出一個 runSize 然後分配出去,剩下的重新歸還到 runsAvail 中。

下面我們來一個具體的例子來説明 PoolChunk 的內存分配邏輯,假設現在有一個剛剛被初始化好的 PoolChunk,如下圖所示。現在我們要向這個 PoolChunk 申請一個 8K 大小(runSize)的內存塊。

image

首先我們要去 Page 規格表中找到 8K 對應的 pageIndex(0):

        // runSize 為 8K ,一個 Page 大小
        int pages = runSize >> pageShifts; // 1
        // 8K 對應在 pageIdx2sizeTab 中的 pageIdx 為 0 
        int pageIdx = arena.sizeClass.pages2pageIdx(pages);

由於現在的 PoolChunk 剛剛被初始化好,所以 runsAvail[0] 中一定是空的,我們直接到 runsAvail[31] 中查找,發現對應的 IntPriorityQueue 中只有一個 Run,大小為 4M,一共 512 個 Page 。

但我們只需要 1 個 Page,所以需要將這個 4M 的 Run 分裂成兩個小的 Run , 第一個 Run 下圖綠色部分,大小恰好 1 個 Page,分配出去。第二個 Run 下圖黃色部分,大小為 511 個 Page ,重新歸還回 PoolChunk。

image

由於 511 個 Page 恰好在 3.5M 規格與 4M 規格之間,所以我們需要將黃色的 Run 歸還到 runsAvail[30] 中。

image

下面是內存塊分裂的實現邏輯:

    // handle 表示即將要被分裂的 Run (4M)
    // needPages 表示我們要申請的 Page 規格(8K)
    private long splitLargeRun(long handle, int needPages) {
        assert needPages > 0;
        // handle 中包含的 pages 個數(512)
        int totalPages = runPages(handle);
        // 剩餘 511 個 Page
        int remPages = totalPages - needPages;

        if (remPages > 0) {
            // handle 的 runOffset 為 0 
            int runOffset = runOffset(handle);
            // 獲取剩餘內存塊在 chunk 中的 runOffset (1)
            // [runOffset , availOffset - 1] 這段內存將會被分配出去
            int availOffset = runOffset + needPages; // 1
            // 將剩餘的內存塊重新包裝成 Run,runOffset = 1 ,size = 511
            long availRun = toRunHandle(availOffset, remPages, 0);
            // 將剩餘的 run, 重新放回到夥伴系統 runsAvail 中
            // 注意這裏並不會向內核那樣減半分裂,而是直接將 run 放回到 remPages 內存規格對應的 runsAvail 數組中
            insertAvailRun(availOffset, remPages, availRun);
            // 將 needPages 分配出去
            return toRunHandle(runOffset, needPages, 1);
        }

        // mark it as used
        handle |= 1L << IS_USED_SHIFT;
        return handle;
    }

PoolChunk 內存分配的總體邏輯如下:

    // runSize 為申請的內存大小
    private long allocateRun(int runSize) {
        // 計算 runSize 包含多少個 pages
        int pages = runSize >> pageShifts;
        // 獲取該 pages 尺寸對應在 pageIdx2sizeTab 中的 pageIdx
        int pageIdx = arena.sizeClass.pages2pageIdx(pages);

        runsAvailLock.lock();
        try {
            // 按照夥伴算法,從 pageIdx 開始在 runsAvail 數組中查找第一個不為空的 IntPriorityQueue
            int queueIdx = runFirstBestFit(pageIdx);
            // chunk 已經沒有剩餘內存了,返回 -1
            if (queueIdx == -1) {
                return -1;
            }
            // 獲取 queueIdx 對應內存規格的 IntPriorityQueue
            IntPriorityQueue queue = runsAvail[queueIdx];
            // 獲取內存地址最低的一個 run, 內存尺寸為 pageIdx2sizeTab[queueIdx]
            long handle = queue.poll();
            assert handle != IntPriorityQueue.NO_VALUE;
            // runOffset(15bits) , size(15bits) , isUsed(1bit), isSubPapge(1bit) , bitmapIndex(32bits)
            handle <<= BITMAP_IDX_BIT_LENGTH;
            assert !isUsed(handle) : "invalid handle: " + handle;
            // 從 runsAvailMap 中刪除該 run 的 offset 信息
            removeAvailRun0(handle);
            // 如果該 run 尺寸恰好和我們請求的 runSize 一致,那麼就直接分配
            // 如果該 run 尺寸大於我們請求的 runSize , 就需要將剩餘的內存塊放入到對應規格的 runsAvail 中
            handle = splitLargeRun(handle, pages);          
            int pinnedSize = runSize(pageShifts, handle);
            // 相應減少 chunk 的剩餘內存統計
            freeBytes -= pinnedSize;
            return handle;
        } finally {
            runsAvailLock.unlock();
        }
    }

4.2 PoolChunk 的內存回收流程

經過上一小節的內存分配流程之後,現在 PoolChunk 的結構如下圖所示,只有一個大小為 511 個 Page 的 Run , 保存在 runsAvail[30] 中。

image

現在我們將剛剛申請到的這個 8K 的內存塊重新釋放回 PoolChunk 中,8K 在 Netty 的 Page 規格表中的 pageIndex 為 0 ,但和內存分配流程不同的是,這 8K 的內存塊不能直接釋放回 runsAvail[0] 中。

而是首先需要根據前面我們介紹的 runsAvailMap,在整個 PoolChunk 中不斷的向前,向後查找與其連續的 Run ,然後將所有連續的 Run 合併成一個更大的 Run 釋放到相應規格的 runsAvail 中。

image

上圖中綠色 Run 是我們要釋放的 8K 內存塊,因為它的 runOffset 為 0 ,是整個 PoolChunk 中第一個 Run ,所以無法向前合併。但此時 PoolChunk 有一個黃色的 Run 與其緊緊相鄰,所以綠色 Run 需要與黃色的 Run 合併成一個更大的藍色 Run。

這樣一來就重新合併成了一個 4M 的 Run,歸還到 runsAvail[31] , 一切又回到了最初的起點。

image

    // handle 表示要釋放的內存塊
    // nioBuffer 是 PooledByteBuf 底層依賴的 PoolChunk 中 memory 的 duplicate 視圖
    // PooledByteBuf 用於包裝 handle 給用户使用
    void free(long handle, int normCapacity, ByteBuffer nioBuffer) {     
        // 獲取要釋放內存塊大小,字節為單位
        int runSize = runSize(pageShifts, handle);
        // start free run
        runsAvailLock.lock();
        try {
            // 在 PoolChunk 不斷的向前,向後合併連續的 Run
            long finalRun = collapseRuns(handle);
            // 重置 isUsed 位 = 0 
            finalRun &= ~(1L << IS_USED_SHIFT);
            // 重置 isSubpage 位 = 0
            finalRun &= ~(1L << IS_SUBPAGE_SHIFT);
            // 將合併後的 finalRun 重新插入到夥伴系統中
            insertAvailRun(runOffset(finalRun), runPages(finalRun), finalRun);
            // 更新 PoolChunk 剩餘內存的統計計數
            freeBytes += runSize;
        } finally {
            runsAvailLock.unlock();
        }
        // 將 nioBuffer 緩存到 cachedNioBuffers 中
        if (nioBuffer != null && cachedNioBuffers != null &&
            cachedNioBuffers.size() < PooledByteBufAllocator.DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
            // 默認可以緩存 1023 個 nioBuffer(全部都是 PoolChunk 的 duplicates 視圖)
            cachedNioBuffers.offer(nioBuffer);
        }
    }

5. PoolChunkList 的設計與實現

內存池中的 PoolChunk 並不是一個一個孤立存在的,而是被 PoolArena 按照內存使用率的不同組織在六個 PoolChunkList 中。

abstract class PoolArena<T> {
    // 按照不同內存使用率組織 PoolChunk
    private final PoolChunkList<T> q050;  // [50% , 100%) 
    private final PoolChunkList<T> q025;  // [25% , 75%) 
    private final PoolChunkList<T> q000;  // [1% , 50%)
    private final PoolChunkList<T> qInit; // [0% , 25%) 
    private final PoolChunkList<T> q075;  // [75% , 100%)
    private final PoolChunkList<T> q100;  // 100%
}

image

這六個 PoolChunkList 通過一個雙向鏈表相互關聯起來。

final class PoolChunkList<T> {
    // 頭指針,指向 List 中第一個 PoolChunk
    private PoolChunk<T> head;
    // 指向前一個 PoolChunkList
    private PoolChunkList<T> prevList;
    // 指向後一個 PoolChunkList
    private final PoolChunkList<T> nextList;
}

每一個 PoolChunkList 都規定了被其管理的 PoolChunk 內存使用率的上限和下限,隨着內存分配的不斷進行,PoolChunk 的內存使用率會越來越高,當達到上限時,就會被移動到下一個 PoolChunkList 中。而隨着內存釋放的不斷進行,PoolChunk 的內存使用率會越來越低,當低於下限時,就會被移動到前一個 PoolChunkList 中。

這裏比較特殊的兩個 PoolChunkList 是 qInit 和 q000 , q000 的前驅節點指向 null , 也就是説 q000 中的 PoolChunk 內存利用率只要低於 1% 就會被 Netty 釋放回 OS , 這裏設計 q000 的目的就是使得那些內存申請不那麼頻繁的 PoolChunk 能夠被及時的釋放掉,既然這些 PoolChunk 中內存使用的並不頻繁,那麼低於 1% 直接釋放就好了,沒必要繼續停留在內存池中,增加不必要的內存消耗。

    protected PoolArena(PooledByteBufAllocator parent, SizeClasses sizeClass) {
        // 按照不同內存使用率範圍劃分 PoolChunkList
        q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, sizeClass.chunkSize);// [100 , 2147483647]
        q075 = new PoolChunkList<T>(this, q100, 75, 100, sizeClass.chunkSize);
        q050 = new PoolChunkList<T>(this, q075, 50, 100, sizeClass.chunkSize);
        q025 = new PoolChunkList<T>(this, q050, 25, 75, sizeClass.chunkSize);
        q000 = new PoolChunkList<T>(this, q025, 1, 50, sizeClass.chunkSize);
        qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, sizeClass.chunkSize);// [-2147483648 , 25]
        // 雙向鏈表組織 PoolChunkList
        // 其中比較特殊的是 q000 的前驅節點指向 NULL
        // qInit 的前驅節點指向它自己
        q100.prevList(q075);
        q075.prevList(q050);
        q050.prevList(q025);
        q025.prevList(q000);
        q000.prevList(null);
        qInit.prevList(qInit);
    }

qInit 的前驅節點指向它自己,也就是説 qInit 中的 PoolChunk 內存利用率低於 0% 時,仍然會繼續留在 qInit 中,不會被釋放。這裏設計 qInit 的目的就是始終讓內存池至少有一個 PoolChunk,避免不必要的重複創建 PoolChunk。

    // minUsage 為 PoolChunkList 中內存使用率的下限,單位是百分比
    // maxUsage 為 PoolChunkList 中內存使用率的上限,單位是百分比
    PoolChunkList(PoolArena<T> arena, PoolChunkList<T> nextList, int minUsage, int maxUsage, int chunkSize) {
        // 所屬 PoolArena
        this.arena = arena;
        // 下一個 PoolChunkList
        this.nextList = nextList;
        // 該 PoolChunkList 中的 PoolChunk 內存佔用率在 [minUsage , maxUsage)
        // 當 PoolChunk 中的內存佔用率低於 minUsage 則將它移動到前一個 PoolChunkList 中 (prevList)
        // 當 PoolChunk 中的內存佔用率達到 maxUsage 則將它移動到後一個 PoolChunkList 中 (nextList)
        this.minUsage = minUsage;
        this.maxUsage = maxUsage;
        // 計算該 PoolChunkList 中的 PoolChunk 可以分配出去的最大內存容量 ,單位為 byte
        // chunkSize * (100L - minUsage) / 100L)
        maxCapacity = calculateMaxCapacity(minUsage, chunkSize);
        // 將內存使用率的上限和下限轉換成對應的閾值
        // PoolChunk 停留在 PoolChunkList 中的剩餘內存最低閾值,達到該閾值則向後移動到 nextList
        freeMinThreshold = (maxUsage == 100) ? 0 : (int) (chunkSize * (100.0 - maxUsage + 0.99999999) / 100L);
        // PoolChunk 停留在 PoolChunkList 中的剩餘內存最高閾值,高於該閾值則向前移動到 prevList
        freeMaxThreshold = (minUsage == 100) ? 0 : (int) (chunkSize * (100.0 - minUsage + 0.99999999) / 100L);
    }

這裏我們需要將 PoolChunkList 的內存使用率上下限轉換為對應的具體閾值,比如,maxUsage 表示的是 PoolChunkList 的內存使用率上限,PoolChunk 的內存使用率達到上限之後,就會被向後移動到下一個 PoolChunkList 。

但在具體的程序實現上,我們不可能直接用百分比來做這個判斷,所以需要將 maxUsage 轉換為 PoolChunk 停留在 PoolChunkList 中的剩餘內存最低閾值 freeMinThreshold,當 PoolChunk 的剩餘內存達到 freeMinThreshold 時,就會被向後移動到下一個 PoolChunkList。

同樣的道理,這裏也需要將 minUsage 轉換為 PoolChunk 停留在 PoolChunkList 中的剩餘內存最高閾值 freeMaxThreshold,當 PoolChunk 的剩餘內存高於 freeMaxThreshold 時,就會被向前移動到上一個 PoolChunkList。

5.1 PoolChunkList 的內存分配流程

當我們向內存池申請 Normal 規格的內存塊或者 PoolSubpage 的時候,Netty 首先會從 q050 中選擇一個 PoolChunk 來分配內存,如果 q050 是空的,或者我們申請的內存尺寸太大,q050 中的 PoolChunk 無法滿足,則繼續按照 q025 > q000 > qInit > q075 這樣的順序來選擇 PoolChunkList 分配內存。這麼設計的目的,筆者在第一小節中已經介紹過了,核心就是為了讓每個 PoolChunk 的服務週期更長一些。

如果這 6 個 PoolChunkList 全都無法滿足本次內存的申請,那麼 Netty 就會重新向 OS 申請一個 PoolChunk 分配內存,最後將這個新的 PoolChunk 加入到 qInit 中。

abstract class PoolArena<T> {
    private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache threadCache) {
        if (q050.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q025.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q000.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            qInit.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
            q075.allocate(buf, reqCapacity, sizeIdx, threadCache)) {
            return;
        }

        // Add a new chunk.
        PoolChunk<T> c = newChunk(sizeClass.pageSize, sizeClass.nPSizes, sizeClass.pageShifts, sizeClass.chunkSize);
        boolean success = c.allocate(buf, reqCapacity, sizeIdx, threadCache);
        qInit.add(c);
    }
}

在進入 PoolChunkList 分配內存之前,我們首先需要判斷該 PoolChunkList 中的 PoolChunk 能夠提供的最大內存容量 maxCapacity —— chunkSize * (100L - minUsage) / 100L) 是否能夠滿足本次內存的申請。

如果連 maxCapacity 都無法滿足,那麼就按照 PoolChunkList 的分配順序到下一個 PoolChunkList 去申請。

final class PoolChunkList<T> {
    boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache threadCache) {
        int normCapacity = arena.sizeClass.sizeIdx2size(sizeIdx);
        if (normCapacity > maxCapacity) {
            // 申請的內存尺寸太大,本 PoolChunkList 無法滿足
            return false;
        }
        // 挨個遍歷 PoolChunkList 中的 PoolChunk,直到內存分配成功
        for (PoolChunk<T> cur = head; cur != null; cur = cur.next) {
            if (cur.allocate(buf, reqCapacity, sizeIdx, threadCache)) {
                // PoolChunk 的剩餘內存達到最小閾值,則向後移動
                if (cur.freeBytes <= freeMinThreshold) {
                    remove(cur);
                    nextList.add(cur);
                }
                return true;
            }
        }
        // 內存分配失敗
        return false;
    }
}

如果 maxCapacity 可以滿足,則從 PoolChunkList 的頭結點開始挨個遍歷,直到找到一個 PoolChunk 能夠完成本次的內存分配任務。當內存分配成功之後,PoolChunk 中的內存使用率會近一步上升,如果剩餘內存容量達到了最小閾值 —— freeMinThreshold,也就是説 PoolChunk 中的內存使用率達到了上限,那麼就會將該 PoolChunk 從當前的 PoolChunkList 中移除。

    private void remove(PoolChunk<T> cur) {
        if (cur == head) {
            head = cur.next;
            if (head != null) {
                head.prev = null;
            }
        } else {
            PoolChunk<T> next = cur.next;
            cur.prev.next = next;
            if (next != null) {
                next.prev = cur.prev;
            }
        }
    }

然後將 PoolChunk 向後移動到下一個 PoolChunkList 中,但 nextList 也有自己的內存使用率範圍限制,所以還需要再次判斷 PoolChunk 的內存使用率是否達到了 nextList 的上限,如果達到,則繼續向後移動,直到移動到 q100 為止。

    void add(PoolChunk<T> chunk) {
        if (chunk.freeBytes <= freeMinThreshold) {
            nextList.add(chunk);
            return;
        }
        add0(chunk);
    }

如果 PoolChunk 的內存使用率在 nextList 的限定範圍內,那麼就將 PoolChunk 加入到 nextList 中(頭插法)。

    void add0(PoolChunk<T> chunk) {
        chunk.parent = this;
        if (head == null) {
            head = chunk;
            chunk.prev = null;
            chunk.next = null;
        } else {
            chunk.prev = null;
            chunk.next = head;
            head.prev = chunk;
            head = chunk;
        }
    }

5.2 PoolChunkList 的內存回收流程

當一個 Run 被釋放回 PoolChunk 的時候,那麼隨着內存釋放的不斷進行,這個 PoolChunk 中的內存使用率會不斷的降低,當內存使用率低於其所在 PoolChunkList 的下限時,也就是説 PoolChunk 中的剩餘內存容量高於了最大閾值 —— freeMaxThreshold,那麼這個 PoolChunk 就需要向前移動到上一個 prevList 中。

final class PoolChunkList<T> {
    boolean free(PoolChunk<T> chunk, long handle, int normCapacity, ByteBuffer nioBuffer) {
        // 內存塊 handle 釋放回 PoolChunk
        chunk.free(handle, normCapacity, nioBuffer);
        // PoolChunk 中的內存使用率低於該 PoolChunkList 的下限
        if (chunk.freeBytes > freeMaxThreshold) {
            remove(chunk);
            // 將 PoolChunk 移動到前一個 PoolChunkList 中
            return move0(chunk);
        }
        return true;
    }
}

但如果該 PoolChunk 原本所在的 PoolChunkList 是 q000 ,當 PoolChunk 的內存使用率低於 1% 之後,那麼這個 PoolChunk 將不會繼續向前移動,而是直接被 Netty 釋放回 OS 中。

    private boolean move0(PoolChunk<T> chunk) {
        // 該 PoolChunkList 是 q000 的情況
        if (prevList == null) {
            // 返回 false, 後續 Netty 會將該  PoolChunk 釋放回 OS
            return false;
        }
        // 其他情況下,PoolChunk 則向前移動
        return prevList.move(chunk);
    }

這裏還有一種特殊情況是,如果該 PoolChunk 原本所在的 PoolChunkList 是 qInit,那麼即使這個 PoolChunk 的內存使用率低於 0% 了,Netty 仍然會讓它繼續停留在 qInit 中,但會將這個 PoolChunk 重新調整到 qInit 中的頭結點處。

剩下的情況, PoolChunk 將會向前移動,但 prevList 也有自己的內存使用率範圍限制,如果這個 PoolChunk 的內存使用率仍然低於 prevList 的下限,那麼將會繼續向前移動,直到移動到 q000 中。

    private boolean move(PoolChunk<T> chunk) {  
        if (chunk.freeBytes > freeMaxThreshold) {
            // PoolChunk 的內存使用率仍然低於 prevList 的下限,繼續向前移動
            return move0(chunk);
        }
        // PoolChunk fits into this PoolChunkList, adding it here.(頭插法)
        add0(chunk);
        return true;
    }

6. PoolSubpage 的設計與實現

經過前面第一小節的介紹,我們多多少少已經對 PoolSubpage 的設計有了一定的瞭解,PoolSubpage 在內存池中主要負責分配 Small 規格的內存塊,其本質其實還是一個 Run ,內部包含了一個或者多個 Page 。

其核心設計思想是首先向 PoolChunk 申請到一個 Run , 然後按照 Small 規格將這個 Run 劃分成多個大小相等的小內存塊,每次申請時從 PoolSubpage 獲取一個小內存塊,每次釋放時,將小內存塊釋放回對應的 PoolSubpage 中。

image

每一種 Small 內存規格在內存池中都會對應一個 PoolSubpage 的雙向循環鏈表,鏈表中的 PoolSubpage 組織管理的全部都是對應 Small 規格的小內存塊。

final class PoolSubpage<T> {
    PoolSubpage<T> prev;
    PoolSubpage<T> next;
}

image

Netty 一共設計了 39 種 Small 規格尺寸 —— [16B , 28k] , 所以內存池中也就對應了 39 個 PoolSubpage 的雙向循環鏈表,每個鏈表負責管理對應 Small 規格的內存塊,這些鏈表被 PoolArena 組織在 smallSubpagePools 數組中, 數組的下標就是對應的 Small 規格在 SizeClasses 內存規格表中的 index 。

abstract class PoolArena<T> {
  // 管理 Small 規格內存塊的核心數據結構
  final PoolSubpage<T>[] smallSubpagePools;
}

image

當內存池剛剛被創建出來的時候,smallSubpagePools 數組中的鏈表都還是空的,只包含一個頭結點,沒有任何的 PoolSubpage。

image

所以當我們第一次向內存池申請 Small 規格內存塊的時候,首先需要到 PoolChunk 中申請一個 PoolSubpage 出來,那麼我們究竟該申請多大的 PoolSubpage 呢 ?

Netty 會取 Small 規格尺寸與 PageSize 的最小公倍數來作為 PoolSubpage 的尺寸。比如,我們申請一個 16 字節的內存塊,那麼對應的 PoolSubpage 大小就是 1 個 Page,裏面可以容納 512 個 16B 的小內存塊 Element。

    // Small 規格對應的 sizeIdx(規格 sizeIndex)
    private int calculateRunSize(int sizeIdx) {
        // 一個 page 最大可以容納多少個內存塊(Element)
        // pageSize / 16(最小內存塊尺寸)
        int maxElements = 1 << pageShifts - SizeClasses.LOG2_QUANTUM;
        int runSize = 0;
        int nElements;
        // sizeIdx 對應的內存規格,PoolSubpage 將會按照 elemSize 進行切分
        final int elemSize = arena.sizeClass.sizeIdx2size(sizeIdx);
        // 查找 pageSize 與 elemSize 的最小公倍數
        do {
            runSize += pageSize;
            nElements = runSize / elemSize;
        } while (nElements < maxElements && runSize != nElements * elemSize);
        // PoolSubpage 切分出的內存塊個數不能超過 maxElements(512)
        while (nElements > maxElements) {
            // runSize 太大了,縮減到 nElements <= maxElements
            runSize -= pageSize;
            nElements = runSize / elemSize;
        }
        // PoolSubpage 的最終尺寸
        return runSize;
    }

隨後 Netty 會向 PoolChunk 申請一個 runSize 大小的 Run,然後封裝成 PoolSubpage,並從 PoolSubpage 中分配一個小內存塊出來。

final class PoolChunk {

    private long allocateSubpage(int sizeIdx, PoolSubpage<T> head) {
        // 計算 PoolSubpage 的尺寸,取對應的 Small 規格與 PageSize 的最小公倍數
        int runSize = calculateRunSize(sizeIdx);
        // 從 PoolChunk 申請一個 runSize 大小的 PoolSubpage
        long runHandle = allocateRun(runSize);
        if (runHandle < 0) {
            return -1;
        }

        int runOffset = runOffset(runHandle);
        // 對應的 Small 內存規格
        int elemSize = arena.sizeClass.sizeIdx2size(sizeIdx);
        // 根據 runHandle 創建 PoolSubpage
        PoolSubpage<T> subpage = new PoolSubpage<T>(head, this, pageShifts, runOffset,
                runSize(pageShifts, runHandle), elemSize);
        // 將 PoolSubpage 保存在 PoolChunk 的 subpages 數組中
        subpages[runOffset] = subpage;
        // 從 PoolSubpage 分配一個小內存塊出去
        return subpage.allocate();
    }
}

image

final class PoolSubpage {
    // head 表示該 PoolSubpage 在 smallSubpagePools 對應規格鏈表的頭結點
    // 每個 PoolSubpage 鏈表都對應一個頭結點
    // elemSize 表示需要被管理的 Small 規格尺寸
    PoolSubpage(PoolSubpage<T> head, PoolChunk<T> chunk, int pageShifts, int runOffset, int runSize, int elemSize) {
        // 該 PoolSubpage 管理的 elemSize 對應的 sizeIndex
        // 比如 16B 對應的 sizeIndex 是 0
        this.headIndex = head.headIndex;
        // PoolSubpage 所屬的 PoolChunk
        this.chunk = chunk;
        // 13
        this.pageShifts = pageShifts;
        // PoolSubpage 在 PoolChunk 中的起始偏移( page 粒度)
        this.runOffset = runOffset;
        // PoolSubpage 大小(字節單位)
        this.runSize = runSize;
        // PoolSubpage 管理的 Small 規格內存塊大小
        this.elemSize = elemSize;
        doNotDestroy = true;
        // PoolSubpage 管理的內存塊個數
        maxNumElems = numAvail = runSize / elemSize;
        // 創建一個 bitmap ,用於管理 PoolSubpage 中所有小內存塊的狀態
        // 以及索引這些小內存塊在 PoolSubpage 中的位置
        int bitmapLength = maxNumElems >>> 6;
        if ((maxNumElems & 63) != 0) {
            bitmapLength ++;
        }
        this.bitmapLength = bitmapLength;
        bitmap = new long[bitmapLength];
        // 下一個可用內存塊對應的 bitmapIndex,初始為 0
        nextAvail = 0;
        // 將該 Subpage 採用頭插法插入到對應規格的 smallSubpagePools 中
        addToPool(head);
    }
}

PoolSubpage 最為核心的一個數據結構就是這個 bitmap , PoolSubpage 的主要職責就是負責組織管理眾多 Small 規格的小內存塊,而每一個小內存塊的分配狀態及其在 PoolSubpage 的位置偏移,就要靠這裏的 bitmap 來標識。

final class PoolSubpage {
    private final long[] bitmap;
    private final int bitmapLength;
}

我們看到 PoolSubpage 中的這個 bitmap 是一個 long 型的數組,Netty 用 1 個 bit 來標識小內存塊的分配狀態:0 表示未分配, 1 表示已分配。也就是説 Netty 用 1 個 bit 來表示 PoolSubpage 中的一個小內存塊。

這樣一來 bitmap 數組中的一個元素(long)就可以表示 64 個小內存塊,那麼這個 bitmap 數組的長度應該設置多少呢 ?

一個 PoolSubpage 的大小為 runSize , 其中的小內存塊尺寸為 elemSize,我們可以通過 numAvail = runSize / elemSize 計算出 PoolSubpage 中一共可以管理 numAvail 個小內存塊。

bitmap 數組中的一個元素可以表示 64 個小內存塊,那麼整個 bitmap 數組的長度就是 bitmapLength = maxNumElems >>> 6。這樣一來,PoolSubpage 中的每一個小內存塊都會對應一個唯一的 bitmapIndex 。

image

當我們從 PoolSubpage 中分配一個小內存塊出去的時候,這個小內存塊對應的 handle 結構低 32 位存儲的就是內存塊的 bitmapIndex。

image

runOffset 表示的就是這個小內存塊所在的 PoolSubpage 在 PoolChunk 中的偏移,size 表示的是這個 PoolSubpage 包含的 Page 個數,bitmapIndex 表示它是 PoolSubpage 中第幾個內存塊。

image

而 PoolChunk 的起始內存地址我們是知道的,就是前面提到的 memory , PoolSubpage 在 PoolChunk 中的 runOffset 也有了,那麼 PoolSubpage 的起始內存地址我們也就知道了 ——memory + (runOffset << pageShifts)

Small 規格的內存塊在 PoolSubpage 中的 bitmapIndex 也有,那麼這個小內存塊的起始內存地址也就知道了 —— memory + (runOffset << pageShifts) + bitmapIdx * elemSize

現在 Small 規格內存塊的起始內存地址有了,大小 elemSize 也有了,那麼對應的 PooledByteBuf 相關的 index 就可以設置了,隨後將這個 PooledByteBuf 返回給用户就可以直接使用了。

6.1 PoolSubpage 的內存分配流程

當一個新的 PoolSubpage 創建出來之後,它就會被加入到 smallSubpagePools 對應規格的 PoolSubpage 鏈表中(頭插法)。

final class PoolSubpage {
    private void addToPool(PoolSubpage<T> head) {
        prev = head;
        next = head.next;
        next.prev = this;
        head.next = this;
    }
}

比如 16B 在內存規格表中的 index 是 0 ,那麼其對應的 PoolSubpage 在剛被創建出來之後,就會插入到 smallSubpagePools[0] 中,如下圖所示:

image

當我們向內存池申請 Small 規格的內存塊時,內存池走的是 smallSubpagePools 來分配。首先我們需要到內存規格表中獲取對應 Small 規格的 sizeIndex , 然後到 smallSubpagePools[sizeIndex] 中獲取鏈表中第一個 PoolSubpage 來分配小內存塊。

比如我們要向內存池申請一個 16B 大小的內存塊,而 16B 在內存規格表中的 sizeIndex 是 0 ,那麼我們就到 smallSubpagePools[0] 去獲取對應的 PoolSubpage,然後從這個 PoolSubpage 中獲取一個 16B 大小的內存塊。

final class PoolChunk {

    boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
        // 待分配內存塊
        final long handle;
        // 分配 Small 規格的內存塊走 smallSubpagePools
        if (sizeIdx <= arena.sizeClass.smallMaxSizeIdx) {
            final PoolSubpage<T> nextSub;
            // 獲取對應規格 PoolSubpage 鏈表的頭結點
            PoolSubpage<T> head = arena.smallSubpagePools[sizeIdx];
            head.lock();
            try {
                nextSub = head.next;
                // 如果鏈表為空,那麼 head.next 指向的是自己
                if (nextSub != head) {
                    // 始終從鏈表中第一個 PoolSubpage 開始分配
                    handle = nextSub.allocate();
                    // 將對應 Small 規格的內存塊封裝成 PooledByteBuffer 返回
                    nextSub.chunk.initBufWithSubpage(buf, null, handle, reqCapacity, cache);
                    return true;
                }
                // 鏈表為空,則到 PoolChunk 中重新申請一個 PoolSubpage
                // 然後加入到 smallSubpagePools 對應規格的鏈表中
                handle = allocateSubpage(sizeIdx, head);
                if (handle < 0) {
                    return false;
                }
                assert isSubpage(handle);
            } finally {
                head.unlock();
            }
        } else {
           ...... 分配 Normal 規格內存塊走 PoolChunk  ......
        }
        // 獲取 PoolChunk 的 memory.duplicate() 視圖緩存
        ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;
        // nioBuffer 為 null 的話,後續訪問 PooledByteBuffer 的時候通過 memory.duplicate() 創建
        // 將 handle 封裝成 PooledByteBuffer 返回
        initBuf(buf, nioBuffer, handle, reqCapacity, cache);
        return true;
    }
}

那麼如何從 PoolSubpage 中獲取內存塊呢 ?首先 PoolSubpage 的本質其實就是 PoolChunk 中的一個 Run ,裏邊包含了一個或者若干個 Page ,然後 Netty 會把這個 Run 按照對應的 Small 規格尺寸切分成多個大小相同的內存塊組織在這個 PoolSubpage 中。

image

PoolSubpage 中每一個小內存塊都會對應一個 bitmapIndex,用於標識其是 PoolSubpage 的第幾個內存塊。PoolSubpage 在 nextAvail 字段中緩存了下一個可供分配內存塊的 bitmapIndex 。

在 PoolSubpage 的初始狀態下,nextAvail 為 0 ,也就是説首先從 PoolSubpage 中的第一個內存塊開始分配。除此之外,Netty 為了保證局部性,總是偏向於分配最近剛剛被釋放的內存塊,如果一個內存塊剛被釋放回 PoolSubpage ,那麼 nextAvail 就會被設置成這個剛剛被釋放內存塊的 bitmapIndex 。下次分配的時候直接從 nextAvail 中獲取即可。

final class PoolSubpage {

    private int nextAvail;

    private int getNextAvail() {
        // 初始為 0
        int nextAvail = this.nextAvail;
        if (nextAvail >= 0) {
            this.nextAvail = -1;
            return nextAvail;
        }
        // nextAvail 為 -1 ,表示此時沒有剛被釋放的內存塊
        // 那麼就需要遍歷 bitmap , 找到第一個為 0 的內存塊 bitmapIndex
        return findNextAvail();
    }
}

nextAvail 緩存 bitmapIndex 的情況一個是初始狀態下,另一個是有內存塊剛剛被釋放,剩下的情況 nextAvail 的值為 -1,這時我們就需要遍歷 PoolSubpage 的 bitmap ,找到第一個為 0 的 bitmapIndex。

bitmapIndex 在 bitmap 中對應的 bit 位設置為 0 表示該內存塊未分配,設置為 1 表示該內存塊已經被分配出去了。

那麼如何高效的遍歷 bitmap 查找第一個為 0 的 bitmapIndex ? 我們知道 PoolSubpage 中的 bitmap 是一個 long 型數組結構,bitmap 中的每一個元素都是一個 long 型的整數,表示 64 個內存塊,每個內存塊用一個 bit 位來標識它的分配狀態。

如果 bitmap 某一個元素,它的 64 位 bit 全為 1 ,對其取反,那麼這個 long 型整數就是 0 ,表示對應的 64 個內存塊已經全部被分配出去了。我們從 bitmap[0] 開始遍歷,挨個對其取反,如果為 0 ,則表示 bitmap[0] 中的 64 個內存塊已經全部分配了,我們就不用看了,繼續對 bitmap[1] , bitmap[2] , ..... , bitmap[bitmapLength - 1] 執行取反操作,直到找到第一個取反後,值不為 0 的 bitmap 元素。那麼就説明這個元素中一定至少有一個 bit 為 0 ,也就是説至少有一個內存塊未被分配。

假設我們現在從下圖所示的一個 PoolSubpage 中查找空閒內存塊,該 PoolSubpage 只有一個空閒內存塊,它的 bitmapIndex 為 67。

image

首先我們對 bitmap[0] 進行取反,由於 bitmap[0] 的 64 位 bit 全為 1 ,所以取反之後值為 0 ,我們就知道了,bitmap[0] 所表示的這 64 個內存塊已經全部分配出去了。

於是我們繼續對 bitmap[1] 進行取反,由於該 long 型整數的第 4 位 bit 為 0 ,所以對其取反之後,值肯定不是 0 。

image

這樣我們就知道了,bitmap[1] 中肯定至少有一個 bit 為 0 , 也就是至少還有一個內存塊未被分配。

    private int findNextAvail() {
        // 遍歷 bitmap , 在 bitmap 中查找第一個還未全部分配出去的內存塊 bitmapIndex
        for (int i = 0; i < bitmapLength; i ++) {
            long bits = bitmap[i];
            // ~bits = 0 表示這個 long 表示的 64 個內存塊已經全部分配出去了
            // ~bits != 0 表示這個 long 中還有未被分配出去的內存塊
            if (~bits != 0) {
                // 找出 bits 中具體哪一位為 0 ,也就是説具體哪一個內存塊未被分配
                return findNextAvail0(i, bits);
            }
        }
        // subPage 中無內存塊可用
        return -1;
    }

那麼 bitmap[1] 中具體是哪一個 bit 為 0 呢 ?很簡單,我們直接從 bitmap[1] 的最低位開始暴力遍歷,挨個查看每一個 bit 是否為 0 —— (bit & 1) == 0,如上圖所示,經過一通遍歷之後,我們知道了原來是第 3 個 bit 為 0 (從 0 開始遍歷計數)。1 * 64 + 3 就是這個內存塊的 bitmapIndex (67) 。

    private int findNextAvail0(int i, long bits) {
        // i 表示 bitmap 數組的 index,也就是第幾個 long,注意 i 並不是 bitmapIndex
        // i * 64
        final int baseVal = i << 6;
        // 從 bitmap[i] 中第一個 bit 開始遍歷查找
        for (int j = 0; j < 64; j ++) {
            // 檢查 bits 的第一個 bit 是否為 0 
            if ((bits & 1) == 0) {
                // j 表示現在檢查到 bits 中的第幾個 bit
                // baseVal + j 表示該內存塊的 bitmapIndex
                int val = baseVal | j;
            }
            // 最低位 bit 檢查完之後,右移一位,開始檢查下一個 bit 是否為 0
            bits >>>= 1;
        }
        return -1;
    }

當我們找到了 PoolSubpage 第一個空閒內存塊的 bitmapIndex 之後,就將其對應在 bitmap 中的 bit 位設置為 1 ,表示已被分配。當一個 PoolSubpage 中所有的內存塊全部被分配出去之後,這個 PoolSubpage 就需要從 smallSubpagePools 中移除

final class PoolSubpage {

    long allocate() {
        if (numAvail == 0 || !doNotDestroy) {
            return -1;
        }
        // 獲取 subPage 下一個可用內存塊的 bitmapIdx
        final int bitmapIdx = getNextAvail();
        // -1 表示 subPage 中所有內存塊都已經被分配出去了(沒有空閒內存塊)
        if (bitmapIdx < 0) {
            // subPage 全部分配完之後,就從 smallSubpagePools 中刪除
            removeFromPool();
        }
        // 第幾個 long
        int q = bitmapIdx >>> 6;
        // long 中的第幾個 bit
        int r = bitmapIdx & 63;
        // 內存塊必須是空閒的
        assert (bitmap[q] >>> r & 1) == 0;
        // 設置內存塊在 bitmap 中對應的 bit 位為 1 (已分配)
        bitmap[q] |= 1L << r;

        if (-- numAvail == 0) {
            // subPage 全部分配完之後,就從 smallSubpagePools 中刪除
            removeFromPool();
        }
        // 組裝成 handle 結構返回
        return toHandle(bitmapIdx);
    }
}

6.2 PoolSubpage 的內存回收流程

當一個內存塊要被釋放回內存池的時候,我們需要判斷這個內存塊到底是 Small 規格的呢還是 Normal 規格的,如果是 Normal 規格的則直接釋放回 PoolChunk,如果是 Small 規格的則是釋放回 smallSubpagePools 中。

那我們如何判斷一個內存塊是 Small 規格還是 Normal 規格的呢 ?這就用到了之前我們介紹的 handle 結構,內存池用它來描述所有內存塊。

image

Small 規格內存塊的 handle 結構有一個特點,就是它的 runOffset 是其所在 PoolSubpage 在 PoolChunk 中的 Page 偏移,size 為該 PoolSubpage 包含的 Page 個數,isUsed 用於表示該內存塊是否被分配,被釋放內存塊的 isUsed 這裏肯定是 1 。

和 Normal 規格最大的不同是,Small 規格內存塊的 isSubpage 在被分配出去的時候會被設置為 1 ,表示其受到 PoolSubpage 的管理,bitmapIdx 會被設置為其在 PoolSubpage 中的 bitmapIndex。而 Normal 規格這兩項全部是 0 。

    private long toHandle(int bitmapIdx) {
        // PoolSubpage 中包含的 page 個數
        int pages = runSize >> pageShifts;
        // 低 32 位保存 bitmapIdx
        return (long) runOffset << RUN_OFFSET_SHIFT
               | (long) pages << SIZE_SHIFT
               | 1L << IS_USED_SHIFT
               | 1L << IS_SUBPAGE_SHIFT
               | bitmapIdx;
    }

我們可以通過 isSubpage 方法來判斷一個內存塊的 Handle 結構對應的 isSubpage 位是否為 1 。如果為 1 ,那麼這個內存塊就是 Small 規格的,否則就是 Normal 規格的。

    static boolean isSubpage(long handle) {
        return (handle >> IS_SUBPAGE_SHIFT & 1) == 1L;
    }

如果是 Small 規格的內存塊,那麼 Netty 就要把它釋放回其所在的 PoolSubpage 中,現在的問題是我們如何通過一個內存塊來查找它的 PoolSubpage 呢 ?也就是説內存塊與 PoolSubpage 的映射關係在哪裏 ?

這就用到了我們前面介紹 PoolChunk 時提到的 subpages 數組,subpages 數組中存放的全部是由該 PoolChunk 分配出去的所有 PoolSubpage 。

final class PoolChunk {
    /**
     * manage all subpages in this chunk
     */
    private final PoolSubpage<T>[] subpages;
}

subpages 數組的索引就是每個 PoolSubpage 的 runOffset ,那麼這個 runOffset 保存在哪裏呢 ?其實就在 Small 規格內存塊的 handle 結構中,我們可以通過 runOffset 方法來提取。

    static int runOffset(long handle) {
        return (int) (handle >> RUN_OFFSET_SHIFT);
    }

有了這個 runOffset ,我們就可以從 subpages[runOffset] 中將內存塊對應的 PoolSubpage 獲取到,剩下的事情就很簡單了,直接將這個內存塊釋放回 PoolSubpage 就可以了。

subpage.free(head, bitmapIdx(handle))

隨着內存塊的釋放,有可能會導致 PoolSubpage 變為一個 Empty PoolSubpage,也就是説 PoolSubpage 中的內存塊全部空閒。對於一個 Empty PoolSubpage , Netty 會將其從 smallSubpagePools 中移除,並將 PoolSubpage 背後的內存釋放回 PoolChunk。

final class PoolChunk {
    void free(long handle, int normCapacity, ByteBuffer nioBuffer) {
        // Small 規格內存塊的釋放
        if (isSubpage(handle)) {
            // 獲取內存塊所在 PoolSubpage 的 runOffset
            int sIdx = runOffset(handle);
            PoolSubpage<T> subpage = subpages[sIdx];
            // 獲取 PoolSubpage 所在 smallSubpagePools 對應規格鏈表頭結點
            PoolSubpage<T> head = subpage.chunk.arena.smallSubpagePools[subpage.headIndex];

            head.lock();
            try {
                assert subpage.doNotDestroy;
                // 將內存塊釋放回 PoolSubpage 中
                // true 表示 PoolSubpage 還是一個 Partial PoolSubpage(部分空閒) , 繼續留在 smallSubpagePools 中
                // false 表示 PoolSubpage 變成了一個 Empty PoolSubpage(全部空閒),從 smallSubpagePools 鏈表中移除
                if (subpage.free(head, bitmapIdx(handle))) {
                    // the subpage is still used, do not free it
                    return;
                }
                // Empty PoolSubpage 從 PoolChunk subpages 數組中移除
                subpages[sIdx] = null;
            } finally {
                head.unlock();
            }
        }

        ........ 釋放 Normal 規格內存塊或者 Empty PoolSubpage ......

        } finally {
            runsAvailLock.unlock();
        }
    }
}

內存塊釋放回 PoolSubpage 的邏輯也是非常簡單,只需要將其 bitmapIdx 在 bitmap 中對應的 bit 位重新設置為 0 就可以了,正好和內存塊的申請互為相反的操作。

那麼如何通過 bitmapIdx 定位到 bitmap 中與其對應具體的 bit 呢 ? 我們還是以上個小節的例子進行説明,假設現在我們將 bitmapIdx 為 67 的內存塊釋放回 PoolSubpage 。

image

首先我們需要知道的是,bitmapIdx 具體是落在哪一個 bitmap 數組元素中,我們可以通過 bitmapIdx / 64 來獲取,對應到上圖中,bitmapIdx(67)是落在 bitmap[1] 中。

 int q = bitmapIdx >>> 6;

接下來我們就需要知道,這個 bitmapIdx 具體是 bitmap[q] 的第幾個 bit ,我們可以通過 bitmapIdx & 63 來獲取,對應到上圖中,bitmapIdx(67)是 bitmap[1] 的第 3 個 bit (從 0 開始計數)。

int r = bitmapIdx & 63;  

具體的 bit 定位到了,剩下的事情就很簡單了,我們只需要將 bitmapIdx 對應的 bit 重新設置為 0 就可以了。

bitmap[q] ^= 1L << r;

而 Netty 往往更加傾向於分配剛剛被釋放的內存塊,從上一小節 PoolSubpage 的分配過程可以看出,Netty 會優先選擇緩存在 nextAvail 字段上的 bitmapIdx,所以當一個內存塊被釋放之後,需要將它的 bitmapIdx 緩存在 nextAvail 字段中。

final class PoolSubpage {
    private void setNextAvail(int bitmapIdx) {
        nextAvail = bitmapIdx;
    }
}

PoolSubpage 的內存分配和釋放,都會伴隨着 smallSubpagePools 的調整,隨着內存分配的不斷進行,PoolSubpage 中的內存塊會慢慢地全部分配出去,也就是説當一個 PoolSubpage 變為 Full PoolSubpage 的時候,那麼就需要將 Full PoolSubpage 從 smallSubpagePools 中移除。

那麼當這個 Full PoolSubpage 中的內存塊被釋放回來之後,這個被移除的 Full PoolSubpage 就會變為 Partial PoolSubpage(部分空閒),那麼我們就需要將這個 PoolSubpage 重新添加回 smallSubpagePools 。

隨着內存釋放的不斷進行,Partial PoolSubpage 中的內存塊會慢慢的全部釋放回來,也就是説當一個 PoolSubpage 變為 Empty PoolSubpage (全部空閒)的時候,Netty 就需要將這個 Empty PoolSubpage 從 smallSubpagePools 中刪除,並將 Empty PoolSubpage 釋放回 PoolChunk 。

但如果這個 Empty PoolSubpage 是 smallSubpagePools 對應規格 PoolSubpage 鏈表中的唯一元素,那麼就讓這個 Empty PoolSubpage 繼續停留在 smallSubpagePools 中,始終保證 smallSubpagePools 對應規格的 PoolSubpage 鏈表中至少有一個 PoolSubpage 。

這個調整 PoolSubpage 的過程和內核中的 slab 非常相似,感興趣的讀者朋友可以回看下筆者之前介紹 slab 的文章 —— 《深度解析 slab 內存池回收內存以及銷燬全流程》
final class PoolSubpage {

    boolean free(PoolSubpage<T> head, int bitmapIdx) {
        // bitmap 中第幾個元素
        int q = bitmapIdx >>> 6;
        // long 型整數的具體第幾個 bit
        int r = bitmapIdx & 63;        
        // 將內存塊在 bitmap 中對應的 bit 設置為 0
        bitmap[q] ^= 1L << r;
        // 設置 nextAvail,下一次申請的時候優先分配剛剛被釋放的內存塊
        setNextAvail(bitmapIdx);
        // Full PoolSubpage 恰好變為 Partial PoolSubpage
        if (numAvail ++ == 0) {
            // 將這個 PoolSubpage 重新添加到  smallSubpagePools 中
            addToPool(head);
            // 確保 PoolSubpage 現在是一個 Partial PoolSubpage
            // 如果 maxNumElems = 1,那麼這裏的  PoolSubpage 會立即變為一個 Empty PoolSubpage(全部空閒)
            if (maxNumElems > 1) {
                // 返回 true 表示 PoolSubpage 是一個 Partial PoolSubpage
                // 需要保留在 smallSubpagePools 中
                return true;
            }
        }
        // numAvail = maxNumElems 説明 PoolSubpage 此時變為一個 Empty PoolSubpage(全部空閒)
        if (numAvail != maxNumElems) {
            // Partial PoolSubpage 繼續停留在 smallSubpagePools
            return true;
        } else {
            // 對於一個 Empty PoolSubpage 來説,Netty 需要將其從 smallSubpagePools 中刪除,並釋放 PoolSubpage 回 PoolChunk
            // 如果該  PoolSubpage 是 smallSubpagePools 對應規格鏈表中的唯一元素,那麼就讓它繼續停留
            if (prev == next) {
                // 始終保證 smallSubpagePools 對應規格的 PoolSubpage 鏈表中至少有一個 PoolSubpage
                return true;
            }
            // 如果對應的 PoolSubpage 鏈表中還有多餘的 PoolSubpage
            // 那麼就將這個 Empty PoolSubpage 釋放掉
            doNotDestroy = false;
            // 將該 Empty PoolSubpage 從 smallSubpagePools 中刪除
            removeFromPool();
            return false;
        }
    }
}

7. PooledByteBuf 如何封裝內存塊

無論是從 PoolChunk 分配出來的 Normal 規格內存塊,還是從 PoolSubpage 分配出來的 Small 規格內存塊,內存池都會返回一個內存塊的 handle 結構。

image

而我們拿到這個 handle 結構是無法直接使用的,因為這個 handle 並不是真正的內存,他只是用來描述內存塊在 PoolChunk 中的位置信息,而真正的內存是 4M 的 PoolChunk。所以我們需要將內存塊的 handle 結構轉換成可以直接使用的 PooledByteBuf。

image

站在 PooledByteBuf 的內部視角來看,用户並不會關心 PooledByteBuf 底層的內存來自於哪裏,用户只會關心 PooledByteBuf 提供的是一段從位置 0 開始,大小為 length 的內存塊。在 PooledByteBuf 這個局部視角上,它的 readerIndex , writerIndex 初始均為 0 。

但我們站在整個內存池的全局視角上來看的話,PooledByteBuf 底層的內存其實是來自於 PoolChunk,筆者之前在 《聊一聊 Netty 數據搬運工 ByteBuf 體系的設計與實現》 中的第 2.7 小節中介紹過 ByteBuf 視圖的概念。我們可以將 PooledByteBuf 看做是 PoolChunk 的某一段局部 slice 視圖。

PooledByteBuf 的本質其實是 PoolChunk 中的某一段內存區域,對於 Normal 規格的內存塊來説,這段區域的起始內存地址是 memory + runOffset << pageShifts , 也就是説 PooledByteBuf 相對於 PoolChunk 的起始內存地址的 offset 偏移是 runOffset << pageShifts, 這一點是我們站在整個內存池的全局視角上觀察到的。

final class PoolChunk {

    void initBuf(PooledByteBuf<T> buf, ByteBuffer nioBuffer, long handle, int reqCapacity,
                 PoolThreadCache threadCache) {
        if (isSubpage(handle)) {
            // Small 規格的 handle 轉換為 PooledByteBuf
            initBufWithSubpage(buf, nioBuffer, handle, reqCapacity, threadCache);
        } else {
            // Normal 規格的 handle 轉換為 PooledByteBuf
            int maxLength = runSize(pageShifts, handle);
            // PooledByteBuf 中的 offset 偏移為 runOffset(handle) << pageShifts
            buf.init(this, nioBuffer, handle, runOffset(handle) << pageShifts,
                    reqCapacity, maxLength, arena.parent.threadCache());
        }
    }
}

但在 PooledByteBuf 的內部視角里,用户看到的起始內存地址偏移是 0 (相對於自己),初始狀態下 PooledByteBuf 的 readerIndex = writerIndex = 0。所以要想通過 PooledByteBuf 的相關 index 訪問到背後真正的內存(相對於 PoolChunk),我們就需要在每次獲取 index 的時候加上一個偏移 offset —— runOffset << pageShifts

    protected final int idx(int index) {
        return offset + index;
    }

    @Override
    protected byte _getByte(int index) {
        return memory.get(idx(index));
    }

    @Override
    protected void _setByte(int index, int value) {
        memory.put(idx(index), (byte) value);
    }

這個就是 PooledByteBuf 最核心的內容,剩下的就和普通的 ByteBuf 一模一樣了。而對於 Small 規格的內存塊來説, 其 handle 結構對應的 PooledByteBuf 相對於 PoolChunk 的起始內存地址的 offset 偏移則是 (runOffset << pageShifts) + bitmapIdx * elemSize

image

final class PoolChunk {
    void initBufWithSubpage(PooledByteBuf<T> buf, ByteBuffer nioBuffer, long handle, int reqCapacity,
                            PoolThreadCache threadCache) {
        int runOffset = runOffset(handle);
        int bitmapIdx = bitmapIdx(handle);
        PoolSubpage<T> s = subpages[runOffset];
        // PooledByteBuf 中的 offset 偏移
        int offset = (runOffset << pageShifts) + bitmapIdx * s.elemSize;
        buf.init(this, nioBuffer, handle, offset, reqCapacity, s.elemSize, threadCache);
    }
}

那麼 PooledByteBuf 中究竟封裝了哪些內存池的相關信息呢 ?我們來看下 PooledByteBuf 的初始化邏輯。

abstract class PooledByteBuf {
    private void init0(PoolChunk<T> chunk, ByteBuffer nioBuffer,
                       long handle, int offset, int length, int maxLength, PoolThreadCache cache) {

        // 該 PooledByteBuf 所屬的 PoolChunk
        this.chunk = chunk;
        // PoolChunk 底層依賴的 ByteBuffer (4M)
        memory = chunk.memory;
        // PoolChunk  memory  的 duplicate 視圖
        // 對於 PooledByteBuf 的 read ,write 操作最終都會落在 tmpNioBuf 上
        tmpNioBuf = nioBuffer;
        // PooledByteBuf 背後的內存池 —— PooledByteBufAllocator
        allocator = chunk.arena.parent;
        // 所屬的 PoolThreadCache (線程本地緩存)
        this.cache = cache;
        // PooledByteBuf 底層依賴的內存塊 handle 結構
        this.handle = handle;
        // PooledByteBuf 相對於 PoolChunk 起始內存地址的偏移,以字節為單位
        this.offset = offset;
        // 用户本來請求的內存尺寸
        this.length = length;
        // 內存池實際分配的內存尺寸
        this.maxLength = maxLength;
    }
}

PooledByteBuf 中的 tmpNioBuf,其實就是來自於 PoolChunk 中 cachedNioBuffers 裏緩存的 memory duplicate 視圖。

final class PoolChunk {
  private final Deque<ByteBuffer> cachedNioBuffers;
}

內存池分配一個內存塊出來之後,都會從 cachedNioBuffers 中取出一個 PoolChunk memory duplicate 視圖傳遞進 PooledByteBuf 中初始化。

final class PoolChunk {
    boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {

            ..... 省略分配 handle 的邏輯 ......

        ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;
        initBuf(buf, nioBuffer, handle, reqCapacity, cache);
        return true;
    }
}

後續對於 PooledByteBuf 的讀寫操作最終都會落到 tmpNioBuf 中進行。當我們使用完 PooledByteBuf 隨後調用 release() 方法準備釋放回內存池的時候,如果 PooledByteBuf 的引用計數為 0 ,那麼就會在 deallocate() 方法中,將其底層依賴的內存塊 handle 釋放回內存池中,同時 PooledByteBuf 這個 Java 實例也會被回收至對象池中。

abstract class PooledByteBuf {
    @Override
    protected final void deallocate() {
        if (handle >= 0) {
            final long handle = this.handle;
            this.handle = -1;
            memory = null;
            // 釋放 PooledByteBuf 背後的內存塊回內存池中
            chunk.arena.free(chunk, tmpNioBuf, handle, maxLength, cache);
            tmpNioBuf = null;
            chunk = null;
            cache = null;
            // 釋放 PooledByteBuf 這個 Java 實例回對象池中
            this.recyclerHandle.unguardedRecycle(this);
        }
    }
}

8. PoolThreadCache 的設計與實現

到目前為止,內存池的整個內部實現筆者就為大家剖析完了,現在讓我們把視角從內存池的內部重新轉移到整個架構層面上來俯瞰一下整個內存池的全貌。

image

筆者在本文第一小節介紹內存池的架構設計時提到過,Netty 為了降低多線程併發向內存池申請內存的競爭激烈程度,從而設計了多個 PoolArena,默認個數為 availableProcessors * 2,我們可以通過 -Dio.netty.allocator.numDirectArenas 參數進行調整。

當線程第一次向內存池申請內存的時候,都會採用 Round-Robin 的方式與一個固定的 PoolArena 進行綁定,後續在線程整個生命週期中的內存申請以及釋放等操作只會與這個綁定的 PoolArena 進行交互。

一個線程只能綁定到一個固定的 PoolArena 上,而一個 PoolArena 卻可以被多個線程綁定。

同時為了省去 cacheline 核間通信的這部分開銷,實現內存申請,釋放的無鎖化,最大化提升內存池的性能。Netty 為每個線程引入了本地緩存 —— PoolThreadCache 。

考慮到這部分本地緩存的內存佔用,默認情況下,Netty 只會為 Reactor 線程以及 FastThreadLocalThread 類型的線程提供 PoolThreadCache,而普通的用户線程要想啓用 PoolThreadCache ,則需要設置 -Dio.netty.allocator.useCacheForAllThreads 為 true 。

PoolThreadCache 提供了 Small 規格以及 Normal 規格的線程本地緩存,其核心屬性如下:

final class PoolThreadCache {
    // 與線程綁定的 PoolArena
    final PoolArena<ByteBuffer> directArena;
    // 本地緩存線程申請過的 Small 規格內存塊
    private final MemoryRegionCache<ByteBuffer>[] smallSubPageDirectCaches;
    // 本地緩存線程申請過的 Normal 規格內存塊
    private final MemoryRegionCache<ByteBuffer>[] normalDirectCaches;
}

每一種內存規格的本地緩存都對應一個 MemoryRegionCache 結構,所以 smallSubPageDirectCaches 以及 normalDirectCaches 都是一個 MemoryRegionCache 結構的數組。

比如 smallSubPageDirectCaches 是用來緩存 Small 規格的內存塊,而 Netty 一共定義了 39 種 Small 規格尺寸 —— [16B , 28K] ,Netty 會為每一種 Small 規格提供一個 MemoryRegionCache 緩存。smallSubPageDirectCaches 數組的 index 就是對應 Small 規格尺寸在內存規格表中的 sizeIndex ,數組大小為 39 。

    // numCaches 表示有多少種 Small 規格尺寸(39)
    // cacheSize 表示每一種 Small 規格尺寸可以緩存多少個內存塊(256)
    private static <T> MemoryRegionCache<T>[] createSubPageCaches(
            int cacheSize, int numCaches) {
        if (cacheSize > 0 && numCaches > 0) {
            // 為每一個 small 內存規格創建 MemoryRegionCache 本地緩存結構
            // cacheIndex 就是對應的 small 內存規格的 sizeIndex       
            MemoryRegionCache<T>[] cache = new MemoryRegionCache[numCaches];
            for (int i = 0; i < cache.length; i++) {
                // 初始化 smallSubPageDirectCaches 數組
                cache[i] = new SubPageMemoryRegionCache<T>(cacheSize);
            }
            return cache;
        } else {
            return null;
        }
    }

    private static final class SubPageMemoryRegionCache<T> extends MemoryRegionCache<T> {
        SubPageMemoryRegionCache(int size) {
            super(size, SizeClass.Small);
        }
    }

image

每個 MemoryRegionCache 結構中都有一個 MpscQueue,用於緩存對應尺寸的內存塊,Netty 根據不同的內存規格分類限定了 MpscQueue 的大小。

對於 Small 規格的線程本地緩存來説,每一種 Small 規格可以緩存的內存塊個數默認為 256 ,我們可以通過 -Dio.netty.allocator.smallCacheSize 進行調節。

對於 Normal 規格的線程本地緩存來説,每一種 Normal 規格可以緩存的內存塊個數默認為 64 ,我們可以通過 -Dio.netty.allocator.normalCacheSize 進行調節。

    private abstract static class MemoryRegionCache<T> {
        // queue 中緩存內存塊的個數上限
        private final int size;
        // 用於緩存線程本地內存塊的 MpscQueue
        private final Queue<Entry<T>> queue;
        // 內存塊是 Small 規格還是 Normal 規格 ?
        private final SizeClass sizeClass;
        // 從該緩存中分配內存塊的次數
        private int allocations;

        MemoryRegionCache(int size, SizeClass sizeClass) {
            // 表示每一個 MemoryRegionCache 中,queue 中可以緩存的內存塊個數
            // 每種 Small 規格尺寸默認可以緩存 256 個內存塊
            // 每種 Normal 規格尺寸默認可以緩存 64 個內存塊
            this.size = MathUtil.safeFindNextPositivePowerOfTwo(size);
            queue = PlatformDependent.newFixedMpscQueue(this.size);
            this.sizeClass = sizeClass;
        }
}

而對於 normalDirectCaches 來説,Netty 一共定義了 29 種 Normal 規格尺寸 —— [32K , 4M],但 Netty 並不會為每一種 Normal 規格提供本地緩存 MemoryRegionCache。其中的原因,一是 Netty 經常使用的是 Small 規格尺寸而不是 Normal 規格尺寸,二是 Normal 規格尺寸太大了,不可能為大尺寸並且不經常使用的內存塊提供緩存。

默認情況下 ,Netty 只會為 32K 這一個 Normal 規格提供本地緩存 MemoryRegionCache,不過我們可以通過 -Dio.netty.allocator.maxCachedBufferCapacity 參數進行調整(單位為字節,默認 32 * 1024),該參數表示 Netty 可以緩存的最大的 Normal 規格尺寸。maxCachedBufferCapacity 以下的 Normal 規格會緩存,超過 maxCachedBufferCapacity 的 Normal 規格則不會被緩存。

image

    // cacheSize 表示每一種 Normal 規格尺寸可以緩存多少個內存塊(64)
    // maxCachedBufferCapacity 表示可緩存的最大 Normal 規格尺寸(32K)
    // maxCachedBufferCapacity 以下的 Normal 規格會緩存,超過 maxCachedBufferCapacity 的 Normal 規格則不會被緩存
    // PoolArena 表示與線程綁定的內存池
    private static <T> MemoryRegionCache<T>[] createNormalCaches(
            int cacheSize, int maxCachedBufferCapacity, PoolArena<T> area) {

        if (cacheSize > 0 && maxCachedBufferCapacity > 0) {
            // 最大可緩存的 Normal 規格
            int max = Math.min(area.sizeClass.chunkSize, maxCachedBufferCapacity);
            // 創建 normalDirectCaches
            List<MemoryRegionCache<T>> cache = new ArrayList<MemoryRegionCache<T>>() ;
            for (int idx = area.sizeClass.nSubpages; idx < area.sizeClass.nSizes &&
                    area.sizeClass.sizeIdx2size(idx) <= max; idx++) {
                // 為 maxCachedBufferCapacity 以下的 Normal 規格創建本地緩存 MemoryRegionCache
                cache.add(new NormalMemoryRegionCache<T>(cacheSize));
            }
            // 返回 normalDirectCaches (轉換成數組)
            return cache.toArray(new MemoryRegionCache[0]);
        } else {
            return null;
        }
    }

    private static final class NormalMemoryRegionCache<T> extends MemoryRegionCache<T> {
        NormalMemoryRegionCache(int size) {
            super(size, SizeClass.Normal);
        }
    }

normalDirectCaches 數組的 index 就是對應 Normal 規格在內存規格表中的 sizeIndex - 39 , 因為第一個 Normal 規格(32K)的 sizeIndex 就是 39 。

8.1 線程如何與 PoolArena 進行綁定

當線程第一次向內存池申請內存的時候,Netty 會將線程與一個固定的 PoolArena 進行綁定,從此之後,在線程整個生命週期中的內存申請以及釋放等操作只會與這個綁定的 PoolArena 進行交互。

public class PooledByteBufAllocator {
    // 線程本地緩存
    private final PoolThreadLocalCache threadCache;

    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
        // 獲取線程本地緩存,線程第一次申請內存的時候會在這裏與 PoolArena 進行綁定
        PoolThreadCache cache = threadCache.get();
        // 獲取與當前線程綁定的 PoolArena
        PoolArena<ByteBuffer> directArena = cache.directArena;

        final ByteBuf buf;
        if (directArena != null) {
            // 從固定的 PoolArena 中申請內存
            buf = directArena.allocate(cache, initialCapacity, maxCapacity);
        } else {
            // 申請非池化內存
            buf = PlatformDependent.hasUnsafe() ?
                    UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
                    new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
        }

        return toLeakAwareBuffer(buf);
    }
}

Netty 會在當前內存池所有的 PoolArena 中,選出一個目前綁定線程數最少的 PoolArena 來與當前線程進行綁定。

    private final class PoolThreadLocalCache extends FastThreadLocal<PoolThreadCache> {

        private static final int CACHE_NOT_USED = 0;

        private <T> PoolArena<T> leastUsedArena(PoolArena<T>[] arenas) {
            // 內存池中第一個 PoolArena
            PoolArena<T> minArena = arenas[0];
            // PoolArena 中的 numThreadCaches 表示目前有多少線程與當前 PoolArena 進行綁定
            if (minArena.numThreadCaches.get() == CACHE_NOT_USED) {
                // 當前內存池還沒有綁定過線程,那麼就從第一個 PoolArena 開始綁定
                return minArena;
            }

            // 選取當前綁定線程數最少的 PoolArena 進行綁定
            for (int i = 1; i < arenas.length; i++) {
                PoolArena<T> arena = arenas[i];
                // 一個 PoolArena 會被多個線程綁定
                if (arena.numThreadCaches.get() < minArena.numThreadCaches.get()) {
                    minArena = arena;
                }
            }

            return minArena;
        }
    }

當線程與一個固定的 PoolArena 綁定好之後,接下來 Netty 就會為該線程創建本地緩存 PoolThreadCache,後續線程的內存申請與釋放都會先走 PoolThreadCache。

private final class PoolThreadLocalCache extends FastThreadLocal<PoolThreadCache> {
        @Override
        protected synchronized PoolThreadCache initialValue() {
            // 選取目前線程綁定數最少的 PoolArena 與當前線程進行綁定
            final PoolArena<ByteBuffer> directArena = leastUsedArena(directArenas);
            // 獲取當前線程
            final Thread current = Thread.currentThread();
            // 判斷當前線程是否是 Reactor 線程 —— executor != null
            final EventExecutor executor = ThreadExecutorMap.currentExecutor();
            // 如果當前線程是 FastThreadLocalThread 或者是 Reactor,那麼無條件使用 PoolThreadCache
            // 除此之外的普通線程再向內存池申請內存的時候,是否使用 PoolThreadCache 是由 useCacheForAllThreads 決定的(默認 false)
            if (useCacheForAllThreads ||
                    current instanceof FastThreadLocalThread ||
                    executor != null) {
                // 為當前線程創建 PoolThreadCache,並與 PoolArena 進行綁定
                final PoolThreadCache cache = new PoolThreadCache(
                        heapArena, directArena, smallCacheSize, normalCacheSize,
                        DEFAULT_MAX_CACHED_BUFFER_CAPACITY, DEFAULT_CACHE_TRIM_INTERVAL, useCacheFinalizers(current));
                // 默認不開啓定時清理
                if (DEFAULT_CACHE_TRIM_INTERVAL_MILLIS > 0) {
                    if (executor != null) {
                        // Reactor 線程會定時清理其 PoolThreadCache 中空閒的內存塊,將他們釋放回 PoolChunk 中
                        executor.scheduleAtFixedRate(trimTask, DEFAULT_CACHE_TRIM_INTERVAL_MILLIS,
                                DEFAULT_CACHE_TRIM_INTERVAL_MILLIS, TimeUnit.MILLISECONDS);
                    }
                }
                return cache;
            }
            // useCacheForAllThreads = false , 則當前線程只與 PoolArena 進行綁定,但沒有 PoolThreadCache(空的)
            return new PoolThreadCache(heapArena, directArena, 0, 0, 0, 0, false);
        }
}

Netty 設計了一個 ThreadExecutorMap 用於緩存 Reactor 線程與其對應的 Executor 之間的映射關係。

public final class ThreadExecutorMap {
    // 緩存 Reactor 線程與其對應的 Executor 之間的映射關係
    private static final FastThreadLocal<EventExecutor> mappings = new FastThreadLocal<EventExecutor>();
}

在 Reactor 線程啓動之後,會將 Reactor 線程所屬的 SingleThreadEventExecutor 設置到 ThreadExecutorMap 中,建立 Reactor 線程與其所屬 SingleThreadEventExecutor 之間的映射關係。

    private static void setCurrentEventExecutor(EventExecutor executor) {
        mappings.set(executor);
    }

當我們在某一個線程上下文中調用 ThreadExecutorMap.currentExecutor() 獲取到的 executor 不為空的時候,那麼恰巧説明當前線程其實就是 Reactor 線程。

    public static EventExecutor currentExecutor() {
        return mappings.get();
    }

如果當前線程是 Reactor 線程或者是一個 FastThreadLocalThread ,那麼 Netty 就會無條件為該線程創建本地緩存 PoolThreadCache,並將 PoolArena 綁定到它的 PoolThreadCache 中。

如果當前線程是一個普通的用户線程,默認情況下,Netty 不會為其創建本地緩存,除非 useCacheForAllThreads 設置為 true 。

final class PoolThreadCache {
    PoolThreadCache(PoolArena<byte[]> heapArena, PoolArena<ByteBuffer> directArena,
                    int smallCacheSize, int normalCacheSize, int maxCachedBufferCapacity,
                    int freeSweepAllocationThreshold, boolean useFinalizer) {
        // 該閾值表示當該 PoolThreadCache 分配了多少次內存塊之後,觸發清理緩存中空閒的內存
        this.freeSweepAllocationThreshold = freeSweepAllocationThreshold;   
        // 線程與 PoolArena 在這裏進行綁定
        this.directArena = directArena;
        if (directArena != null) {
            // 創建 Small 規格的緩存結構
            // smallCacheSize = DEFAULT_SMALL_CACHE_SIZE = 256 , 表示每一個 small 內存規格對應的 MemoryRegionCache 可以緩存的內存塊個數
            smallSubPageDirectCaches = createSubPageCaches(smallCacheSize, directArena.sizeClass.nSubpages);
            // 創建 Normal 規格的緩存結構
            // normalCacheSize = DEFAULT_NORMAL_CACHE_SIZE  = 64 , 每一個 normal 內存規格的 MemoryRegionCache 可以緩存的內存塊個數
            normalDirectCaches = createNormalCaches(normalCacheSize, maxCachedBufferCapacity, directArena);
            // PoolArena 中線程綁定計數加 1
            directArena.numThreadCaches.getAndIncrement();
        } else {
            // No directArea is configured so just null out all caches
            smallSubPageDirectCaches = null;
            normalDirectCaches = null;
        }
        // 當線程終結時是否使用 Finalizer 清理 PoolThreadCache
        freeOnFinalize = useFinalizer ? new FreeOnFinalize(this) : null;
    }
}

在 PoolThreadCache 的構造函數中,主要是將線程與 PoolArena 進行綁定,然後創建 Small 規格的線程本地緩存結構 —— smallSubPageDirectCaches,以及 Normal 規格的線程本地緩存結構 —— normalDirectCaches。

除此之外,Netty 還設計了兩個關於清理 PoolThreadCache 的參數:freeSweepAllocationThreshold 和 useFinalizer 。

對於那些在 PoolThreadCache 中不經常被使用的緩存來説,我們需要及時地將它們釋放回 PoolChunk 中,否則就會導致不必要的額外內存消耗。對此,Netty 設置了一個閾值 freeSweepAllocationThreshold , 默認為 8192 , 我們可以通過 -Dio.netty.allocator.cacheTrimInterval 進行調整。

它的語義是當 PoolThreadCache 分配內存的次數達到了閾值 freeSweepAllocationThreshold 之後,Netty 就會無條件清理 PoolThreadCache 中緩存的所有空閒內存塊。這種情況下,仍然還沒有被分配出去的內存塊,Netty 認為它們就是不經常被使用了,沒必要繼續停留在 PoolThreadCache 中。

useFinalizer 則是用於當線程終結的時候,是否採用 Finalizer 來釋放 PoolThreadCache 中的內存塊,因為 PoolThreadCache 是一個 Thread Local 變量,當線程終結的時候,PoolThreadCache 這個實例會被 GC 回收,但是它裏面緩存的內存塊就沒法釋放了,這就導致了內存泄露。

相似的情況還有 JDK 中的 DirectByteBuffer ,GC 只是回收 PoolThreadCache ,DirectByteBuffer 這些 Java 實例,它們內部引用的 Native Memory 則不會被回收,需要我們使用額外的機制來保證這些 Native Memory 及時回收。

useFinalizer 默認為 true , 我們可以通過參數 -Dio.netty.allocator.disableCacheFinalizersForFastThreadLocalThreads 進行調整。

disableCacheFinalizersForFastThreadLocalThreads 設置為 false (默認),則 useFinalizer 為 true , 那麼所有線程的 PoolThreadCache 在線程退出的時候將會被 Finalizer 進行清理。

如果 useFinalizer 為 false , 那麼當線程退出的時候,它的本地緩存 PoolThreadCache 將不會由 Finalizer 來清理。這種情況下,我們就需要特別注意,一定要通過 FastThreadLocal.removeAll() 或者 PoolThreadLocalCache.remove(PoolThreadCache) 來手動進行清理。否則就會造成內存泄露。

8.2 PoolThreadCache 的內存分配流程

當線程與一個固定的 PoolArena 綁定好之後,後續線程的內存申請與釋放就都和這個 PoolArena 打交道了,在進入 PoolArena 之後,首先我們需要從對象池中取出一個 PooledByteBuf 實例,因為後續從內存池申請的內存塊我們還無法直接使用,需要包裝成一個 PooledByteBuf 實例返回。Netty 針對 PooledByteBuf 實例也做了池化管理。

對 Netty 對象池具體實現細節感興趣的讀者朋友可以回看下筆者之前的文章 《詳解 Recycler 對象池的精妙設計與實現》
abstract class PoolArena {
    PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
        // 從對象池中獲取一個 PooledByteBuf 對象,這裏設置 maxCapacity
        PooledByteBuf<T> buf = newByteBuf(maxCapacity);
        // 從內存池中申請內存塊並初始化 PooledByteBuf 返回
        allocate(cache, buf, reqCapacity);
        return buf;
    }
}

image

  • 對於 Small 內存規格來説,走 tcacheAllocateSmall 進行分配。
  • 對於 Normal 內存規格來説,走 tcacheAllocateNormal 進行分配。
  • 對於 Huge 內存規格來説,則直接向 OS 申請,不會走內存池。
    private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
        // 獲取 reqCapacity 在內存規格表中的 sizeIndex
        final int sizeIdx = sizeClass.size2SizeIdx(reqCapacity);
        // [16B , 28K] 之間是 small 規格的內存
        if (sizeIdx <= sizeClass.smallMaxSizeIdx) {
            tcacheAllocateSmall(cache, buf, reqCapacity, sizeIdx);
        } else if (sizeIdx < sizeClass.nSizes) {
            // [32K , 4M] 之間是 normal 規格的內存
            tcacheAllocateNormal(cache, buf, reqCapacity, sizeIdx);
        } else {
            // 超過 4M 就是 Huge 規格
            int normCapacity = sizeClass.directMemoryCacheAlignment > 0
                    ? sizeClass.normalizeSize(reqCapacity) : reqCapacity;
            // Huge 內存規格直接向操作系統申請,不經過 cache 也不經過內存池
            allocateHuge(buf, normCapacity);
        }    

Small 規格內存塊的申請首先會嘗試從線程本地緩存 PoolThreadCache 中去獲取,如果緩存中沒有,則到 smallSubpagePools 中申請。

abstract class PoolArena {
    private void tcacheAllocateSmall(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity,
                                     final int sizeIdx) {
        // 首先嚐試從線程本地緩存 PoolThreadCache 申請 Small 規格
        if (cache.allocateSmall(this, buf, reqCapacity, sizeIdx)) {
            return;
        }

        ...... 通過 smallSubpagePools 分配 Small 規格內存塊 .......
    }
}

PoolThreadCache 中的 smallSubPageDirectCaches 是用來緩存 Small 規格的內存塊,一共 39 種規格,smallSubPageDirectCaches 數組的 index 就是對應 Small 規格尺寸在內存規格表中的 sizeIndex。

image

我們可以通過請求的內存尺寸對應在內存規格表中的 sizeIndex ,到 smallSubPageDirectCaches 中獲取對應的 MemoryRegionCache 。

    private static <T> MemoryRegionCache<T> cache(MemoryRegionCache<T>[] cache, int sizeIdx) {
        if (cache == null || sizeIdx > cache.length - 1) {
            return null;
        }
        return cache[sizeIdx];
    }

MemoryRegionCache 中有一個 MpscQueue ,裏面緩存了對應規格的內存塊,內存塊信息用一個 Entry 結構描述。

        static final class Entry<T> {
            // 內存塊所屬的 PoolChunk
            PoolChunk<T> chunk;
            // PoolChunk 中 memory 的 duplicate 視圖
            ByteBuffer nioBuffer;
            // 內存塊對應的 handle 結構
            long handle = -1;
            // 內存塊大小,單位為字節
            int normCapacity;
        }

我們從 MpscQueue 中拿出一個 Entry,利用裏面封裝的內存塊信息初始化成一個 PooledByteBuf 返回。

final class MemoryRegionCache {
        public final boolean allocate(PooledByteBuf<T> buf, int reqCapacity, PoolThreadCache threadCache) {
            // 從 MemoryRegionCache 中獲取內存塊
            Entry<T> entry = queue.poll();
            if (entry == null) {
                return false;
            }
            // 封裝成 PooledByteBuf
            initBuf(entry.chunk, entry.nioBuffer, entry.handle, buf, reqCapacity, threadCache);
            // 回收 entry 實例
            entry.unguardedRecycle();
            // MemoryRegionCache 的分配次數加 1
            ++ allocations;
            return true;
        }
}

隨後 MemoryRegionCache 中的 allocations 加 1 ,每一次從 MemoryRegionCache 中成功申請到一個內存塊,allocations 都會加 1 。

同時 PoolThreadCache 中的 allocations 計數也會加 1 , 當 PoolThreadCache 的 allocations 計數達到閾值 freeSweepAllocationThreshold 的時候,Netty 就會將 PoolThreadCache 中緩存的所有空閒內存塊重新釋放回 PoolChunk 中。這裏表達的語義是,都已經分配了這麼多次了,仍然空閒的內存塊那就是不經常使用的了,對於不經常使用的內存塊就沒必要緩存了。

    private boolean allocate(MemoryRegionCache<?> cache, PooledByteBuf buf, int reqCapacity) {
        if (cache == null) {
            // no cache found so just return false here
            return false;
        }
        // true 表示分配成功,false 表示分配失敗(緩存沒有了)
        boolean allocated = cache.allocate(buf, reqCapacity, this);
        // PoolThreadCache 中的 allocations 計數加 1
        if (++ allocations >= freeSweepAllocationThreshold) {
            allocations = 0;
            // 清理 PoolThreadCache,將緩存的內存塊釋放回 PoolChunk
            trim();
        }
        return allocated;
    }

同樣的道理,Normal 規格內存塊的申請首先也會嘗試從線程本地緩存 PoolThreadCache 中去獲取,如果緩存中沒有,則到 PoolChunk 中申請。

    private void tcacheAllocateNormal(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity,
                                      final int sizeIdx) {
        // 首先嚐試從線程本地緩存 PoolThreadCache 申請 Normal 規格
        if (cache.allocateNormal(this, buf, reqCapacity, sizeIdx)) {
            return;
        }
        lock();
        try {
            // 到 PoolChunk 中申請 Normal 規格的內存塊
            allocateNormal(buf, reqCapacity, sizeIdx, cache);
            ++allocationsNormal;
        } finally {
            unlock();
        }
    }

PoolThreadCache 中的 normalDirectCaches 是用來緩存 Normal 規格的內存塊,但默認情況下只會緩存一種 Normal 規格 —— 32K , 超過 32K 還是需要到 PoolChunk 中去申請。

normalDirectCaches 數組的 index 就是對應 Normal 規格在內存規格表中的 sizeIndex - 39 , 因為第一個 Normal 規格(32K)的 sizeIndex 就是 39 。

image

    private MemoryRegionCache<?> cacheForNormal(PoolArena<?> area, int sizeIdx) {
        // sizeIdx - 39
        int idx = sizeIdx - area.sizeClass.nSubpages;
        // 獲取 normalDirectCaches[idx]
        return cache(normalDirectCaches, idx);
    }

當我們獲取到 Normal 規格對應的 MemoryRegionCache 之後,剩下的流程就都是一樣的了,從 MemoryRegionCache 獲取一個 Entry 實例,根據裏面封裝的內存塊信息包裝成 PooledByteBuf 返回。

8.3 清理 PoolThreadCache 中不經常使用的內存塊

Netty 清理 PoolThreadCache 緩存有兩個時機,一個是主動清理,當 PoolThreadCache 分配內存塊的次數 allocations (包括 Small 規格,Normal 規格的分配次數)達到閾值 freeSweepAllocationThreshold (8192)時 , Netty 將會把 PoolThreadCache 中緩存的所有 Small 規格以及 Normal 規格的內存塊全部釋放回 PoolSubpage 中。

    void trim() {
        // 釋放 Small 規格緩存
        trim(smallSubPageDirectCaches);
        // 釋放 Normal 規格緩存
        trim(normalDirectCaches);
    }

    private static void trim(MemoryRegionCache<?>[] caches) {
        if (caches == null) {
            return;
        }
        for (MemoryRegionCache<?> c: caches) {
            trim(c);
        }
    }

挨個釋放 smallSubPageDirectCaches 以及 normalDirectCaches 中的 MemoryRegionCache 。

private abstract static class MemoryRegionCache<T> {
       // MemoryRegionCache 中可緩存的最大內存塊個數
       private final int size;
       // MemoryRegionCache 已經分配出去的內存塊個數
       private int allocations;

        public final void trim() {
            // 計算最大剩餘的內存塊個數
            int free = size - allocations;
            allocations = 0;
            // 將剩餘的內存塊全部釋放回內存池中
            if (free > 0) {
                free(free, false);
            }
        }
}

釋放緩存在 MpscQueue 中的所有內存塊。

        private int free(int max, boolean finalizer) {
            int numFreed = 0;
            for (; numFreed < max; numFreed++) {
                Entry<T> entry = queue.poll();
                if (entry != null) {
                    freeEntry(entry, finalizer);
                } else {
                    // all cleared
                    return numFreed;
                }
            }
            return numFreed;
        }

從 MpscQueue 中獲取 Entry,根據 Entry 結構中封裝的內存塊信息,將其釋放回內存池中。

        private  void freeEntry(Entry entry, boolean finalizer) {
            PoolChunk chunk = entry.chunk;
            long handle = entry.handle;
            ByteBuffer nioBuffer = entry.nioBuffer;
            int normCapacity = entry.normCapacity;
            // finalizer = false , 表示由 Netty 主動釋放
            if (!finalizer) {
                // 回收 entry 實例
                entry.recycle();
            }
            // 釋放內存塊回內存池中
            chunk.arena.freeChunk(chunk, handle, normCapacity, sizeClass, nioBuffer, finalizer);
        }

另一種清理 PoolThreadCache 緩存的時機是定時被動清理,定時清理機制默認是關閉的。但我們可以通過 -Dio.netty.allocator.cacheTrimIntervalMillis 參數進行開啓,該參數默認為 0 , 單位為毫秒,用於指定定時清理 PoolThreadCache 的時間間隔。

                // 默認不開啓定時清理
                if (DEFAULT_CACHE_TRIM_INTERVAL_MILLIS > 0) {
                    if (executor != null) {
                        // Reactor 線程會定時清理其 PoolThreadCache 中空閒的內存塊,將他們釋放回內存池中
                        executor.scheduleAtFixedRate(trimTask, DEFAULT_CACHE_TRIM_INTERVAL_MILLIS,
                                DEFAULT_CACHE_TRIM_INTERVAL_MILLIS, TimeUnit.MILLISECONDS);
                    }
                }

8.4 PoolThreadCache 的內存回收流程

當 PooledByteBuf 的引用計數為 0 時,Netty 就會將 PooledByteBuf 背後引用的內存塊釋放回內存池中,並且將 PooledByteBuf 這個實例釋放回對象池。

abstract class PooledByteBuf {
    @Override
    protected final void deallocate() {
        if (handle >= 0) {
            final long handle = this.handle;
            this.handle = -1;
            memory = null;
            // 將內存釋放回內存池中
            chunk.arena.free(chunk, tmpNioBuf, handle, maxLength, cache);
            tmpNioBuf = null;
            chunk = null;
            cache = null;
            // 回收 PooledByteBuf 實例
            this.recyclerHandle.unguardedRecycle(this);
        }
    }
}

如果內存塊是 Huge 規格的,那麼直接釋放回 OS , 如果內存塊不是 Huge 規格的,那麼就根據內存塊 handle 結構中的 isSubpage bit 位判斷該內存塊是 Small 規格的還是 Normal 規格的。

image

Small 規格 handle 結構 isSubpage bit 位設置為 1 ,Normal 規格 handle 結構 isSubpage bit 位設置為 0 。

    private static SizeClass sizeClass(long handle) {
        return isSubpage(handle) ? SizeClass.Small : SizeClass.Normal;
    }

然後根據內存塊的規格釋放回對應的 MemoryRegionCache 中。

abstract class PoolArena {
    void free(PoolChunk<T> chunk, ByteBuffer nioBuffer, long handle, int normCapacity, PoolThreadCache cache) {
        // Huge 規格的內存塊直接釋放回 OS,非池化管理
        if (chunk.unpooled) {
            int size = chunk.chunkSize();
            // 直接將 chunk 的內存釋放回 OS
            destroyChunk(chunk);
        } else {
            // 獲取內存塊的規格 small ? normal ?
            SizeClass sizeClass = sizeClass(handle);
            // 先釋放回對應的 PoolThreadCache 中
            // cache.add 返回 false 表示緩存添加失敗
            if (cache != null && cache.add(this, chunk, nioBuffer, handle, normCapacity, sizeClass)) {
                return;
            }
            // 如果緩存添加失敗,則釋放回內存池
            freeChunk(chunk, handle, normCapacity, sizeClass, nioBuffer, false);
        }
    }
}

這裏緩存添加失敗的情況有三種:

  1. 對應規格的 MemoryRegionCache 已經滿了,對於 Small 規格來説,其對應的 MemoryRegionCache 緩存結構最多可以緩存 256 個內存塊,對於 Normal 規格來説,則最多可以緩存 64 個。
  2. PoolThreadCache 並沒有提供對應規格尺寸的 MemoryRegionCache 緩存。比如默認情況下,Netty 只會提供 32K 這一種 Normal 規格的緩存,如果釋放 40K 的內存塊則只能釋放回內存池中。
  3. 線程對應的本地緩存 PoolThreadCache 已經被釋放。比如線程已經退出了,那麼其對應的 PoolThreadCache 則會被釋放,這時內存塊就只能釋放回內存池中。
final class PoolThreadCache {
    // PoolThreadCache 是否已經被釋放
    private final AtomicBoolean freed = new AtomicBoolean();

    boolean add(PoolArena<?> area, PoolChunk chunk, ByteBuffer nioBuffer,
                long handle, int normCapacity, SizeClass sizeClass) {
        // 獲取要釋放內存尺寸大小 normCapacity 對應的內存規格 sizeIndex
        int sizeIdx = area.sizeClass.size2SizeIdx(normCapacity);
        // 獲取 sizeIndex 對應內存規格的 MemoryRegionCache
        MemoryRegionCache<?> cache = cache(area, sizeIdx, sizeClass);
        if (cache == null) {
            return false;
        }
        // true 表示 PoolThreadCache 已被釋放
        if (freed.get()) {
            return false;
        }
        // 將內存塊釋放回對應的 MemoryRegionCache 中
        return cache.add(chunk, nioBuffer, handle, normCapacity);
    }
}

將內存塊釋放回對應規格尺寸的 MemoryRegionCache 中。

        public final boolean add(PoolChunk<T> chunk, ByteBuffer nioBuffer, long handle, int normCapacity) {
            // 根據內存塊相關的信息封裝 Entry 實例
            Entry<T> entry = newEntry(chunk, nioBuffer, handle, normCapacity);
            // 將 Entry 實例添加到 MpscQueue 中
            boolean queued = queue.offer(entry);
            if (!queued) {
                // 緩存失敗,回收 Entry 實例
                entry.unguardedRecycle();
            }
            // true 表示緩存成功
            // false 表示緩存滿了,添加失敗
            return queued;
        }

8.5 PoolThreadCache 的釋放

PoolThreadCache 是線程的本地緩存,裏面緩存了內存池中 Small 規格的內存塊以及 Normal 規格的內存塊。

final class PoolThreadCache {
    // 本地緩存線程申請過的 Small 規格內存塊
    private final MemoryRegionCache<ByteBuffer>[] smallSubPageDirectCaches;
    // 本地緩存線程申請過的 Normal 規格內存塊
    private final MemoryRegionCache<ByteBuffer>[] normalDirectCaches;
}

當線程終結的時候,其對應的 PoolThreadCache 也隨即會被 GC 回收,但這裏需要注意的是 GC 回收的只是 PoolThreadCache 這個 Java 實例,其內部緩存的這些內存塊 GC 是管不着的,因為 GC 並不知道這裏還有一個內存池的存在。

同樣的道理類似於 JDK 中的 DirectByteBuffer,GC 只負責回收 DirectByteBuffer 這個 Java 實例,其背後引用的 Native Memory ,GC 是管不着的,所以我們需要使用額外的機制來保證這些 Native Memory 被及時回收。

對於 JDK 中的 DirectByteBuffer,JDK 使用了 Cleaner 機制來回收背後的 Native Memory ,而對於 PoolThreadCache 來説,Netty 這裏則是用了 Finalizer 機制會釋放。

對 Cleaner 以及 Finalizer 背後的實現細節感興趣的讀者朋友可以回看下筆者之前的文章 《以 ZGC 為例,談一談 JVM 是如何實現 Reference 語義的》。

PoolThreadCache 中有一個 freeOnFinalize 字段:

final class PoolThreadCache {
    // 利用 Finalizer 釋放 PoolThreadCache
    private final FreeOnFinalize freeOnFinalize;
}

當 useFinalizer 為 true 的時候,Netty 會創建一個 FreeOnFinalize 實例:

freeOnFinalize = useFinalizer ? new FreeOnFinalize(this) : null;

FreeOnFinalize 對象再一次循環引用了 PoolThreadCache , FreeOnFinalize 重寫了 finalize() 方法,當 FreeOnFinalize 對象創建的時候,JVM 會為其創建一個 Finalizer 對象(FinalReference 類型),Finalizer 引用了 FreeOnFinalize ,但這種引用關係是一種 FinalReference 類型。

    private static final class FreeOnFinalize {
        // 待釋放的 PoolThreadCache
        private volatile PoolThreadCache cache;

        private FreeOnFinalize(PoolThreadCache cache) {
            this.cache = cache;
        }

        @Override
        protected void finalize() throws Throwable {
            try {
                super.finalize();
            } finally {
                PoolThreadCache cache = this.cache;
                this.cache = null;
                // 當 FreeOnFinalize 實例要被回收的時候,觸發 PoolThreadCache 的釋放
                if (cache != null) {
                    cache.free(true);
                }
            }
        }
    }

與 PoolThreadCache 相關的對象引用關係如下圖所示:

image

當線程終結的時候,那麼 PoolThreadCache 與 FreeOnFinalize 對象將會被 GC 回收,但由於 FreeOnFinalize 被一個 FinalReference(Finalizer) 引用,所以 JVM 會將 FreeOnFinalize 對象再次復活,由於 FreeOnFinalize 對象也引用了 PoolThreadCache,所以 PoolThreadCache 也會被複活。

隨後 JDK 中的 2 號線程 —— finalizer 會執行 FreeOnFinalize 對象的 finalize() 方法,釋放 PoolThreadCache。

        Thread finalizer = new FinalizerThread(tg);
        finalizer.setPriority(Thread.MAX_PRIORITY - 2);
        finalizer.setDaemon(true);
        finalizer.start();

但如果有人不想使用 Finalizer 來釋放的話,則可以通過將 -Dio.netty.allocator.disableCacheFinalizersForFastThreadLocalThreads 設置為 true , 那麼 useFinalizer 就會變為 false 。

這樣一來當線程終結的時候,它的本地緩存 PoolThreadCache 將不會由 Finalizer 來清理。這種情況下,我們就需要特別注意,一定要通過 FastThreadLocal.removeAll() 或者 PoolThreadLocalCache.remove(PoolThreadCache) 來手動進行清理。否則就會造成內存泄露。

private final class PoolThreadLocalCache extends FastThreadLocal<PoolThreadCache> {
        @Override
        protected void onRemoval(PoolThreadCache threadCache) {
            threadCache.free(false);
        }
}

下面是 PoolThreadCache 的釋放流程:

final class PoolThreadCache {
    // PoolThreadCache 是否已經被釋放
    private final AtomicBoolean freed = new AtomicBoolean();
    private final FreeOnFinalize freeOnFinalize;

    // finalizer = true ,表示釋放流程由 finalizer 線程執行
    // finalizer = false ,表示釋放流程由用户線程執行
    void free(boolean finalizer) {
        // 我們需要保證 free 操作只能執行一次
        // 因為這裏有可能會被 finalizer 線程以及用户線程併發執行
        if (freed.compareAndSet(false, true)) {
            if (freeOnFinalize != null) {
                // 如果用户線程先執行 free 流程,那麼儘早的斷開 freeOnFinalize 與 PoolThreadCache 之間的引用
                // 這樣可以使 PoolThreadCache 儘早地被回收,不會被後面的 Finalizer 復活
                freeOnFinalize.cache = null;
            }
            // 將 PoolThreadCache 緩存的所有內存塊釋放回內存池中
            // 釋放流程與 8.3 小節的內容一致
            int numFreed = free(smallSubPageDirectCaches, finalizer) +
                           free(normalDirectCaches, finalizer) ;

            if (directArena != null) {
                // PoolArena 的線程綁定計數減 1
                directArena.numThreadCaches.getAndDecrement();
            }
        }
    }

9. 內存池相關的 Metrics

為了更好的監控內存池的運行狀態,Netty 為內存池中的每個組件都設計了一個對應的 Metrics 類,用於封裝與該組件相關的 Metrics。

image.png

其中內存池 PooledByteBufAllocator 提供的 Metrics 如下:

public final class PooledByteBufAllocatorMetric implements ByteBufAllocatorMetric {

   private final PooledByteBufAllocator allocator;

   PooledByteBufAllocatorMetric(PooledByteBufAllocator allocator) {
        this.allocator = allocator;
   }
   // 內存池一共有多少個 PoolArenas
   public int numDirectArenas() {
        return allocator.numDirectArenas();
   }
   // 內存池中一共綁定了多少線程
   public int numThreadLocalCaches() {
        return allocator.numThreadLocalCaches();
   }
   // 每一種 Small 規格最大可緩存的個數,默認為 256
   public int smallCacheSize() {
        return allocator.smallCacheSize();
   }
   // 每一種 Normal 規格最大可緩存的個數,默認為 64
   public int normalCacheSize() {
        return allocator.normalCacheSize();
   }
   // 內存池中 PoolChunk 的尺寸大小,默認為 4M
   public int chunkSize() {
        return allocator.chunkSize();
   }
   // 該內存池目前一共向 OS 申請了多少內存(包括 Huge 規格)單位為字節
   @Override
   public long usedDirectMemory() {
        return allocator.usedDirectMemory();
   }

PoolArena 提供的 Metrics 如下:

abstract class PoolArena<T> implements PoolArenaMetric {
    // 一共有多少線程與該 PoolArena 進行綁定
    @Override
    public int numThreadCaches() {
        return numThreadCaches.get();
    }
    // 一共有多少種 Small 規格尺寸,默認為 39
    @Override
    public int numSmallSubpages() {
        return smallSubpagePools.length;
    }
    // 該 PoolArena 中一共包含了幾個 PoolChunkList
    @Override
    public int numChunkLists() {
        return chunkListMetrics.size();
    }
    // 該 PoolArena 總共分配內存塊的次數(包含 Small 規格,Normal 規格,Huge 規格)
    // 一直累加,內存塊釋放不遞減
    long numAllocations();
    // PoolArena 總共分配 Small 規格的次數,一直累加,釋放不遞減
    long numSmallAllocations();
    // PoolArena 總共分配 Normal 規格的次數,一直累加,釋放不遞減
    long numNormalAllocations();
    // PoolArena 總共分配 Huge 規格的次數,一直累加,釋放不遞減
    long numHugeAllocations();
    // 該 PoolArena 總共回收內存塊的次數(包含 Small 規格,Normal 規格,Huge 規格)
    // 一直累加   
    long numDeallocations();
    // PoolArena 總共回收 Small 規格的次數,一直累加
    long numSmallDeallocations();
    // PoolArena 總共回收 Normal 規格的次數,一直累加
    long numNormalDeallocations();
    // PoolArena 總共釋放 Huge 規格的次數,一直累加
    long numHugeDeallocations();
    // PoolArena 當前淨內存分配次數
    // 總 Allocations 計數減去總 Deallocations 計數
    long numActiveAllocations();
    // 同理,PoolArena 對應規格的淨內存分配次數
    long numActiveSmallAllocations();
    long numActiveNormalAllocations();
    long numActiveHugeAllocations();
    // 該 PoolArena 目前一共向 OS 申請了多少內存(包括 Huge 規格)單位為字節
    long numActiveBytes();
}

PoolSubpage 提供的 Metrics 如下:

final class PoolSubpage<T> implements PoolSubpageMetric {
    // 該 PoolSubpage 一共可以切分出多少個對應 Small 規格的內存塊
    @Override
    public int maxNumElements() {
        return maxNumElems;
    }
    // 該 PoolSubpage 當前還剩餘多少個未分配的內存塊
    int numAvailable();
    // PoolSubpage 管理的 Small 規格尺寸
    int elementSize();
    // 內存池的 pageSize,默認為 8K
    int pageSize();
}

PoolChunkList 提供的 Metrics 如下:

final class PoolChunkList<T> implements PoolChunkListMetric {
    // 該 PoolChunkList 中管理的 PoolChunk 內存使用率下限,單位百分比
    @Override
    public int minUsage() {
        return minUsage0(minUsage);
    }
    // 該 PoolChunkList 中管理的 PoolChunk 內存使用率上限,單位百分比
    @Override
    public int maxUsage() {
        return min(maxUsage, 100);
    }
}

PoolChunk 提供的 Metrics 如下:

final class PoolChunk<T> implements PoolChunkMetric {
    // 當前 PoolChunk 的內存使用率,單位百分比
    int usage();
    // 默認 4M
    int chunkSize();
    // 當前 PoolChunk 中剩餘內存容量,單位字節
    int freeBytes();
}

總結

到現在為止,關於 Netty 內存池的整個設計與實現筆者就為大家剖析完了,從整個內存池的設計過程來看,我們見到了許多 OS 內核的影子,Netty 也是參考了很多 OS 內存管理方面的設計,如果對 OS 內存管理這塊內容感興趣的讀者朋友可以擴展看一下筆者之前寫的相關文章:

  • 《深度剖析 Linux 夥伴系統的設計與實現》
  • 《細節拉滿,80 張圖帶你一步一步推演 slab 內存池的設計與實現》
  • 《深度解讀 Linux 內核級通用內存池 —— kmalloc 體系》

好了,今天的內容就到這裏,我們下篇文章見~~~

user avatar u_17513518 頭像 vanve 頭像 xiaoniuhululu 頭像 AmbitionGarden 頭像 ligaai 頭像 kohler21 頭像 shumile_5f6954c414184 頭像 aipaobudeshoutao 頭像 aipaobudezuoyeben 頭像 immerse 頭像 chenjiabing666 頭像 zbooksea 頭像
點贊 48 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.