Netty內存池(史上最全 + 5W字長文)

文章很長5萬字,而且超級複雜,涉及Linux內核的核心算法,建議收藏起來慢慢讀!瘋狂創客圈總目錄 語雀版 | 總目錄 碼雲版| 總目錄 博客園版 爲您奉上珍貴的學習資源 :


推薦:入大廠 、做架構、大力提升Java 內功 的 精彩博文

入大廠 、做架構、大力提升Java 內功 必備的精彩博文 秋招漲薪1W + 必備的精彩博文
1:Redis 分佈式鎖 (圖解-秒懂-史上最全) 2:Zookeeper 分佈式鎖 (圖解-秒懂-史上最全)
3: Redis與MySQL雙寫一致性如何保證? (面試必備) 4: 面試必備:秒殺超賣 解決方案 (史上最全)
5:面試必備之:Reactor模式 6: 10分鐘看懂, Java NIO 底層原理
7:TCP/IP(圖解+秒懂+史上最全) 8:Feign原理 (圖解)
9:DNS圖解(秒懂 + 史上最全 + 高薪必備) 10:CDN圖解(秒懂 + 史上最全 + 高薪必備)
11: 分佈式事務( 圖解 + 史上最全 + 吐血推薦 ) 12:限流:計數器、漏桶、令牌桶
三大算法的原理與實戰(圖解+史上最全)
13:架構必看:12306搶票系統億級流量架構
(圖解+秒懂+史上最全)
14:seata AT模式實戰(圖解+秒懂+史上最全)
15:seata 源碼解讀(圖解+秒懂+史上最全) 16:seata TCC模式實戰(圖解+秒懂+史上最全)

Java 面試題 30個專題 , 史上最全 , 面試必刷 阿里、京東、美團... 隨意挑、橫着走!!!
1: JVM面試題(史上最強、持續更新、吐血推薦) 2:Java基礎面試題(史上最全、持續更新、吐血推薦
3:架構設計面試題 (史上最全、持續更新、吐血推薦) 4:設計模式面試題 (史上最全、持續更新、吐血推薦)
17、分佈式事務面試題 (史上最全、持續更新、吐血推薦) 一致性協議 (史上最全)
29、多線程面試題(史上最全) 30、HR面經,過五關斬六將後,小心陰溝翻船!
9.網絡協議面試題(史上最全、持續更新、吐血推薦) 更多專題, 請參見【 瘋狂創客圈 高併發 總目錄

SpringCloud 微服務 精彩博文
nacos 實戰(史上最全) sentinel (史上最全+入門教程)
SpringCloud gateway (史上最全) 分庫分表sharding-jdbc底層原理與實操(史上最全,5W字長文,吐血推薦)

宗旨:此文,帶大家起底式、穿透式 搞定Netty內存池

Netty 作爲底層網絡框架,爲了更高效的網絡傳輸性能,堆外內存(Direct ByteBuffer)的使用是非常高頻的。

堆外內存在 JVM 之外,在有效降低 JVM GC 壓力的同時,還能提高傳輸性能。

但它也是一把雙刃劍,堆外內存是非常寶貴的資源,申請和釋放都是高成本的操作,使用不當還可能造成嚴重的內存泄露等問題 。

爲什麼需要池化內存

性能問題:創建堆外內存的速度比堆內存慢了10到20倍

那麼進行池化管理,多次重用是比較有效的方式。

爲了解決這個問題Netty就做了內存池,Netty的內存池是不依賴於JVM本身的GC的。

從申請內存大小的角度講,申請多大的 Direct ByteBuffer 進行池化又會是一大問題,太大會浪費內存,太小又會出現頻繁的擴容和內存複製!!!

所以呢,就需要有一個合適的內存管理算法,解決高效分配內存的同時又解決內存碎片化的問題。

所以一個優秀的內存管理算法必不可少。

一個內存分配器至少需要看關注兩個核心目標:

  • 高效的內存分配和回收,提升單線程或者多線程場景下的性能
  • 提高內存的有效利用率,減少內存碎片,包括內部碎片和外部碎片

可以帶着以下問題去看Netty內存池源碼:

  • 內存池管理算法是怎麼做到申請效率,怎麼減少內存碎片
  • 高負載下內存池不斷擴展,如何做到內存回收
  • 對象池是如何實現的,這個不是關鍵路徑,可以當成黑盒處理
  • 內存池跟對象池作爲全局數據,在多線程環境下如何減少鎖競爭
  • 池化後內存的申請跟釋放必然是成對出現的,那麼如何做內存泄漏檢測,特別是跨線程之間的申請跟釋放是如何處理的。

jemalloc

jemalloc是一種優秀的內存管理算法,在這裏就不展開去探究了,大家可以自行 google 百度。本文基於netty管理pooled direct memory實現進行講解,netty對於java heap buffer的管理和對direct memory的管理在實現上基本相同

Netty 作爲一款高性能的網絡應用程序框架,擁有自己的內存分配。

Netty內存池的思想源於 jemalloc github ,可以說是 jemalloc 的 Java 版本
本章源碼基於 Netty 4.1.0版本,該版本是採用 jemalloc3.x 的算法思想,而 4.1.45 以後的版本則基於 jemalloc4.x 算法進行重構,兩者差別還是挺大的。

jemalloc 是由 Jason Evans 在 FreeBSD 項目中引入的新一代內存分配器。它是一個通用的 malloc 實現,側重於減少內存碎片和提升高併發場景下內存的分配效率,其目標是能夠替代 mallocjemalloc 應用十分廣泛,在 Firefox、Redis、Rust、Netty 等出名的產品或者編程語言中都有大量使用。具體細節可以參考 Jason Evans 發表的論文 [《A Scalable Concurrent malloc Implementation for FreeBSD》]。
除了 jemalloc 之外,業界還有一些著名的高性能內存分配器實現,比如 ptmalloctcmalloc。簡單對比如下:

  • ptmalloc(per-thread malloc) 基於 glibc 實現的內存分配器,由於是標準實現,兼容性較好。缺點是多線程之間內存無法實現共享,內存開銷很大。
  • tcmalloc(thread-caching malloc) 是由 Google 開源,最大特點是帶有線程緩存,目前在 Chrome、Safari 等產品中有所應用。tcmalloc 爲每個線程分配一個局部緩存,可以從線程局部緩衝分配小內存對象,而對於大內存分配則使用自旋鎖減少內存競爭,提高內存效率。
  • jemalloc 借鑑 tcmalloc 優秀的設計思路,所以在架構設計方面兩者有很多相似之處,同樣都包含線程緩存特性。但是 jemalloc 在設計上比 tcmalloc 要複雜。它將內存分配粒度劃分爲Small、Large、Huge,並記錄了很多元數據,所以元數據佔用空間高於 tcmalloc。

從上面瞭解到,他們的核心目標無外乎有兩點:

  • 高效的內存分配和回收,提升單線程或多線程場景下的性能。
  • 減少內存碎片,包括內存碎片和外部碎片。提高內存的有效利用率。

內存碎片

在 Linux 世界,物理內存會被劃分成若干個 4KB 大小的內存頁(page),這是分配內存大小的最小粒度。

分配和回收都是基於 page 完成的。

page 內產生的碎片稱爲 內存碎片,page 外產生的碎片稱爲 外部碎片

內存碎片產生的原因:

是內存被分割成很小的塊,雖然這些塊是空閒且地址連續的,但卻小到無法使用。

隨着內存的分配和釋放次數的增加,內存將變得越來越不連續。

最後,整個內存將只剩下碎片,即便有足夠的空閒頁框可以滿足請求,但要分配一個大塊的連續頁框就無法滿足,

外部碎片產生的原因:

外部碎片指的是還沒有被分配出去(不屬於任何進程),但由於太小了無法分配給申請內存空間的新進程的內存空閒區域。
外部碎片是出於任何已分配區域或頁面外部的空閒存儲塊。

這些存儲塊的總和可以滿足當前申請的長度要求,但是由於它們的地址不連續或其他原因,使得系統無法滿足當前申請。

減少內存浪費的核心

所以減少內存浪費的核心就是儘量避免產生內存碎片。

常見的內存分配器算法

常見的內存分配器算法有:

  • 動態內存分配
  • 夥伴算法
  • Slab算法

動態內存分配

全稱 Dynamic memory allocation,又稱爲 堆內存分配,簡單 DMA

簡單地說就是想要多少內存空間,操作系統就給你多少。在大部分場景下,只有在程序運行時才知道所需內存空間大小,

提前分配的內存大小空間不好把控,分配太多造成空間浪費,分配太少造成程序崩潰。

DMA 就是從一整塊內存中 按需分配,對於已分配的內存會記錄元數據,同時還會使用空閒分區維護空閒內存,便於在下次分配時快速查找可用的空閒分區。

常見的有以下三種查找策略:

首次適應算法(first fit)

  • 空閒分區按內存地址從低到高的順序以雙向鏈表形式連接在一起。
  • 內存分配每次從低地址開始查找並分配。因此造成低地址使用率較高而高地址使用率很低。同時會產生較多的小內存。

循環首次適應算法(next fit)

  • 該算法是 首次適應算法 的變種,主要變化是第二次的分配是從下一個空閒分區開始查找。
  • 對於 首次適應算法 ,該算法將內存分配得更加均勻,查找效率有所提升,但是這會導致嚴重的內存碎片。

最佳適應算法(best fit)

  • 空間分區鏈始終保持從小到大的遞增順序。當內存分配時,從開頭開始查找適合的空間內存並分配,當完成分配請求後,空閒分區鏈重新按分區大小排序。
  • 此算法的空間利用率更高,但同樣會有難以利用的小空間分區,究其原因是空閒內存塊大小不變,並沒有針對內存大小做優化分類,除非內存內存大小剛好等於空閒內存塊的大小,空間利用率 100%。
  • 每次分配完後需要重新排序,因此存在 CPU 消耗。

動態內存分配的問題

動態內存分配的問題:會導致嚴重的內存碎片

內存碎片就是內存被分割成很小很小的一些塊,這些塊雖然是空閒的,可是卻小到沒法使用。

隨着申請和釋放次數的增長,內存將變得愈來愈不連續。

最後,整個內存將只剩下碎片,即便有足夠的空閒頁框能夠知足請求,但要分配一個大塊的連續頁框就可能沒法滿足,因此減小內存浪費的核心就是儘可能避免產生內存碎片。

針對這樣的問題,有不少行之有效的解決方法,其中夥伴算法被證實是很是行之有效的一套內存管理方法,所以也被至關多的操做系統所採用。

夥伴算法(Buddy memory allocation)

如何避免外部碎片的方法有兩種:

(1)是利用分頁單元把一組非連續的空閒頁框映射到連續的線性地址區間;

(2)夥伴系統:是記錄現存空閒連續頁框塊情況,以儘量避免爲了滿足對小塊的請求而分割大的連續空閒塊。

夥伴內存分配技術是一種內存分配算法,它將內存劃分爲分區,以最合適的大小滿足內存請求。

Buddy memory allocation 於 1963 年 Harry Markowitz 發明。

夥伴算法的原理

夥伴(buddy)算法中,它不像DMA那樣,根據需要從被管理內存的空閒分區以隨意大小方式進行分配。

而是按照不同的規格,以塊爲單位進行分配。各個內存塊可分可合,但不是任意的分與合。

每一個塊都有個朋友,或叫“夥伴”,既可與之分開,又可與之結合。所以,只有夥伴關係的內存塊,才能分開和合並。

夥伴算法的原理

系統中的空閒內存總是按照相鄰關係,兩兩分組,每組中的兩個內存塊稱作夥伴。

夥伴的分配可以是彼此獨立的。

但如果兩個小夥伴都是空閒的,內核將其合併爲一個更大的內存塊,作爲下一層次上某個內存塊的夥伴。

具體先看下一個例子:

在这里插入图片描述

首先,夥伴算法把所有的空閒頁面分爲10個塊組,

每組中塊的大小是2的冪次方個頁面,例如

  • 第0組中塊的大小都爲1個頁面,
  • 第1組中塊的大小爲都爲2個頁面,
  • 第9組中塊的大小都爲512個頁面。

也就是說,每一組中塊的大小是相同的,且這同樣大小的塊形成一個鏈表(可以解釋爲hashmap中hash值相同的key的桶 bucket)。

剩下的未分配的內存,我們將其添加到第10個塊組中。

並且,除了第0個塊組有2個塊以外,其餘都只有一個塊,但是第10個塊組可以有多個塊。

什麼的夥伴塊?如何獲取?

夥伴塊指的是連續的兩個塊,這兩個塊大小相等,並且兩個塊合併後的塊可以一直迭代合併爲1024頁的大塊。

這一點可能不太好理解,畫了個圖:

img

假設我們尋找1號塊的夥伴塊,如果是2號塊的話,當1,2號塊合併後,是無法繼續與0號塊 合併的,此時0號塊就變成了不可合併狀態,所以1號塊的夥伴塊應該是0號而不是2號。

尋找到夥伴塊的方法是這樣的:

當塊大小爲n時,尋找到的夥伴塊必須滿足,合併後的大塊的左邊(低內存)區域的大小應該是合併大塊的k倍,即2nk的大小(k爲非負整數)。

怎麼進行快分配?

當需要分配一個內存大小爲n時,需要分配一個內存塊,塊的大小爲m,且m滿足: m/2 < n and m>=n.

通過這個限制條件,我們可以獲得要分配的塊的大小,並且到對應的塊組尋找有沒有空閒塊,如果有的話就把這個塊分配出來,如果沒有的話就把繼續尋找到上一級塊組,如果上一級有的話,就將這個空閒塊拆分爲兩個並且分配一個塊出來,另一個塊歸入下一級塊組中。

如果上一級也沒有空閒塊的話,就繼續向上一級尋找,遞歸尋找到合適的塊。

怎麼進行塊釋放?

類似於塊分配的逆向操作,回收一個塊時會首先檢測其夥伴塊是否空閒,如果空閒的話,回收塊會與夥伴塊合併爲更大的塊,並且在上一級塊組中尋找夥伴塊合併,遞歸進行此操作,直到無法再次合併爲止。

夥伴算法的一個簡單例子

假設,一個最初由256KB的物理內存。假設申請21KB的內存,內核需分片過程如下:

內核將256KB的內存進行分割,變成兩個128KB的內存塊,AL和AR,這兩個內存塊稱爲夥伴

隨後他發現128KB也遠大於21KB,於是他繼續分割爲兩個64KB的內存塊,發現64KB 也不是滿足需求的最小的內存塊,於是他繼續分割爲兩個32KB的。

32KB再往下就是16KB,就不滿足需求了,所以32KB是它滿足需求的最下的內存塊了,所以他就分割出來的CL 或者CR 分配給需求方。

當需求方用完了,需要進行歸還:

然後他把32KB的內存還回來,它的另一個夥伴如果沒被佔用,那麼他們地址連續,就合併成一個64KB的內存塊,以此類推,進行合併。

注意:

這裏的所有的分割都是進行二分來分割,所有內存塊的大小都是2的冪次方。

夥伴算法的祕訣:

把內存塊存放在比鏈接表更先進的數據結構中。這些結構常常是桶型、樹型和堆型的組合或變種。一般來說,由於所選數據結構的不同,而各夥伴分配程序的工作方式是相差很大。

由於有各種各樣的具有已知特性的數據結構可供使用,所以夥伴分配程序得到廣泛應用。

夥伴分配程序編寫起來常常很複雜,其性能可能各不相同。

linux內核中夥伴算法

Linux內核內存管理的一項重要工作就是如何在頻繁申請釋放內存的情況下,避免碎片的產生。

Linux採用夥伴系統解決外部碎片的問題,採用slab解決內部碎片的問題。

Linux2.6爲每一個管理區使用不一樣的夥伴系統,內核空間分爲三種區,DMA,NORMAL,HIGHMEM,對於每一種區,都有對應的夥伴算法。

linux內核中,夥伴算法把所有的空閒頁框分組成 11 個塊鏈表,每一個塊鏈表分別包含大小爲1、2、4、8、16、32、64、128、256、512 和 1024 個連續的頁框。

最大內存請求大小爲 4MB,該內存是連續的。夥伴算法即大小相同、地址連續。

11個塊鏈表中:

  • 第0個塊鏈表包含大小爲2^0個連續的頁框,

  • 第1個塊鏈表中,每一個鏈表元素包含2個頁框大小的連續地址空間

  • ….

  • 第10個塊鏈表中,每一個鏈表元素表明4M的連續地址空間。

每一個鏈表中元素的個數在系統初始化時決定,在執行過程當中,動態變化。

#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
#else
#define MAX_ORDER CONFIG_FORCE_MAX_ZONEORDER
#endif
#define MAX_ORDER_NR_PAGES (1 << (MAX_ORDER - 1))

  struct free_area {
    struct list_head    free_list[MIGRATE_TYPES];//空閒塊雙向鏈表
    unsigned long       nr_free;//空閒塊的數目
  };
  
  struct zone{
       ....
       struct free_area    free_area[MAX_ORDER];  
       ....
  };

zone從上到下的每一個元素的類型爲free_area,free_area內部都是都保存一個free_list鏈表,

img

struct free_area free_area[MAX_ORDER] #MAX_ORDER 默認值爲11,分別存放着11個組

free_area數組中第K個free_area元素,它標識所有大小爲2^k的空閒塊,所有空閒快由free_list指向的雙向循環鏈表組織起來。

在這裏插入圖片描述

換一個角度的圖

在這裏插入圖片描述

夥伴算法每次只能分配2的冪次個頁框的空間,每頁大小通常爲4K

例如:一次分配1頁,2頁,4頁,8頁,…,1024頁(2^10)等等,所以,夥伴算法最多一次可以分配4M(1024*4K)的內存空間

MAX_ORDER默認值爲11,分別存放着11個組,free_area結構體裏面又標註了該組別空閒內存塊的情況。

zone的成員:nr_free和zone_mem_map數組

其中的nr_free,它指定了對應空間剩餘塊的個數。

zone的 zone_mem_map數組

在這裏插入圖片描述

夥伴位圖mem_map

夥伴關係: 兩個內存塊,大小相同,地址連續,同屬於一個大塊區域。(第0塊和第1塊是夥伴,第2塊和第3塊是夥伴,但第1塊和第2塊不是夥伴)

夥伴位圖:用一位描述夥伴塊的狀態位碼,稱之爲夥伴位碼。

夥伴位碼:用一位描述夥伴塊的狀態位碼,稱之爲夥伴位碼。
比如,bit0爲第0塊和第1塊的夥伴位碼,若是bit0爲1,表示這兩塊至少有一塊已經分配出去,若是bit0爲0,說明兩塊都空閒或者兩塊都被使用。如果bit0爲1,表示這兩塊至少有一塊已經分配出去,如果bit0爲0,說明兩塊都空閒,還沒分配。

整個過程當中,位圖扮演了重要的角色

Linux內核夥伴算法中每一個order 的位圖都表示全部的空閒塊,位圖的某位對應於兩個夥伴塊,

爲1就表示其中一塊忙,爲0表示兩塊都閒或都在使用。

系統每次分配和回收夥伴塊時都要對它們的夥伴位跟1進行異或運算。

所謂異或是指剛開始時,兩個夥伴塊都空閒,它們的夥伴位爲0:

  • 若是其中一塊被使用,異或後得1;

  • 若是另外一塊也被使用,異或後得0;

  • 若是前面一塊回收了異或後得1;

  • 若是另外一塊也回收了異或後得0。

img

如圖所示,位圖的某一位對應兩個互爲夥伴的塊,爲1表示其中一塊已經分配出去了,爲0表示兩塊都空閒或都已被使用。

夥伴中不管是分配/還是釋放,都只是相對的位圖進行異或操做。

分配內存時對位圖的是爲釋放過程服務,釋放過程根據位圖判斷夥伴是否存在:

  • 若是對相應位的異或操做,得1,說明之前爲0,兩塊都是busy,沒有夥伴能夠合併,

  • 若是異或操做得0,說明之前是1,說明只有一塊是busy,一塊是 idle(/free),busy的是自己,就進行合併

    並且,繼續按這種方式合併夥伴,直到不能合併爲止。

位圖的主要用途是在回收算法中指示是否能夠和夥伴塊合併,分配時只要搜索空閒鏈表就足夠了。但是,分配的同時還要對相應位異或一下,這是爲回收算法服務。

Buddy算法的分配原理:

假如系統需要4(2^2)個頁面大小的內存塊,該算法就到free_area[2]中查找,

如果free_area[2]鏈表中有空閒塊,就直接從中摘下並分配出去。

如果沒有,算法將順着數組向上查找free_area[3], 如果free_area[3]中有空閒塊,則將其從鏈表中摘下,分成等大小的兩部分,前四個頁面作爲一個塊插入free_area[2],後一部分4個頁面分配出去

free_area[3]中也沒有,就再向上查找,如果free_area[4]中有,就將這16(2^4)個頁面等分成兩份,前一半掛到free_area[3]的鏈表頭部,後一半的8個頁等分成兩等分,前一半掛free_area[2]的鏈表中,後一半分配出去。

假如free_area[4]也沒有,則重複上面的過程,直到到達free_area數組的最後,如果還沒有則放棄分配。

img

Buddy算法分配的例子

假設在初始階段,全是大小爲2^9大小的塊( MAX_ORDER爲10),序號依次爲0, 512, 1024等等,而且全部area的map位都爲0(實際上操做系統代碼要佔一部分空間,但這裏只是舉例),

如今要分配一個2^3大小的頁面塊,有如下動做:

  1. 從order爲3的area的空閒鏈表開始搜索,沒找到就向高一級area搜索,依次類推,按照假設條件,會一直搜索到order爲9的area,找到了序號爲0的2^9頁塊。
  2. 把序號爲0的 2^9 頁塊從order爲9的area的空閒鏈表中摘除, 並對該area的 mem_map第0位( 0 >>(1+9) )異或一下得1。

說明:

把序號爲n的頁面塊插入order爲i的area時,需要對該area的map位(n>>(1+order))跟1異或,更新map值

所以,這裏對area的map第0(序號右移(1+order)的結果)位跟1異或一

  1. 把序號爲0的 2^9 頁塊拆分成兩個序號分別爲0和256的2^8頁塊,前者放入order爲8的area的空閒鏈表中,並對該area的第0位( 0>>(1+8) )異或一下得1。

說明:

拆分後兩個快,第一個的偏移量還是0,第二個夥伴的偏移量序號爲 2^8 ,=256

  1. 把序號爲256的2^8頁塊拆分成兩個序號分別爲256和384的2^7頁塊,前者放入order爲7的area的空閒鏈表中,並對該area的第1位( 256>>(1+7) )異或一下得1。

說明:

拆分後兩個快,第一個的偏移量還是256,第二個夥伴的偏移量爲256 + 2^7(第二個偏移量128) =384

  1. 把序號爲384的2^7頁塊拆分成兩個序號分別爲384和448的2^6頁塊,前者放入order爲6的area的空閒鏈表中,並對該area的第3位( 384>>(1+6) )異或一下得1。
  2. 把序號爲448的2^6頁塊拆分成兩個序號分別爲448和480的2^5頁塊,前者放入order爲5的area的空閒鏈表中,並對該area的第7位( 448>>(1+5) )異或一下得1。
  3. 把序號爲480的2^5頁塊拆分成兩個序號分別爲480和496的2^4頁塊,前者放入order爲4的area的空閒鏈表中,並對該area的第15位( 480>>(1+4) )異或一下得1。
  4. 把序號爲496的2^4頁塊拆分成兩個序號分別爲496和504的2^3頁塊,前者放入order爲3的area的空閒鏈表中,並對該area的第31位( 496>>(1+3) )異或一下得1。
  5. 序號爲504的2^3頁塊就是所求的塊。

把序號爲n的頁面塊插入order爲i的area時,須要對該area的map位(n>>(1+order))跟1異或,更新map值

Buddy算法的釋放原理:

內存的釋放是分配的逆過程,也可以看作是夥伴的合併過程。

當釋放一個塊時,先在其對應的鏈表中考查是否有夥伴存在,如果沒有夥伴塊,就直接把要釋放的塊掛入鏈表頭;

如果有,則從鏈表中摘下夥伴,合併成一個大塊,然後繼續考察合併後的塊在更大一級鏈表中是否有夥伴存在,直到不能合併或者已經合併到了最大的塊。

img

整個過程中,位圖扮演了重要的角色,如圖2所示,位圖的某一位對應兩個互爲夥伴的塊,爲1表示其中一塊已經分配出去了,爲0表示兩塊都空閒。

夥伴中無論是分配還是釋放都只是相對的位圖進行異或操作。

分配內存時對位圖的是爲釋放過程服務,釋放過程根據位圖判斷夥伴是否存在,如果對相應位的異或操作得1,則沒有夥伴可以合併,如果異或操作得0,就進行合併,並且繼續按這種方式合併夥伴,直到不能合併爲止。

Buddy算法回收的例子

map下標 如何計算:頁塊的序號>>(1+order)

回收的頁面塊假設序號爲n,頁面的規模爲order,在map的第i位,i= n>>(1+order)

假設序號爲4,order規模爲0,在map的第2位,因爲 4>>1+0= 2

假設序號爲5,order規模爲0,在map的第2位,因爲 5>>1+0= 2

回收的頁面塊假設序號爲n,回收時,取得map中的的該area中的map[n>>(1+order)],將該map值跟1異或,若結果爲0(大家都idle),則序號n的頁面塊可跟其夥伴塊合併,不然不能合併。

回收的頁塊若能與夥伴塊合併,插入上一級的area中,則視合併塊爲之前已被佔用,

然後根據該area中其夥伴塊的狀態(已被回收或者被佔用)判斷其map位是1(夥伴忙,自己鹹)還是0(夥伴忙,自己忙),再讓map位跟1異或一下,結果爲0則再合併,結果爲1則不再合併。

假設回收序號爲4,order規模爲0的內存塊:

1.當回收序號爲4的1頁塊時,先找到order爲0(規模最小)的area,把該頁面塊加入到該area的空閒鏈表中,

而後判斷其夥伴塊(序號爲5的頁塊)的狀態,讀該area的map的第2(下標爲2 序號右移1+order的結果)位( 4>>(1+order) ),

假設夥伴塊被佔,則該位爲0(回收4塊前,四、5塊都忙),現跟1異或一下得1,並再也不向上合併。

2.當回收序號爲5的1頁塊時,同理,先找到order爲0的area,把該頁面塊加入到該area的空閒鏈表中,而後判斷其夥伴塊(序號爲4的1頁塊)的狀態,

讀該area的map的第2位(5>>(1+order) ),這時該位爲1(回收4塊前,四忙、5不忙),現跟1異或一下得0,並向上合併,

把序號爲4的1頁塊和序號爲5的1頁塊從該area的空閒鏈表中摘除,合併成序號爲4的2頁塊,並放到order爲1的area的空閒鏈表中。

同理,此時又要判斷合併後的塊的夥伴塊(序號爲6的2頁塊)的狀態,讀該area(order爲1的area,不是其它!)的map的第1位((4>>(1+order) ),

假設夥伴塊在此以前已被回收,則該位爲1,現異或一下得0,並向上合併,把序號爲4的2頁塊和序號爲6的2頁塊從order爲1的area的空閒鏈表中摘除,合併成序號爲4的4頁塊,並放到order爲2的area的空閒鏈表中。

而後再判斷其夥伴塊狀態,如此反覆。

夥伴(buddy)算法的問題

夥伴算法管理的是原始內存,比如最原始的物理內存(Java中的堆外),或者說一大塊連續的 堆內存。

申請時,夥伴算法會給程序分配一個較大的內存空間,即保證所有大塊內存都能得到滿足。

很明顯分配比需求還大的內存空間,會產生內部碎片。

所以夥伴算法雖然能夠完全避免外部碎片的產生,但這恰恰是以產生內部碎片爲代價的。

缺點:

雖然夥伴算法有效減少了外部碎片,但最小粒度還是 page(4K),因此有可能造成非常嚴重的內部碎片,最嚴重帶來 50% 的內存碎片。

Slab 算法

夥伴 算法 在小內存場景下並不適用,因爲每次都會分配一個 page,導致非常嚴重的內部碎片。

而 Slab 算法 則是在 夥伴算法 的基礎上對小內存分配場景做了專門的優化:

提供調整緩存機制 存儲內核對象,當內核需要再次分配內存時,基本上可以通過緩存中獲取。

Linux 底層採用 Slab 算法 進行小內存分配。

Linux採用夥伴系統解決外部碎片的問題,採用slab解決內部碎片的問題。

slab 分配器的基本原理:

按照預定固定的大小,將分配的內存分割成特定長度的塊,以完全解決內存碎片問題。

具體來說:

slab 分配器將分配的內存分割成各種尺寸的塊,並把相同尺寸的塊分成組。

另外分配到的內存用完之後,不會釋放,而是返回到對應的組,重複利用。

在這裏插入圖片描述

強調: Linux採用夥伴系統解決外部碎片的問題,採用slab解決內部碎片的問題。

jemalloc 算法

jemalloc 是基於 buddy+Slab 而來,比buddy+ Slab 更加複雜。

Slab 提升小內存分配場景下的速度和效率,jemalloc 通過 ArenaThread Cache 在多線程場景下也有出色的內存分配效率。

Arena分而治之思想的體現,與其讓一個人管理全部內存,到不如將任務派發給多個人,每個人獨立管理,互不干涉(線程競爭)。

Thread Cachetcmalloc 的核心思想,jemalloc 也把它借鑑過來。

通過Thread Cache機制, 每個線程有自己的內存管理器,分配在這個線程內完成,就不需要和其他線程競爭。

相關文檔

  • Facebook Engineering post: This article was written in 2011 and corresponds to jemalloc 2.1.0.
  • jemalloc(3) manual page: The manual page for the latest release fully describes the API and options supported by jemalloc, and includes a brief summary of its internals.

Netty 底層的內存分配是採用 jemalloc 算法思想。

Netty內存規格

Netty 對內存大小劃分爲:Tiny、Small、Normal 和 Huge 四類。

Huge 類型

Netty 默認向操作系統申請的內存大小爲 16MB,對於大於 16MB 的內存定義爲 Huge 類型,

Netty 對 Huge 類型的處理方式爲:

大型內存不做緩存、不做池化,直接以 Unpool 的形式分配內存,用完後回收

Tiny、Small、Normal類型

對於 16MB 及更小的內存,分類爲:Tiny、Small、Normal,也有對應的枚舉 SizeClass 進行描述。

// io.netty.buffer.PoolArena.SizeClass
enum SizeClass {
    Tiny,
    Small,
    Normal
}

在這裏插入圖片描述

不過 Netty 定義了一套更細粒度的內存分配單位:Chunk、Page、Subpage,方便內存的管理。

注意:爲了方便管理, Netty 在每個區域內又定義了更細粒度的內存分配單位,分別是 Chunk、Page 和 Subpage。

Chunk

Chunk 即上述提及的 Netty 向操作系統申請內存的單位,默認是 16MB。後續所有的內存分配也都是基於 Chunk 完成。

Chunk 是 Page 的集合。

一個 Chunk(16MB),由 2048 個 Page (8KB)組成。

netty 內存向系統或者JVM堆申請是大塊的內存,單位是chunk塊, 不是一點一點申請,而是一大塊一大塊的申請,然後再內部高效率的二次分配

一個chunk 的大小是16MB, 實際上每個chunk, 都以雙向鏈表的形式保存在一個chunkList 中,

而多個chunkList, 同樣也是雙向鏈表進行關聯的, 大概結構如下所示:

img

這樣, 在內存分配時, chunkList 中, 是根據chunk 的內存使用率歸到一個chunkList 中,

會根據百分比找到相應的chunkList, 在chunkList 中選擇一個chunk 進行內存分配。

Page

Page 是 Chunk 用於管理內存的基本單位。

Page 的默認大小爲 8KB,若欲申請 16KB,則需申請連續的兩塊空閒 Page。

img

  

SubPage

很多場景下, 爲緩衝區分配8KB 的內存也是一種浪費, 比如只需要分配2KB 的緩衝區, 如果使用8KB 會造成6KB 的浪費,

這種情況, netty 又會將page 切分成多個subpage,

SubPage 是 Page 下的管理單位。

每個subpage 大小要根據分配的緩衝區大小而指定, 比如要分配2KB 的內存, 就會將一個page 切分成4 個subpage, 每個subpage 的大小爲2KB, 如下圖:

img

對於底層應用,KB 級的內存已屬於大內存的範疇,更多的是 B 級的小內存,直接使用Page 進行內存的分配,無疑是非常浪費的。

所以對 Page 進行了切割劃分,劃分後的便是 SubPage,Tiny 和 Small 類型的內存使用的分配單位都是 SubPage。

切割劃分的算法原則是:

如首次申請 512 B 的內存,則先申請一塊 Page 內存,然後將 8 KB 的 Page 按照 512B 均分爲 16 塊,每一塊可以認爲是一個 SubPage,然後將第一塊 SubPage 內存地址返回給申請方。

同時下一次申請 512B 內存,則在 16 塊中分配第二塊。

其他非 512B 的內存申請,則另外申請一個 Page 進行均等切分和分配。

所以,對於 SubPage 沒有固定的大小,和 Tiny、Small 中某個具體大小的內存申請有關。

問題:爲什麼只有上面窮舉出來的內存大小,沒有19B、21B、3KB這樣規格?

是因爲 netty 中會把申請內存大小進行了標準化,向上取整到最接近的上圖中所列舉出的大小,以便於管理。

內存規格化

Netty 需要對用戶申請的內存大小進行 規格化 處理,目的是方便後續計算和內存分配。

通過內存規格化,將 31B 規格化爲 32B,將 15MB 規格化 16MB

Netty 和內存規格化涉及三個核心算法:

  • 一是找到離分配內存最近且大於分配內存的 2 值。獲取最接近 2^n 的數
  • 二是找到離分配內存最近且大於分配內存的16 倍的值。
  • 三是通過掩碼判斷是否大於某個數。

獲取最接近 2^n 的數(非常重要的算法)

對於small和normal ,規格化成獲取最接近 2^n 的數,便於計算和管理。

注意:Netty 通過大量的位運算來提升性能,但代碼的可讀性不太好

下面的算法,獲取最接近 2^n 的數(非常重要的算法),jvm源碼裏邊都用到了。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

上面一連串的位移計算,看得眼花繚亂。

這個算法的核心:是找到最接近 2的冪 且 大於用戶申請規模的值。這個算法很重要,很多核心源碼用到。

這個算法的思路: 把二進制 0100 0000 0001(1025) 變成 0111 1111 1111 +1 (2048)

記初始值爲 i,原始值的二進制最高位爲 1 的序號記爲 n,具體執行過程描述如下:

  • 先執行 i-1 操作,目的是解決當值爲 2時也能得到本身,而非 2。

  • 再執行 i |= i>>>1 運算,目的是賦值第 n-1 位的值爲 1。

    假設爲1的最高位n,也就是第 n 位位置確定爲 1,那麼無符號右移一位後第 n-1 也爲 1。

    再與原值進行 | 運算後更新第 n-1 的值。

    此時,原值的第 n、n-1 都確定爲 1,那麼接下來就可以無符號右移兩倍,讓n-2、n-3 賦值爲 1。

    由於 int 類型有 32 位,所以只需要進行 5 次運算,每次分別無符號右移1、2、4、8、16 就可讓小於 i 的所有位都賦值爲 1。

測試用例:獲取最接近 2^n 的數

由於源碼可讀性太差,代碼的可讀性不太好。特意寫了用例

在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

獲取最近的下一個16的倍數值

對於tiny類型,規格化其實思路很簡單:

先把低四位的值抹去(變成0),再加上 16 就得到了目標值。

在這裏插入圖片描述

測試用例執行結果

在這裏插入圖片描述

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

Netty 內存池分配整體思路

設計思路

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

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

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

線程首次分配/回收內存時,首先會爲其分配一個固定的Arena。

線程選擇Arena時使用round-robin的方式,也就是順序輪流選取。

每個線程各種保存Arena和緩存池信息,這樣可以減少競爭並提高訪問效率。

Arena將內存分爲很多Chunk進行管理,Chunk內部保存Page,以頁爲單位申請。

下圖展示了netty基於jemalloc實現的內存劃分邏輯

img

內存池結構

Netty中將內存池分爲五種不同的形態從大到小依次是:

  • PoolArena,

  • PoolChunkList,

  • PoolChunk,

  • PoolPage,

  • PoolSubPage.

首先,Netty 會向 操作系統 申請一整塊 連續內存,稱爲 chunk(數據塊),除非申請 Huge 級別大小的內存,

否則一般大小爲 16MB,使用 io.netty.buffer.PoolChunk 對象包裝。

具體長這樣子:

在這裏插入圖片描述

Netty將chunk進一步拆分爲多個page,每個 page 默認大小爲 8KB,

因此每個 chunk 包含 2048 個 page。爲了對小內存進行精細化管理,減少內存碎片,提高內存使用率,

Netty 對 page 進一步拆分若干 subpage,subpage 的大小是動態變化的,最小爲 16Byte。

  1. 計算: 當請求內存分配時,將所需要內存大小進行內存規格化,獲得規格化的內存請求值。根據值確認準確的樹的高度。
  2. 搜索: 在內存映射數據中,進行空閒內存序列的搜索。
  3. 標記: 分組被標記爲全部已使用,且通過循環更新其父節點標記信息。父節點的標記值取兩個子節點標記值的最小的一個。

當然,上面說的只是整體思路。還要分類型進行細化。

在這裏插入圖片描述

Huge 分配邏輯概述

Normal 級別分配的大小範圍是 [16M, 無限大)

大內存分配比其他類型的內存分配稍微簡單一點,操作的內存單元是 PoolChunk,它的容量大小是用戶申請的容量(可滿足內存對齊要求)。

Netty 對 Huge 對象的內存塊採用非池化管理策略,在每次請求分配內存時單獨創建特殊的非池化 PoolChunk 對象,當對象內存釋放時整個 PoolChunk 內存也會被釋放。

大內存的分配邏輯是在 io.netty.buffer.PoolArena#allocateHuge 完成。

Normal 分配邏輯

Normal 級別分配的大小範圍是 [4097B, 16M) 。

核心思想是將 PoolChunk 拆分成 2048 個 page ,這是 Normal 分配的最小單位。

每個 page 等大(pageSize=8KB),並在邏輯上通過一棵滿二叉樹管理這些 page 對象。

我們申請的內存本質是組合若干個 page 。Normal 的分配核心邏輯是在 PoolChunk#allocateRun(int) 完成。

Small 分配邏輯

Small 級別分配的大小範圍是 (496B, 4096B] 。

核心是把一個 page 拆分若干個 Subpage,PoolSubpage 就是這些若干個 Subpage 的化身,有效解決小內存場景造成內存碎片的問題。

一個 page 大小爲 8192B,有且只有四種大小: 512B、1024B、2048B 和 4096B,以 2 倍遞增。

當申請的內存大小在 496B~4096B 範圍內時,就能確定這四種中的一種。

當進行內存分配時,先在樹的最底層找到一個空閒的 page,拆分成若干個 subpage,並構造一個 PoolSubpage 進行管理。

選擇第一個 subpage 用於此次申請,標記爲已使用,並將 PoolSubpage 放置在 PoolSubpage[] smallSubpagePools 數組所對應的鏈表中。

等下次申請等大容量內存時就可從 PoolSubpage[] 直接分配從鏈表中分配內存。

Tiny 分配邏輯

Tiny 級別分配的大小範圍是 (0B, 496B] 。

分配邏輯與 Small 類似,

先找到空閒的 Page 然後將其拆分若干個 Subpage 並構造一個 PoolSubpage 對它們進行管理。

隨後選擇第一個 subpage 用於此次申請,並將對象 PoolSubpage 放置在 PoolSubpage[] tinySubpagePools 數組所對應的鏈表中。等待下次分配時使用。

區別在於如何定義若干個?

Tiny 給出的定義邏輯是獲取最接近 16*N 的且大於規格值的大小。

比如申請內存大小爲 31B,找到最接近的下一個 16*1 的倍數且大於 31 的值是 32

因此,就把 Page 拆分成 8192/32=256 個 subpage,這裏的若干個就是根據規格值確定的,它是可變的值。

PoolArena

上面講述了針對不同級別 Netty 是如何完成內存分配的。

arena是jemalloc中的概念,它是一個內存管理單元,線程在arena中去分配和釋放內存,

PoolArena的高併發設計

爲了減少線程成間的競爭,很自然會提供多個PoolArena。

和G1垃圾回收器、Redis分段鎖一樣,這裏用了分治模式,

系統正常會存在多個arena,每個線程會被綁定一個arena,PoolArena是線程共享的對象,每個線程只會綁定一個 PoolArena,線程和 PoolArena 是多對一的關係。

同一個arena可以被多個線程共享,arena和thread之間的關係如下圖

在這裏插入圖片描述

PoolArena 是進行池化內存分配的核心類,採用固定數量的多個 Arena 進行內存分配,默認與 CPU 核心數量有關,

當某個線程首次申請內存分配時,會通過輪詢(Round-Robin) 方式得到一個 PoolArena,在該線程的整個生命週期內只和這個 Arena 打交道,

PoolArena 是分治思想的體現,其目標是,解決在多線程場景下的高併發問題。

PoolArena的核心成員

PoolArena 提供 DirectArena 和 HeapArena 子類,這是因爲底層容器類型不同所以需要子類區分。但核心邏輯是在 PoolArena 完成的。

PoolArena 的數據結構大致(除去監測指標數據)可分爲兩大類:

  • 存儲 PoolChunk 的 6 個 PoolChunkList

  • 存儲 PoolSubpage 的 2 個數組。

PoolArena 構造器初始化也做了很多重要的工作,包含串聯 PoolChunkList 以及初始化 PoolSubpage[] 。

存儲 PoolChunk 的 6 個 PoolChunkList

在這裏插入圖片描述

q000q025q050q075q100 表示最低內存使用率。如下圖所示

在這裏插入圖片描述

任意 PoolChunkList 都有內存使用率的上下限:

  • minUsag

  • maxUsage。

如果使用率超過 maxUsage,那麼 PoolChunk 會從當前 PoolChunkList 移除,並移動到下一個PoolChunkList 。

同理,如果使用率小於 minUsage,那麼 PoolChunk 會從當前 PoolChunkList 移除,並移動到前一個PoolChunkList。

每個 PoolChunkList 的上下限都有交叉重疊的部分,爲什麼呢?

因爲 PoolChunk 需要在 PoolChunkList 不斷移動,如果臨界值恰好銜接的,則會導致 PoolChunk 在兩個 PoolChunkList 不斷移動,造成性能損耗。

PoolChunkList 適用於 Chunk 場景下的內存分配,PoolArena 初始化 6 個 PoolChunkList 並按上圖首尾相連,形成雙向鏈表,但是, q000 這個 PoolChunkList 是沒有前向節點

q000 這個 PoolChunkList 是沒有前向節點,爲什麼呢?

因爲當其餘 PoolChunkList 沒有合適的 PoolChunk 可以分配內存時,會創建一個新的 PoolChunk 放入 pInit 中,然後根據用戶申請內存大小分配內存。

而在 p000 中的 PoolChunk ,如果因爲內存歸還的原因,使用率下降到 0%,則不需要放入 pInit,而是直接執行銷燬方法,將整個內存塊的內存釋放掉。

這樣,內存池中的內存就有生成/銷燬等完成生命週期流程,避免了在沒有使用情況下還佔用內存。

存儲 PoolSubpage 的 2 個數組

PoolSubpage 是對某一個 page 的化身,page畢竟太粗,

如果申請1Byte的空間就分配一個頁是不是太浪費空間,對,這確實很浪費,

在Netty中Page還會被細化爲subpage,

用於專門處理小於8k的空間申請,那是subpage。

在這裏插入圖片描述

於是, Page 還可以按 elemSize 拆分成若干個 subpage,

在 PoolArena 使用 PoolSubpage[] 數組來存儲 PoolSubpage 對象,

兩個PoolSubpage[] 數組如下圖所示:

在這裏插入圖片描述

smallSupbagePools 數組

對於 Small類型的subpage, 它擁有四種不同大小的規格,因此 smallSupbagePools 的數組長度爲 4,

smallSubpagePools[0] 表示 elemSize=512B 的 PoolSubpage 對象的鏈表,

smallSubpagePols[1] 表示 elemSize=1024B 的 PoolSubpages 對象的鏈表。

smallSubpagePols[2] 表示 elemSize=2048B 的 PoolSubpages 對象的鏈表。

smallSubpagePols[3] 表示 elemSize=4096B 的 PoolSubpages 對象的鏈表。

tinySubpagePools 數組

tinySubpagePools 原理一樣,只不過劃分的粒度(步長)比較小,

tinySubpagePools 數組的元素劃分,不是以2的冪的步長劃分的,而是以倍數來的,以 16 的倍數遞增。

從16B-496B,總共可分爲 32 類,因此 tinySubpagePools 數組長度爲 32。

PoolSubpage[] 數組與HashMap的對比

這兩個PoolSubpage[] 數組用來存儲 PoolSubpage 對象且按 PoolSubpage#elemSize 確定索引的位置 index,最後將它們構造雙向鏈表。

每個PoolSubpage[] 數組都對應一組雙向鏈表。

每個PoolSubpage[] 數組下標所對應的 size 容量不一樣,按 PoolSubpage#elemSize 確定索引的位置 index

PoolSubpage數組的結構,非常類似於一個簡單的HashMap,簡單的HashMap集合的三個基本存儲概念

名稱 說明
table 存儲所有節點數據的數組
slot 哈希槽。即table[i]這個位置
bucket 哈希桶。table[i]上所有元素形成的表或數的集合

在這裏插入圖片描述

PoolSubpage#elemSize 可以理解爲hashmap的key,這是這裏不進行hash運算,而是根據elemSize 的規模去確定 slot 槽位

PoolSubpage[] 的一個元素PoolSubpage,可以理解爲hashmap的bucket,這是這裏不鏈表,而是雙向鏈表

PoolArena的具體實現

PoolArena是功能的門面,通過PoolArena提供接口供上層使用,屏蔽底層實現細節。

Netty默認會生成2×CPU個PoolArena跟IO線程數一致。

然後第一次使用的時候會找一個使用線程最少的PoolArena

      private <T> PoolArena<T> leastUsedArena(PoolArena<T>[] arenas) {
            if (arenas == null || arenas.length == 0) {
                return null;
            }
 
            PoolArena<T> minArena = arenas[0];
            for (int i = 1; i < arenas.length; i++) {
                PoolArena<T> arena = arenas[i];
                if (arena.numThreadCaches.get() < minArena.numThreadCaches.get()) {
                    minArena = arena;
                }
            }
 
            return minArena;
        }

現在我們看下PoolArena的屬性,比較多,

    //maxOrder默認是11
    private final int maxOrder;
    //內存頁的大小,默認是8k
    final int pageSize;
    //默認是13,表示的是8192等於2的13次方
    final int pageShifts;
   // 默認是16M
    final int chunkSize;
     //這個等於~(pageSize-1),用於判斷申請的內存是不是大於或者等於一個page
     //申請內存reqCapacity&subpageOverflowMask如果等於0那麼表示申請的內 
     //存小於一個page的大小,如果不等於0那麼表示申請的內存大於或者一個page的大小
    final int subpageOverflowMask;
   //它等於pageShift - 9,默認等4
    final int numSmallSubpagePools;
    final int directMemoryCacheAlignment;
    final int directMemoryCacheAlignmentMask;
     //tiny類型內存PoolSubpage數組,數組長度是32,從index=1開始使用
    private final PoolSubpage<T>[] tinySubpagePools;
     //small類型內存PoolSubpage數組,數組長度在默認情況下是4
    private final PoolSubpage<T>[] smallSubpagePools;
    //PoolChunkList代表鏈表中的節點,
    //每個PoolChunkList存放內存使用量在相同範圍內的chunks,
    //比如q075存放的是使用量達到了75%以上的chunk
    private final PoolChunkList<T> q050;
    private final PoolChunkList<T> q025;
    private final PoolChunkList<T> q000;
    private final PoolChunkList<T> qInit;
    private final PoolChunkList<T> q075;
    private final PoolChunkList<T> q100;

  private final List<PoolChunkListMetric> chunkListMetrics;
    
//下面都是一些記錄性質的屬性
    // Metrics for allocations and deallocations
    private long allocationsNormal;
    // We need to use the LongCounter here as this is not guarded via synchronized block.
    private final LongCounter allocationsTiny = PlatformDependent.newLongCounter();
    private final LongCounter allocationsSmall = PlatformDependent.newLongCounter();
    private final LongCounter allocationsHuge = PlatformDependent.newLongCounter();
    private final LongCounter activeBytesHuge = PlatformDependent.newLongCounter();

    private long deallocationsTiny;
    private long deallocationsSmall;
    private long deallocationsNormal;

    // We need to use the LongCounter here as this is not guarded via synchronized block.
    private final LongCounter deallocationsHuge = PlatformDependent.newLongCounter();

    // Number of thread caches backed by this arena.
    final AtomicInteger numThreadCaches = new AtomicInteger();



是由多個PoolChunkList和兩個SubPagePools(一個是tinySubPagePool,一個是smallSubPagePool)組成的。

看下tinySubpagePools和smallSubpagePools數組的初始化, 以tinySubpagePools爲例

在這裏插入圖片描述

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

構造一個 newSubpagePoolArray方法中,創建了一個PoolSubpage 對象數組,裏邊沒有初始化任何元素

接下來,是初始化每一個元素,或者說,初始化每一個slot槽位,

具體的做法是:每一個槽位構造出一個頭部對象,類型爲 PoolSubpage

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

PoolSubpage是雙向鏈表節點型的對象,默認head和next都指向自己

所以初始化後的SubpagePools長這樣

img

看下smallSubpagePools數組的初始化, 和tinySubpagePools類似,只是數組的大小不同

在這裏插入圖片描述

Netty的池化內存分配流程

在深入PoolSubpage之前,有必要先說下netty的內存分配流程實現。

netty向jvm或者堆外內存每次申請的內存以chunk爲基本單位,

每個chunk的默認大小是16M,在netty內部每個chunk又被分成若干個page,默認情況下每個page的大小爲8k,所以在默認情況下一個chunk包含2048個page

img

應用程序向netty申請內存的時候分成兩四情況:

1)如果申請的內存大於一個chunk的尺寸,規模爲huge,那麼netty就會直接向JVM或者操作系統申請相應大小的內存。

2)如果申請的內存小於chunk的尺寸,但是規模爲normal,默認情況下也就是小於16M,那麼netty就會以page爲單位去分配一個run系列的page給應用程序

比如申請10K的內存,那麼netty會選擇一個chunk中的2個page分配給應用程序,

如果申請的內存小於一個page的大小,那麼就直接分配一個page給應用程序

3)如果申請的內存小於normal的尺寸,但是規模爲small,則先去 smallSubpagePools 中查找,如果沒有,則找一個page劈成n個同等規模的Subpage,然後進行分配,剩餘的Subpage插入smallSubpagePools 具體的slot中。

4)如果申請的內存小於normal的尺寸,但是規模爲tiny,則先去 tinySubpagePools 中查找,如果沒有,則找一個page劈成n個同等規格的Subpage,然後進行分配,剩餘的Subpage插入tinySubpagePools 具體的slot中。

第2、3、4步中,如果沒有空閒的page,則申請一個chunk,分配成page後,再申請一個page

PoolChunk

Netty一次向系統申請16M的連續內存空間,這塊內存通過PoolChunk對象包裝,

爲了更細粒度的管理它,進一步細分成頁,默認一個頁8k(pageSize=8k),爲的把這16M內存分成了2048個頁。

頁作爲Netty的最基本的單位 ,所有的內存分配首先必須申請一塊空閒頁。

幾個比較重要的術語

在介紹PoolChunk代碼之前先介紹幾個比較重要的術語。

  • page:page是PoolChunk的分配的最小單位,默認的1個page的大小爲8kB
  • run:表示的是一個page的集合
  • handle:句柄,用於表示poolChunk中一塊內存的位置,大小,使用情況等信息,

page頁與page run

Page 是 Chunk 用於管理內存的基本單位。

Page 的默認大小爲 8KB,若欲申請 16KB,則需申請連續的兩塊空閒 Page。

img

如果內部分配的不只是8k,比如 8193個字節,規格化後,會變成 2個page,

這個兩個page組成一個序列,或者說一串,叫做page run。

handle:內存句柄

一個handle 是一個long佔8個字節,其由低位的4個字節和高位的4個字節組成,

toHandle(bitmapIdx);

在這裏插入圖片描述

memoryMapIdx:

低位的4個字節表示當前page(8K)在PoolChunk(16M)中memoryMap映射數組中的下標索引;

bitmapIdx:

高位的4個字節則表示當前需要分配的內存PoolSubPage在page 8KB內存中的位圖索引。

場景一:

對於大於8KB的內存分配,不涉及PoolSubPage結構,因而高位四個字節的位圖索引爲0, 而低位的4個字節page序列在chunk中的位置索引;

場景二:

對於低於8KB的內存分配,其會使用一個PoolSubPage來表示整個8KB內存,因而需要一個位圖索引來表示subpage在page中的位圖索引。

從形象上理解,這個是兩個俄羅斯套娃

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

PoolPage

Page的大小默認是8192字節,也可以設置系統變量io.netty.allocator.pageSize來改變頁的大小。

自定義頁大小有如下限制:

  • 1.必須大於4096字節;
  • 2.必須是2的整次數冪。

如果管理一個chunk的Page

一個chunk的Page需要通過某種數據結構跟算法管理起來。

大家可以設想一下,最簡單的方式:是採用數組或位圖管理

img

如上圖1表示已申請,0表示空閒。這樣申請一個Page的複雜度爲O(n)。

netty分配給應用程序的內存都是以page(默認8K)爲單位進行分配,同時根據申請量每次分配2^n個page,

換言之netty不可能一次分配3個page或者5個page,如果申請3x8192的內存默認netty會分配4個page,如果申請5x8192的內存,默認netty會分配8個page。

一個chunk默認的大小是16M,在PoolChunk中這16M的內存在邏輯上被劃分成2048個page。

爲了高效的實現page的分配,netty使用一棵樹完全二叉樹來管理這16M的內存,這棵完全二叉樹的深度爲12,

從第0層開始,第11層有2048個葉子節點,每個葉子節點代表一個page,

img

在這裏插入圖片描述

注意:這顆完全二叉樹中的非葉子節代表的內存容量等於其子節點的容量和

在這裏插入圖片描述

這裏,構造了一棵 滿二叉樹(Compelte balanced binary) 從而加快搜索速度。棵樹看起來看是這樣的(括號中的表示每個節點的大小)

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

depth=maxOrder 時,節點是由 page 組成。

fbt樹memoryMap 和 depthMap

PoolChunk 用兩個成員 memoryMap(內存映射) 和 depthMap (深度映射) 就是代表的這顆二叉樹,是兩顆fbt樹,滿二叉樹

默認 memoryMap和depthMap數組長度爲4096

final class PoolChunk<T> implements PoolChunkMetric {

    private static final int INTEGER_SIZE_MINUS_ONE = Integer.SIZE - 1;

    final PoolArena<T> arena;
    final T memory;
    final boolean unpooled;
    final int offset;
    private final byte[] memoryMap;  //表示完全二叉樹,共有4096個節點
    private final byte[] depthMap; //表示節點的層高,共有4096個
    ...
    

一個完全二叉樹可以用大小爲4096的數組表示,數組元素的值含義爲:

private final byte[] memoryMap; //表示完全二叉樹,共有4096個
private final byte[] depthMap;  //表示節點的層高,共有4096個

但是這棵完全二叉樹只有4095個節點,netty選擇從數組的index=1開始去表示這棵完全二叉樹。

那麼memoryMap和depthMap有什麼區別呢?

  • memoryMap[i] = depthMap[i]= depth: 表示該節點下面的所有葉子節點都可用,這是初始狀態
  • memoryMap[i] = depthMap[i] + 1:表示該節點下面有一部分葉子節點被使用,但還有一部分葉子節點可用
  • memoryMap[i] = maxOrder + 1 = 12:表示該節點下面的所有葉子節點不可用

memoryMap 是會隨着page的分配和回收動態的修改每個節點的值,

depthMap中的元素一旦初始化之後就不會被修改了,將來需要查看某個節點初始狀態的值( 節點的depth深度)就可以通過depthMap查找。

滿二叉樹的初始化

我們看下memoryMap和depthMap初始化代碼

在這裏插入圖片描述

Netty採用完全二叉樹進行管理,樹中每個葉子節點表示一個Page,即樹高爲12,

在這裏插入圖片描述

特點:

中間節點表示頁節點的持有者,葉子節點爲 page,page 處於第11層,樹的層數從0開始。

高度爲 11 的節點(2048 - 4095)即爲 Page 節點,代表 8 KB ;

高度爲 10 的節點(1024 - 2047)均擁有 2 個 Page 節點,代表16 KB;

高度爲 1 的節點(2、3)均擁有 1024 個 Page 節點,代表 8 MB;

高度爲 0 的節點(1)擁有 2048 個 Page 節點,代表 16 MB,即一個滿 Chunk 的大小。

memoryMap搜索算法

memoryMap 類型是 byte[],用來記錄樹的分配情況。

id索引的元素 初始值,爲對應節點所在的樹的深度。搜索空閒page時,用id索引,在 memoryMap 滿二叉樹上搜索:

  • memoryMap[id] = depth_of_id => 代表該節點: 空閒/完全未分配。

    這裏,可以把depth_of_id 理解爲規模,可以分配出 >= depth_of_id 級別的內存,

    比如 id爲10, depth_of_id 爲10,可以分配出 10(16k)、11(8k) 級規模的內存

  • memoryMap[id] > depth_of_id => 代表該節點: 至少有一個子節點已經被分配了,但其他子節點仍然可分配。

    這裏,可以把depth_of_id 理解爲規模,可以分配出 > depth_of_id 級別的內存,

    比如 id爲10, depth_of_id 爲11 ,可以分配出 11(8k) 級規模的內存, 10(16k)級的分配不了了,已經有一個子節點已經分配完了

  • memoryMap[id] = maxOrder + 1 => 代表該節點: 當前節點已經完成分配了,即當前節點處於不可用狀態。

    比如 id爲10, depth_of_id 爲12,不可以分配出 10(16k)、11(8k) 級規模的內存,兩個子樹,都分配完了

規格化處理

所有用戶申請內存的大小都按PoolArena#normalizecapacity 方法法進行規格化處理

這確保了當我們請求大小 >= pageSize 的內存段時,規格化容量等於下一個最近的2的次冪。

頁的申請跟釋放

有了上面的數據結構,那麼頁的申請跟釋放就非常簡單了,

頁的申請,只需要從根節點一路遍歷找到可用的節點即可,複雜度爲O(lgn)。

代碼爲:

#PoolChunk
  //根據申請空間大小,選擇申請方法
  long allocate(int normCapacity) {
        if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
            return allocateRun(normCapacity); //大於1頁
        } else {
            return allocateSubpage(normCapacity);
        }
    }

allocate方法是PoolChunk真正實現page分配的地方,PoolChunk對於page的分配分成兩種情況:

  • 單次內存申請量標準化之後大於或者等於一個page,對應的分配方法是allocateRun

  • 單次內存申請量標準化之後小於一個page,對應的分配方法是allocateSubpage

兩大場景,具體如下:

  • 場景一:大於或者等於一個page,

對應的分配方法是allocateRun,返回的就是memoryMap 內存映射的索引值,也就是數組的下標,

  • 場景一:小於一個page

對應的分配方法是allocateSubpage, 之後會返回一個handle 句柄,handle 是一個64位的標記,記載着很多內存地址信息,

img

allocateRun分配page run

接下來看看allocateRun(頁面系列):allocateRun是分配多個page操作,找到最接近當前需要分配的size的內存塊

allocateRun首先計算 內存塊在 fbt上的深度

舉個例子,當分配的內存大於2^13B(8196B)時,可以通過內存值計算對應的深度:int d=11-(log2(normCapacity)-13)。

其中,normCapacity爲分配的內存大小,它大於或等於8KB且爲8KB的整數倍。

例如,申請大小爲16KB的內存,d=11-(14-13)=10,表示只能在小於或等於10層上尋找還未被分配的節點。

在這裏插入圖片描述

深度d到底怎麼算的?

int d = maxOrder - (log2(normCapacity) - pageShifts)

首先我們看下(log2(normCapacity) - pageShifts),在這裏的normCapacity至少是8k,取了log2後至少是13.

兩個例子:

如果 normCapacity是8k, 取了log2後剛好是13,那麼(log2(normCapacity) - pageShifts) 結果就是0, 於是 d=maxOrder=11。意思就是說定位要了深度11,也就是最大深度

如果是16k呢?那結果就是11-(14-13)=10,深度是10。

計算深度時,首先要計算內存塊size的對數,這裏用位運算,

來計算 2的對數,效率比數學運算高多了

在這裏插入圖片描述

再通過allocateNode找到一個最合適的節點: 沿着平衡二叉樹,從根節點開始,尋找一個合適的節點。

如果無法分配,返回-1。

allocateNode

allocateNode就是要在平衡二叉樹中要找到一個可以滿足normCapacity的節點,這裏邊有個要點:

內存映射 memoryMap[id]存放的是,該id能分配的最小深度,這個最小深度,實際上代表的能分配的最大容量

Netty採用了前序遍歷算法:

  • 從根節點開始,第二層爲左右節點,先看左邊節點內存是否夠分配,

  • 若不夠,則選擇其兄弟節點(右節點);

  • 若當前左節點夠分配,則需要繼續向下一層層地查找,直到找到層級最接近d(分配的內存在二叉樹中對應的層級)的節點。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

測試用例:allocateNode的執行過程

allocateNode找到一個可以滿足normCapacity的節點,測試用例的結果如下

在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

updateParentsAlloc更新父節點的深度值

一個子節點分配之後,同時要更新父節點,一直到根節點,‘

具體的修改邏輯爲:

不斷迭代,找父節點,

將父節點的深度值就改成子節點裏最小的那個,

在這裏插入圖片描述

updateParentsAlloc更新父節點的深度值,測試用例的結果如下

在這裏插入圖片描述

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

通過memoryMap的index找偏移量

內存映射memoryMap的下標的作用:

通過內存映射memoryMap的下標可以找到其偏移量。然後初始化PooledBytebuf實例

在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述

彈性伸縮

前面的算法原理部分介紹了Netty如何實現內存塊的申請和釋放,單個chunk比較容量有限,

如何管理多個chunk,構建成能夠彈性伸縮內存池?

PoolChunk管理

爲了解決單個PoolChunk容量有限的問題,Netty將多個PoolChunk組成鏈表一起管理,

然後用PoolChunkList對象持有鏈表的head, 將所有PoolChunk組成一個鏈表的話,進行遍歷查找管理效率較低,

因此Netty設計了PoolArena對象(arena中文是舞臺、場所),實現對多個PoolChunkList、PoolSubpage的分組管理調度,線程安全控制、提供內存分配、釋放的服務

PoolChunkList

上面討論了PoolChunk的內存分配算法,但是PoolChunk只有16M,

這遠遠不夠用,所以會很很多很多PoolChunk,這些PoolChunk組成一個鏈表,然後用PoolChunkList持有這個鏈表.

#PoolChunkList
private PoolChunk<T> head;
 
#PoolChunk
PoolChunk<T> prev;
PoolChunk<T> next;

img

它有6個PoolChunkList,所以將PoolChunk按內存使用率分類組成6個PoolChunkList,

同時每個PoolChunkList還把各自串起來,形成一個PoolChunkList鏈表。

在這裏插入圖片描述

注意:

  • q000沒有前驅節點,所以一旦PoolChunk使用率爲0,就從PoolChunkList中移除,釋放掉這部分空間,避免在高峯的時候申請過內存一直緩存到池中。
  • 各個PoolChunkList的區間是交叉的、重疊的,這是故意的,因爲如果重疊,而是介於一個臨界值的話,PoolChunk會在前後PoolChunkList不停的來回移動。

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

// 容納使用率 (0,25%) 的PoolChunk
private final PoolChunkList qInit;
// [1%,50%)
private final PoolChunkList q000;
// [25%, 75%)
private final PoolChunkList q025;
// [50%, 100%)
private final PoolChunkList q050;
// [75%, 100%)
private final PoolChunkList q075;
// 100%
private final PoolChunkList q100;

6個PoolChunkList對象組成雙向鏈表,

當PoolChunk內存分配、釋放,導致使用率變化,需要判斷PoolChunk是否超過所在PoolChunkList的限定使用率範圍,

如果超出了,需要沿着6個PoolChunkList的雙向鏈表找到新的合適PoolChunkList,成爲新的head

同樣的,當新建PoolChunk並分配完內存,該PoolChunk也需要按照上面邏輯放入合適的PoolChunkList中

分配歸一化內存normCapacity(大小範圍在[pageSize, chunkSize]) 具體處理如下:

按順序依次訪問q050、q025、q000、qInit、q075,遍歷PoolChunkList內PoolChunk鏈表判斷是否有PoolChunk能分配內存

如果上面5個PoolChunkList有任意一個PoolChunk內存分配成功,PoolChunk使用率發生變更,重新檢查並放入合適的PoolChunkList中,結束

否則新建一個PoolChunk,分配內存,放入合適的PoolChunkList中(PoolChunkList擴容)

note:可以看到分配內存依次優先在q050 - q025 - q000 - qInit - q075的PoolChunkList的內分配,

這樣做的好處是,使分配後各個區間內存使用率更多處於[75,100)的區間範圍內,提高PoolChunk內存使用率的同時也兼顧效率,減少在PoolChunkList中PoolChunk的遍歷

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

支撐百萬級併發,Netty如何實現高性能內存管理

支撐百萬級併發,Netty如何實現高性能內存管理

PoolChunkList的額定使用率區間存在交叉,這樣設計是因爲如果基於一個臨界值的話,當PoolChunk內存申請釋放後的內存使用率在臨界值上下徘徊的話,會導致在PoolChunkList鏈表前後來回移動

PoolSubpage管理

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

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

相同規格大小(elemSize)的PoolSubpage組成鏈表,不同規格的PoolSubpage鏈表的head則分別保存在tinySubpagePools 或者 smallSubpagePools數組中,如下圖:

支撐百萬級併發,Netty如何實現高性能內存管理

支撐百萬級併發,Netty如何實現高性能內存管理

當需要分配小內存對象到PoolSubpage中時,根據歸一化後的大小,計算出需要訪問的PoolSubpage鏈表在tinySubpagePools和smallSubpagePools數組的下標,訪問鏈表中的PoolSubpage的申請內存分配,

如果訪問到的PoolSubpage鏈表節點數爲0,則創建新的PoolSubpage分配內存然後加入鏈表

PoolSubpage鏈表存儲的PoolSubpage都是已分配部分內存,當內存全部分配完或者內存全部釋放完的PoolSubpage會移出鏈表,減少不必要的鏈表節點;

當PoolSubpage內存全部分配完後再釋放部分內存,會重新將加入鏈表

PoolArean內存池彈性伸縮可用下圖總結:

支撐百萬級併發,Netty如何實現高性能內存管理

PoolChunkList源碼分析

下面是PoolChunkList中屬性定義


    
  //PoolChunkList所屬的arena
  private final PoolArena<T> arena; 

 private final int minUsage; //最小使用率
 private final int maxUsage; //最大使用率
 private final int maxCapacity;    //屬於本PoolChunkList管理的chunk最大可被申請的內存量

 //同類chunk鏈表頭指針
    private PoolChunk<T> head;
    private final int freeMinThreshold;
    private final int freeMaxThreshold;

// This is only update once when create the linked like list of PoolChunkList in PoolArena constructor.
// 指向PoolChunkList鏈表中的前一個節點
private PoolChunkList<T> prevList;
private final PoolChunkList<T> nextList;

那麼netty到底是如何給不同的chunk進行分類的呢?

其實是通過設定兩個參數實現的,minUsage和maxUsage,

在netty中chunk按照剩餘可用內存的大小被分成了6類,下面是這六類在PoolArena中的定義

//[100,) 每個PoolChunk使用率100%
q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, chunkSize);
//[75,100) 每個PoolChunk使用率75-100%
q075 = new PoolChunkList<T>(this, q100, 75, 100, chunkSize);
//[50,100)
q050 = new PoolChunkList<T>(this, q075, 50, 100, chunkSize);
//[25,75)
q025 = new PoolChunkList<T>(this, q050, 25, 75, chunkSize);
//[1,50)
q000 = new PoolChunkList<T>(this, q025, 1, 50, chunkSize);
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, chunkSize);

img

同時申請空間,使用哪一個PoolChunkList也是有先後順序的,

先從 q050 使用率的列表開始

#PoolArena
allocateNormal(...){
  if (q050.allocate(...) || q025.allocate(...) ||
            q000.allocate(...) || qInit.allocate(...) ||
            q075.allocate(...)) {
            return;
        }
  PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
  ...
  qInit.add(c);
}

如果Chunk的使用率超過設置的maxUsage,則移到下一個PoolChunkList中

#PoolChunkList
  boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
        if (head == null || normCapacity > maxCapacity) {
            return false;
        }
        for (PoolChunk<T> cur = head;;) {
            long handle = cur.allocate(normCapacity);
            if (handle < 0) {
                cur = cur.next;
                if (cur == null) {
                    return false;
                }
            } else {
                cur.initBuf(buf, handle, reqCapacity);
                if (cur.usage() >= maxUsage) {
                    remove(cur); 
                    nextList.add(cur); //移到下一個PoolChunkList中
                }
                return true;
            }
        }
    }
 

freeMinThreshold、freeMaxThreshold兩個屬性

PoolChunk在使用過程中,還會受到freeMinThreshold、freeMaxThreshold兩個屬性的影響。

freeMinThreshold和freeMaxThreshold會分別根據maxUsage和minUsage計算出來,公式如下:

freeMinThreshold = (maxUsage == 100) ? 0 :
    (int) (chunkSize * (100.0 - maxUsage + 0.99999999) / 100L);

freeMaxThreshold = (minUsage == 100) ? 0 :
    (int) (chunkSize * (100.0 - minUsage + 0.99999999) / 100L);

在arena中minUsage和maxUsage被設置了不同大小的值

我們就拿q025來舉例子看下,

q025對應的PoolChunkList,

minUsage = 25,maxUsage = 75,

根據這兩個值可以算出:

  • freeMinThreshold約定於4.16M
  • freeMaxThreshold約等於12.16M。

PoolChunk在使用過程中是會結合freeMinThreshold與 freeMaxThreshold動態變化的:

  • 當一個chunk分配出去一些內存後,如果這個chunk在這次分配之後剩餘的可用內存小於freeMinThreshold,那麼這個chunk就不再屬於這個chunkList管理了,那麼他就會被沿着PoolChunkList鏈表向下繼續查找適合存放它的PoolChunkList。

  • 當向一個chunk中釋放內存後,如果釋放之後這個chunk的可用內存大於freeMaxThreshold,那麼就需要沿着PoolChunkList鏈表向頭部方向去尋找適合管理這個chunk的PoolChunkList。

下圖給出了不同PoolChunkList管理的chunk的內存範圍

img

最終,PoolChunk會在不同PoolChunkList中變化。

這樣設計的目的:

考慮到隨着內存的申請與釋放,PoolChunk的內存碎片也會相應的升高,

使用率越高的PoolChunk, 其申請一塊連續空間(分配一塊連續的內存空間)失敗的概率也會大大的提高。

PoolSubpage

頁還要分爲子頁,爲啥呢?

由於一個頁(page)的大小就達到了10^13(8192字節),通常, 一次申請分配內存,沒有這麼大,可能很小。

當我們申請的內存小於一個chunk的大小,netty會以page爲單位返回結果給申請者,一個page默認是8K,

如果我們申請的內存量是32B,netty返回了一個page,這不是很浪費嗎,

對你,這確實很浪費,

於是,Netty將頁(page)劃分成更小的片段——SubPage

實際上,除了小對象直接分配一個page會造成浪費之外,小對象在page中進行平衡樹的標記又額外消耗更多空間,

因此Netty的實現是:先PoolChunk中申請空閒page,同一個page分爲相同大小規格的小內存進行存儲

支撐百萬級併發,Netty如何實現高性能內存管理

小對象內存管理

當請求對象的大小reqCapacity = 496,規格化計算後方式是向上取最近的16的倍數,

例如15規整爲15、40規整爲48、490規整爲496,

規格化後的大小(normalizedCapacity)小於pageSize的小對象可分爲2類:

  • 微型對象(tiny):規格化爲16的整倍數,如16、32、48、…、496,一共31種規格

  • 小型對象(small):規格化爲2的冪的,有512、1024、2048、4096,一共4種規格

有兩種類型的PoolSubpage:

1)申請內存小於512的tiny類型的PoolSubpage

2)申請內存在512到4096之間的small類型的PoolSubpage

所以,Subpage按大小分有兩大類,36種情況:

  1. Tiny:小於512B的情況,最小空間爲16,對齊大小爲16,區間爲[16,512),所以共有32種情況。

    簡單的說:Tiny類型的內存,分配規格按照 16的倍數 關係增長

  2. Small:大於等於512的情況,總共有四種,512,1024,2048,4096。

​ 簡單的說:Small類型的內存,分配規格按照 2的冪次方 關係增長

tinySubpagePools

tiny是按照16的倍數分塊,最大分到496,tinySubpagePools數組長度是32,

其中index=0的位置是不使用的,

數組的第一個元素指向的是內存被按照以16B爲基本單位進行劃分的pages,第32個元素指向的是內存被按照以496B爲基本單位進行劃分的pages。

img

smallSubpagePools

small類型從512開始後面的數是前面的2倍直到4096,所以smallSubpagePools的大小是4,

第1個元素指向的是內存被按照以512B爲基本單位進行劃分的pages,

第2個元素指向的是內存被按照以1024B爲基本單位進行劃分的pages,

第3個元素指向的是內存被按照以2048B爲基本單位進行劃分的pages,

最後一個元素指向的是內存被按照以4096B爲基本單位進行劃分的pages

img

問題: 規格化後,是不是存在內存的浪費呢?

例如1025byte規格化爲2048byte, 浪費了 1023個byte

8193byte規格化爲16384byte,8191個byte

這樣是不是造成了一些浪費?

可以理解爲是一種取捨,通過規格化處理,使池化內存分配大小規格化,大大方便內存申請和內存、內存複用,提高效率

PoolSubpage的作用:

對申請到的page按照實際申請量的大小,對這個page再進行分割,從而做到內存的合理利用和減少內存碎片化。

PoolSubpage的屬性

PoolSubpage是由PoolChunk的page生成的,page可以生成多種PoolSubpage ,

但是,一 個 page 只 能 生 成 其 中 一 種 PoolSubpage 。

這些page用PoolSubpage對象進行封裝,PoolSubpage內部有記錄這些小內存的重要成員:

  • 內存規格大小(elemSize)、
  • 可用內存數量(numAvail)和
  • 各個小內存的使用情況,通過long[]類型的bitmap相應bit值0或1,來記錄內存是否已使用

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

#PoolSubpage(數據結構)
    
    final PoolChunk<T> chunk;   //對應的chunk
    private final int memoryMapIdx; //chunk中那一頁,頁的大小,肯定大於等於4096,默認爲 8k
    private final int pageSize; //subpage頁大小
    private final long[] bitmap; //位圖,使用bitmap來記錄每個內存單元的使用情況。

    int elemSize; //單元大小
    private int maxNumElems; //總共有多少個單位
    private int bitmapLength; //位圖大小,maxNumElems >>> 6,一個long有64bit
    private int nextAvail; //下一個可用的單位
    private int numAvail; //還有多少個可用單位;

elemSize單元大小

PoolSubpage 會被分成n個小的element單元:

  • 單元的大小爲 elementSize,
  • n爲pageSize/elementSize個

bitmap單元位圖

這裏bitmap是個位圖,使用bitmap來記錄每個內存單元element的使用情況.

位圖的每一個位置,0表示element可用,1表示element不可用.

相關的索引

nextAvail 記錄下一個可用內存單元的位置,初始狀態爲0,

numAvail 記錄可用的內存塊的數量,還有多少個可用單位;

PoolSubpage的分配步驟和源碼分析

平衡二叉樹第十一層的葉子節點最小也是8KB,那比如要分配128B的緩存,直接分給8KB顯然是不合適的,

Tiny是小於512Byte,Small介於512B~8KB,Tiny和Small統稱Subpage,Subpage的內存分配,包含Tiny和Small的內存分配,

所以 ,Subpage的內存分配應該是整個netty最爲複雜的部分了。

在這裏插入圖片描述

PoolSubpage將chunk中的一個page再次劃分,分成相同大小的N份,這裏暫且叫Element,經過對每個Element的標記與清理標記來進行內存的分配與釋放。

subpage分配的整體流程

img

步驟:

  • 在PoolChunk的二叉樹上找到匹配的節點,由於小於8KB,因此只匹配一個page節點,

  • 然後按照申請規模進行切割, 切割之後創建 subpage,

  • 創建完成後,分配一個 element,然後返回其 handle , 兩個int(32位)的索引 bitmapIdx 和 memoryMapIdx,合併成一個long類型

以分配128B爲例, 先從平衡二叉樹的第11層, 選一個未分配的葉子節點Page (大小爲8KB),

假設,選中的爲第一個page,memoryMap[2048], 對該Page進行切割,假如要分配128B,整體會切割爲64塊

因爲:8192/128=64

PoolSubpage的構造

inal class PoolSubpage<T> {
    private final int memoryMapIdx; // 當前page在chunk中的id
    private final int runOffset;    // 當前page在chunk.memory的偏移量
    private final int pageSize;	    // page大小
    private final long[] bitmap;    // 這個bitmap的實現和BitSet相同,經過對每個二進制位的標記來修改一段內存的佔用狀態
	
    PoolSubpage<T> prev;	    // 前一個節點,這裏要配合PoolArena看,後面再說
    PoolSubpage<T> next;

    boolean doNotDestroy;	    // 表示該page在使用中,不能被清除
    int elemSize;		        // 該page切分後每一段的大小
    private int maxNumElems;	    // 該page包含的段數量
    private int bitmapLength;	    // bitmap須要用到的長度
    private int nextAvail;	    // 下一個可用的位置
    private int numAvail;	    // 可用的段數量
	
    PoolSubpage(PoolChunk<T> chunk, int memoryMapIdx, int runOffset, int pageSize, int elemSize) {
        this.chunk = chunk;
        this.memoryMapIdx = memoryMapIdx;
        this.runOffset = runOffset;
        this.pageSize = pageSize;
	    // 這裏爲何是16,64兩個數字呢,
	    // elemSize是通過normCapacity處理的數字,最小值爲16;
        // 因此一個page最多可能被分成pageSize/16段內存,而一個long有64個位,固能夠表示64個內存段的狀態;
        // 所以最多須要pageSize/16/64個long ,才能保證全部段的狀態均可以管理
        bitmap = new long[pageSize >>> 10]; // pageSize / 16(2^4) / 64  (2^6)
        init(elemSize);
    }

這個構造方法只是做了一些簡單的屬性初始化工作。

難點的地方,是初始bitmap,

bitmap用bit位來記錄每個subpage的使用情況,每個bit對應一個subpage,0表示subpage空閒,1表示subpage已經被分配出去。

一個subpage的大小是elemSize,最小的elemSize=16,

那麼一個page最多可分割成subpage的數量maxSubpageCount=pageSize/16=pageSize >> 4。

bitmap是個long型的數字,每個long數據有64位,因此bitmap的最大長度只需要maxBitmapLength = maxSubpageCount / 64 = pageSize / 16 / 64

所以,bitmap= new long[pageSize >>> 10] 就夠用了。

PoolSubpage的初始化

init方法的作用是根據elemSize計算出有效的bitmap長度bitmapLength,

然後把bitmapLength範圍內存的bit值都初始化爲0,

將本身加入到chunk.arena的對應規模的slot的列表中。

在這裏插入圖片描述在這裏插入圖片描述

chunk在分配page時,若是是8K如下的段則交給subpage管理,

然而chunk並無將subpage暴露給外部,subpage只好自謀生路,在初始化或重新分配時將本身加入到chunk.arena的pool中,

經過arena進行後續的管理(包括複用subpage上的其餘element)

PoolChunk#allocateSubpage分配subpage

在這裏插入圖片描述
在這裏插入圖片描述

  1. PoolSubpage head = arena.findSubpagePoolHead(normCapacity);

    找到當前規格內存normCapacity在subpagePools數組中索引,進而獲取該索引內head節點。

    例如:normCapacity=32屬於tiny規格,32 / 16 = 2,即取tinySubpagePools[2]中head節點

  2. int id = allocateNode(d); 進行page分配,上面已經分析過

  3. 獲取PoolChunk中subpages數組中subpage(一般情況下爲null),創建一個subpage並放到subpages中

  4. subpage.allocate();

    在subpage上進行分配

PoolSubpage#allocate分配Element

下面看看subpage是如何進行內部的內存分配的:

在這裏插入圖片描述

final int bitmapIdx = getNextAvail();

找到當前page中可分配內存段的bitmapIdx

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

通過bitmapIdx定位到具體分配了哪個段,並把代表該段的索引置爲1表示不可用,

bitmapIdx 32位int類型高26位表示在bitmap數組中位置索引,低6位表示64位long類型內位數索引(2^6 = 64);

toHandle(bitmapIdx);

在這裏插入圖片描述

把兩個int(32位)的索引 bitmapIdx 和 memoryMapIdx,合併成一個long類型返回

可以想一下如何把兩個不同類型索引或兩個int類型值同時返回?

netty這裏把兩個int(32位)的索引 bitmapIdx 和 memoryMapIdx,合併成一個long類型,

加 0x4000000000000000L 是爲了讓高32位不全是0

getNextAvail

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

  1. bitmap: 8個long類型組成的位圖數組;bitmapLength:bitmap的有效長度,不一定是8,例如16B時需要8個long,bitmapLength=8;32B時只需要4個long,bitmapLength=4

  2. 遍歷bitmap中每一個long類型,判斷long是不是全1,不是的話就說明該long類型表示的位圖中還有未分配的段

  3. 有可用段時通過findNextAvail0(i, bits);找到索引直接返回

PoolSubpage#free



    // 釋放指定element
    boolean free(int bitmapIdx) {
        if (elemSize == 0) {
            return true;
        }
        // 下面這幾句轉換成咱們常見的BitSet,其實就是bitSet.set(q, false)
        int q = bitmapIdx >>> 6;
        int r = bitmapIdx & 63;
        assert (bitmap[q] >>> r & 1) != 0;
        bitmap[q] ^= 1L << r;
        // 將這個index設置爲可用, 下次分配時會直接分配這個位置的內存
        setNextAvail(bitmapIdx);
        // numAvail=0說明以前已經從arena的pool中移除了,如今變回可用,則再次交給arena管理
        if (numAvail ++ == 0) {
            addToPool();
            return true;
        }

        if (numAvail != maxNumElems) {
            return true;
        } else {
            // 注意這裏的特殊處理,若是arena的pool中沒有可用的subpage,則保留,不然將其從pool中移除。
            // 這樣儘量的保證arena分配小內存時能直接從pool中取,而不用再到chunk中去獲取。
            // Subpage not in use (numAvail == maxNumElems)
            if (prev == next) {
                // Do not remove if this subpage is the only one left in the pool.
                return true;
            }

            // Remove this subpage from the pool if there are other subpages left in the pool.
            doNotDestroy = false;
            removeFromPool();
            return false;
        }
    }

handle:bitmapIdx 和 memoryMapIdx

一個handle 是一個long佔8個字節,其由低位的4個字節和高位的4個字節組成,

toHandle(bitmapIdx);

在這裏插入圖片描述

memoryMapIdx:

低位的4個字節表示當前page(8K)在PoolChunk(16M)中memoryMap映射數組中的下標索引;

bitmapIdx:

高位的4個字節則表示當前需要分配的內存PoolSubPage在page 8KB內存中的位圖索引。

場景一:

對於大於8KB的內存分配,不涉及PoolSubPage結構,因而高位四個字節的位圖索引爲0, 而低位的4個字節page序列在chunk中的位置索引;

場景二:

對於低於8KB的內存分配,其會使用一個PoolSubPage來表示整個8KB內存,因而需要一個位圖索引來表示subpage在page中的位圖索引。

從形象上理解,這個是兩個俄羅斯套娃

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

PoolArena大管家/管理器

PoolArena是內存管理的大管家/管理器。

它內部有一個PoolChunkList組成的鏈表(上文已經介紹過了,鏈表是按PoolChunkList所管理的使用率劃分)。

此外,它還有兩個PoolSubpage的數組,PoolSubpage[] tinySubpagePools 和 PoolSubpage[] smallSubpagePools。

默認情況下,tinySubpagePools的長度爲31,即存放16,32,48...496這31種規格的PoolSubpage(不同規格的PoolSubpage存放在對應的數組下標中,相同規格的PoolSubpage在同一個數組下標中形成鏈表)。

默認情況下,smallSubpagePools的長度爲4,存放512,1024,2048,4096這四種規格的PoolSubpage。

PoolArena會根據所申請的內存大小決定是找PoolChunk還是找對應規格的PoolSubpage來分配。

值得注意的是,PoolArena在分配內存時,是會存在競爭的,

因此在關鍵的地方,PoolArena會通過sychronize來保證線程的安全。

另外,Netty對這種競爭做了一定程度的優化,它會分配多個PoolArena,讓線程儘量使用不同的PoolArena,減少出現競爭的情況。

PooledByteBufAllocator

內存分配的入口是 【PooledByteBufAllocator #newDirectBuffer()】。

PooledByteBufAllocator的結構

PooledByteBufAllocator該類,也就是內存分配的 起始類, 其內部定義了很多 默認變量值 用於接下來的內存分配整個流程。

PooledByteBufAllocator的結構圖如下:

img

從上圖 可看到有三個核心的類:

1.PoolArena(DirectArena, HeapArena都繼承於該類) : 可以把他想象成內存大管家

2.PoolThreadCache: 線程本地內存緩存池

3.InternalThreadLocalMap: 目前把他當成 ThreadLocalMap 就行

在講解源碼前,請 思考一個問題 :

從圖中可看到 不管是 DirectArena 還是 HeapArena,他們都有多個,且各自組成了數組 分別是directArenas 和 heapArenas。

前面說了PoolArena , 我們目前可以看成是一個內存大管家, 當業務來申請內存時,需要從PoolArena 這塊大內存中 截取一塊來使用就行。

但是 爲什麼要有多個PoolArena呢

解答:

首先我們假設就只有一個PoolArena, 衆所周知 現在的 CPU 一般都是多核的, 此時有多個業務(多線程) 同時從Netty中 申請內存(從大內存中偏移出多個小內存)來使用,

而本質上是多核CPU來操作這同一塊大內存 進行讀寫。

但是 由於 操作系統的讀寫內存屏障 存在, 會導致多個線程的讀寫並不能做到真正的並行

因此Netty用了多個PoolArena 來減輕這種不能並行的行爲,從而提升效率

PooledByteBufAllocator的核心屬性

用戶程序申請內存通過PooledByteBufAllocator類提供的buffer去操作,下面是這個類定義的屬性

    //heap類型arena的個數,數量計算方法同下 
    private static final int DEFAULT_NUM_HEAP_ARENA;
    //direct類型arena個數,默認爲min(cpu_processors * 2,maxDirectMemory/16M/2/3),正 
    //常情況下如果不設置很小的Xmx或者很小的-XX:MaxDirectMemorySize, 
    //arena的數量就等於計算機processor個數的2倍,
    private static final int DEFAULT_NUM_DIRECT_ARENA;
     //內存頁的大小,默認爲8k(這個內存頁可以類比操作系統內存管理中的內存頁)
    private static final int DEFAULT_PAGE_SIZE;’
    //默認是11,因爲一個chunk默認是16M = 2^11  *  2^13(8192)
    private static final int DEFAULT_MAX_ORDER; // 8192 << 11 = 16 MiB per chunk
    //緩存tiny類型的內存的個數,默認是512
    private static final int DEFAULT_TINY_CACHE_SIZE;
    //緩存small類型的內存的個數,默認是256
    private static final int DEFAULT_SMALL_CACHE_SIZE;
   //緩存normal類型的內存的個數,默認是64
    private static final int DEFAULT_NORMAL_CACHE_SIZE;
   //最大可以被緩存的內存值,默認爲32K,當申請的內存超過32K,那麼這塊內存就不會被放入緩存池了
    private static final int DEFAULT_MAX_CACHED_BUFFER_CAPACITY;
    //cache經過多少次回收之後,被清理一次,默認是8192
    private static final int DEFAULT_CACHE_TRIM_INTERVAL;
    private static final long DEFAULT_CACHE_TRIM_INTERVAL_MILLIS;
    //是不是所有的線程都是要cache,默認true,
    private static final boolean DEFAULT_USE_CACHE_FOR_ALL_THREADS;
    private static final int DEFAULT_DIRECT_MEMORY_CACHE_ALIGNMENT;
// Use 1023 by default as we use an ArrayDeque as backing storage which will then allocate an internal array
        // of 1024 elements. Otherwise we would allocate 2048 and only use 1024 which is wasteful.
    static final int DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK;
//-------------------------------------------下半部分的屬性---------------------------------------
    //heap類型的arena數組    
    private final PoolArena<byte[]>[] heapArenas;
    //direct memory 類型的arena數組   
    private final PoolArena<ByteBuffer>[] directArenas;
    //對應上面的DEFAULT_TINY_CACHE_SIZE;
    private final int tinyCacheSize;
    private final int smallCacheSize;
    private final int normalCacheSize;
    private final List<PoolArenaMetric> heapArenaMetrics;
    private final List<PoolArenaMetric> directArenaMetrics;
    //這是個FastThreadLocal,記錄是每個線程自己的內存緩存信息
    private final PoolThreadLocalCache threadCache;
    //每個PageChunk代表的內存大小,默認是16M,這個可以類比操作系統內存管理中段的概念
    private final int chunkSize;

創建heap和direct memory類型的Arena

在初始化PooledByteBufAllocator的時候會創建heap和direct memory類型的Arena,

/**
     *
     * @param preferDirect          是否申請直接內存                   默認一般都是true
     * @param nHeapArena            heapArena的個數                  假設 cpu*2
     * @param nDirectArena          directArena的個數                假設 cpu*2 
     * @param pageSize              頁的大小                          默認8KB (8192)
     * @param maxOrder             chunk中完全平衡二叉樹的深度           11
     * @param smallCacheSize     smallMemoryRegionCache的隊列長度      256
     * @param normalCacheSize    normalMemoryRegionCache的隊列長度     64
     * @param useCacheForAllThreads 是否所有的線程都使用PoolThreadCache  true
     * @param directMemoryCacheAlignment 對齊填充                      0
     */
    public PooledByteBufAllocator(boolean preferDirect, int nHeapArena, int nDirectArena, int pageSize, int maxOrder, int smallCacheSize, int normalCacheSize,
boolean useCacheForAllThreads, int directMemoryCacheAlignment) {
        
        // directByDefault = true
        super(preferDirect);

        // 目前理解成 threadLocal 每個線程中有自己的 PoolThreadLocalCache緩存
        threadCache = new PoolThreadLocalCache(useCacheForAllThreads);
        
        // 賦值 smallCacheSize=256  normalCacheSize=64
        this.smallCacheSize = smallCacheSize;
        this.normalCacheSize = normalCacheSize;
		
        
        // 計算chunkSize   8KB<<11 = 16mb (16,777,216)
        chunkSize = validateAndCalculateChunkSize(pageSize, maxOrder);
		

        // pageSize=8KB(8192)  
        //pageShifts表示 1 左移多少位是 8192  = 13
        int pageShifts = validateAndCalculatePageShifts(pageSize, directMemoryCacheAlignment);
        
        
        
        // 生成 heapArenas 數組
        if (nHeapArena > 0) {
            heapArenas = newArenaArray(nHeapArena);
            List<PoolArenaMetric> metrics = new ArrayList<PoolArenaMetric>(heapArenas.length);
            for (int i = 0; i < heapArenas.length; i ++) {
                PoolArena.HeapArena arena = new PoolArena.HeapArena(this,
                        pageSize, pageShifts, chunkSize,
                        directMemoryCacheAlignment);
                heapArenas[i] = arena;
                metrics.add(arena);
            }
            heapArenaMetrics = Collections.unmodifiableList(metrics);
        } else {
            heapArenas = null;
            heapArenaMetrics = Collections.emptyList();
        }
		
        // netty默認情況下都會使用 直接內存,因此我們在整個Netty中關心直接內存相關就可以了,而且直接內存與堆內存邏輯並無太多差異。
        
        // 生成 directArena數組  
        if (nDirectArena > 0) {

            // 假設平臺CPU 個數是8, 這裏會創建 cpu(8)*2 = 16個長度的 directArenas數組。
            directArenas = newArenaArray(nDirectArena);

            // 這是個內存池圖表 
            //如果想要監測 內存池詳情 可使用該對象(關於內存分配邏輯不用關心Metric相關對象)
            List<PoolArenaMetric> metrics = new ArrayList<PoolArenaMetric>(directArenas.length);

            // for循環最終創建了 16個 directArena 對象,並且 將這些DirectArena對象放入到數組內
            for (int i = 0; i < directArenas.length; i ++) {
                
                // 參數1:allocator 對象
                // 參數2:pageSize 8k
                // 參數3:pageShift 13  1<<13 可推出pageSize的值
                // 參數4:chunkSize: 16mb
                // 參數5:directMemoryCacheAlignment 對齊填充 0
                PoolArena.DirectArena arena = new PoolArena.DirectArena(
                        this, pageSize, pageShifts, chunkSize, directMemoryCacheAlignment);
                directArenas[i] = arena;
                metrics.add(arena);
            }
            directArenaMetrics = Collections.unmodifiableList(metrics);
        } else {
            directArenas = null;
            directArenaMetrics = Collections.emptyList();
        }
        
        metric = new PooledByteBufAllocatorMetric(this);
    }

分配直接內存入口

 /**
     * 申請分配直接內存 入口
     * @param initialCapacity  業務需求內存大小
     * @param maxCapacity      內存最大限制
     * @return  ByteBuf netty中內存對象
     */
    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {

        // 若當前線程沒有PoolThreadCache 則創建一份 PoolThreadCache 
      	// 若當前線程有 則直接獲取
        PoolThreadCache cache = threadCache.get();
        // 拿到當前線程cache中綁定的directArena
        PoolArena<ByteBuffer> directArena = cache.directArena;
        final ByteBuf buf;
        // 這個條件正常邏輯 都會成立
        if (directArena != null) {
            
            //這是咱們的核心入口 ******  
            // 參數1: cache 當前線程相關的PoolThreadCache對象
            // 參數2:initialCapactiy 業務層需要的內存容量
            // 參數3:maxCapacity 最大內存大小
            buf = directArena.allocate(cache, initialCapacity, maxCapacity);
        } else {
            // 一般不會走到這裏,不用看
            buf = PlatformDependent.hasUnsafe() ?
                    UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
                    new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
        }

        return toLeakAwareBuffer(buf);
    }

緩存架構

回顧一下有關內存分配幾個非常重要的概念,分別是arena、chunk、page、subpage。
接下來我們看一下什麼是arena:
在這裏插入圖片描述
在每個線程去申請內存的時候,他首先會通過ThreadLocal 這種方式去獲得當前線程的PoolThreadCache對象,

然後調用他的allocate方法去申請內存,他一共分爲兩部分,

  • 一個是cache,在申請內存的時候首先會嘗試從緩存中獲取,

  • 另外一個就是arena,如果從緩存中獲取不到對應的內存,那麼就要通過arena從內存池中獲取。

本地線程存儲

雖然提供了多個PoolArena減少線程間的競爭,但是難免還是會存在鎖競爭,所以需要利用ThreaLocal進一步優化,把已申請的內存放入到ThreaLocal自然就沒有競爭了。

大體思路是:

  • 在ThreadLocal裏面放一個PoolThreadCache對象
  • 然後釋放的內存都放入到PoolThreadCache裏面,下次申請先從PoolThreadCache獲取。

但是,如果thread1申請了一塊內存,然後傳到thread2在線程釋放,這個Netty在內存holder對象裏面會引用PoolThreadCache,所以還是會釋放到thread1裏。

PoolThreadCache

PoolThreadCache是Netty內存管理中能夠實現高效內存申請和釋放的一個重要原因,

Netty會爲每一個線程都維護一個PoolThreadCache對象,

當進行內存申請時,首先會嘗試從PoolThreadCache中申請,如果無法從中申請到,則會嘗試從Netty的公共內存池中申請。

PoolThreadCache數據結構

PoolThreadCache的數據結構與PoolArena的主要屬性結構非常相似,但細微位置有很大的不同。

在PoolThreadCache中,其維護了三個數組(我們以直接內存的緩存方式爲例進行講解),

在這裏插入圖片描述

這三個數組分別保存了tiny,small和normal類型的緩存數據,

在這裏插入圖片描述

不同於PoolArena的使用PoolSubpage和PoolChunk進行不同規格的內存的維護,這裏不同規格的內存,三個數組都是使用MemoryRegionCache類型

up-7f886fe18b10ae92fa15c61e81725aa877f.png

每個元素的桶(內部結構爲隊列)的長度,是一個有界隊列,不同規格的桶,長度不一樣:

  • 對於tiny類型的緩存,該隊列的長度爲512,
  • 對於small類型的緩存,該隊列的長度爲256,
  • 對於normal類型的緩存,該隊列的長度爲64。

在進行內存釋放的時候,如果一種規格的隊列桶已經滿了,那麼就會將該內存塊釋放回PoolArena中。

MemoryRegionCache

cache數組中的一個元素,類型爲MemoryRegionCache,主要成爲爲隊列 bucket,和內存的規格

在這裏插入圖片描述

隊列桶中的元素統一使用的是Entry這種數據結構,該結構的主要屬性如下:

在這裏插入圖片描述

PoolThreadCache中維護每一個內存塊最終都是使用的一個Entry對象來進行的,

  • 緩存Entry最重要的屬性是chunk和handle,

  • chunk記錄了當前內存塊所在的PoolChunk對象,

一個handle 是一個long佔8個字節,其由低位的4個字節和高位的4個字節組成,

低位的4個字節表示當前page(8K)在PoolChunk(16M)中memoryMap映射數組中的下標索引;

而高位的4個字節則表示當前需要分配的內存在PoolSubPage所代表的8KB內存中的位圖索引。

對於大於8KB的內存分配,不涉及PoolSubPage結構,因而高位四個字節的位圖索引爲0, 而低位的4個字節page序列在chunk中的位置索引;

對於低於8KB的內存分配,其會使用一個PoolSubPage來表示整個8KB內存,因而需要一個位圖索引來表示subpage在page中的位圖索引。

PoolThreadCache的初始化

對於PoolThreadCache的初始化,其初始化過程是與PoolThreadLocalCache所綁定的。

PoolThreadLocalCache的作用與Java中的ThreadLocal的作用非常類似,核心方法大致如下:

  • initialValue()方法,用於在無法從PoolThreadLocalCache中獲取數據時,通過調用該方法初始化一個。

  • get()方法,用於在無法從PoolThreadLocalCache中獲取數據時

  • remove()方法,分別用於從PoolThreadLocalCache中將當前綁定的數據給清除。

    首先看看獲取PoolThreadCache初始化的入口代碼:

在這裏插入圖片描述

如下是PoolThreadLocalCache.initialValue()方法的源碼

在這裏插入圖片描述
在這裏插入圖片描述

首先會根據PoolArena的屬性numThreadCaches查找PoolArena數組中被最少線程佔用的那個arena,

然後將其封裝到一個新建的PoolThreadCache中, 並且返回

在這裏插入圖片描述

獲得PoolThreadCache後,再通過PoolThreadCache獲取PoolArena,再調用 PoolArena#allocate方法

在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述

大致的步驟:

  • PoolThreadCache是將其分爲tiny,small和normal三種不同的方法來調用的,首先根據規格化後的內容規模,去計算不同類型的slot槽位

  • 在對應的內存數組中,相對應的slot槽位找到MemoryRegionCache對象之後,通過調用allocate()方法來申請內存,

  • 申請完之後還會檢查當前緩存申請次數是否達到了8192次,達到了則對緩存中使用的內存塊進行檢測,將較少使用的內存塊返還給PoolArena。

根據規模計算不同類型的slot槽位

在這裏插入圖片描述

img

拿到槽位後,獲取不同類型的 MemoryRegionCache對象

在這裏插入圖片描述
在這裏插入圖片描述

MemoryRegionCache.allocate()

下面我們繼續看一下MemoryRegionCache.allocate()方法是如何申請內存的:

在這裏插入圖片描述

MemoryRegionCache 是從隊列中取entry,如果取到了,則使用該內存塊初始化一個ByteBuf對象。

PoolThreadCache會對其內存塊使用次數進行計數,這麼做的目是什麼呢?

主要是目前已經線程隔離了,防止一個線程佔用太多的資源,其他線程搶不到內存。

所以,如果一個ThreadPoolCache所緩存的內存塊使用次數較少,那麼就可以將其釋放到PoolArena中,以便於其他線程可以申請使用。

PoolThreadCache會在其內存總的申請次數達到8192時,遍歷其所有的MemoryRegionCache,然後調用其trim()方法進行內存釋放,

MemoryRegionCache#trim()

如下是該方法的源碼:

在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述

內存釋放

還要補充5000字

對象池

只要知道一個PoolChunk裏面的頁索引,申請到的空間容量,如果是小內存還需要知道頁內索引, 就可以定位到這塊內存了。

這些信息, 都保存到PooledByteBuf對象

  protected PoolChunk<T> chunk;
    protected long handle; //索引
    protected T memory;  //chunk.memory
    protected int offset;  //偏移量
    protected int length;
    int maxLength;
    PoolThreadCache cache;

所以再申請完內存後,還需要創建這麼個對象來保存這些信息。

在Netty中會有一個輕量級的對象池(Recycler)來保存PooledByteBuf對象。

這裏用了ThreadLocal來減少鎖的爭用,所以同樣的會出現不通線程之間申請與釋放的問題。

在這個地方Netty的處理方式爲:它爲每個線程維持一個對象隊列,同時又有一個全局的隊列來保存釋放的對象。

當線程從自身的隊列拿不到對象時,會從全局隊列中轉移一部分對象到自身隊列中去。

內存釋放

Netty中使用引用計數機制來管理資源,

ByteBuf實現了ReferenceCounted接口,當實例化ByteBuf對象時,引用計數加1。

當應用代碼保持一個對象引用時,會調用retain方法將計數增加1,對象使用完畢進行釋放,調用release將計數器減1.

當引用計數變爲0時,對象將釋放所有的資源,返回內存池。

內存泄露檢測

內存泄露檢測的原理是利用虛引用,當一個對象只被虛引用指向時,

在GC的時候會被自動放到了一個ReferenceQueue裏面,每次去申請內存的時候最後都會根據檢測策略去ReferenceQueue裏面判斷釋放有泄露的對象。

 #AbstractByteBufAllocator
  protected static ByteBuf toLeakAwareBuffer(ByteBuf buf) {
        ResourceLeakTracker<ByteBuf> leak;
        switch (ResourceLeakDetector.getLevel()) {
            case SIMPLE:
                leak = AbstractByteBuf.leakDetector.track(buf);
                if (leak != null) {
                    buf = new SimpleLeakAwareByteBuf(buf, leak);
                }
                break;
            case ADVANCED:
            case PARANOID:
                leak = AbstractByteBuf.leakDetector.track(buf);
                if (leak != null) {
                    buf = new AdvancedLeakAwareByteBuf(buf, leak);
                }
                break;
            default:
                break;
        }
        return buf;
    }

netty支持下面四種級別,使用-Dio.netty.leakDetectionLevel=advanced可以調節等級。

  • 禁用(DISABLED) - 完全禁止泄露檢測,省點消耗。
  • 簡單(SIMPLE) - 默認等級,告訴我們取樣的1%的ByteBuf是否發生了泄露,但總共一次只打印一次,看不到就沒有了。
  • 高級(ADVANCED) - 告訴我們取樣的1%的ByteBuf發生泄露的地方。每種類型的泄漏(創建的地方與訪問路徑一致)只打印一次。對性能有影響。
  • 偏執(PARANOID) - 跟高級選項類似,但此選項檢測所有ByteBuf,而不僅僅是取樣的那1%。對性能有絕大的影響。

Handler中的內存處理機制

Netty中有handler鏈,消息是由本Handler傳到下一個Handler。

所以Netty引入了一個規則,誰是最後使用者,誰負責釋放。

根據誰最後使用誰負責釋放的原則,每個Handler對消息可能有三種處理方式

  • 對原消息不做處理,調用 ctx.fireChannelRead(msg)把原消息往下傳,那不用做什麼釋放。

  • 將原消息轉化爲新的消息並調用 ctx.fireChannelRead(newMsg)往下傳,那必須把原消息release掉。

  • 如果已經不再調用ctx.fireChannelRead(msg)傳遞任何消息,那更要把原消息release掉。

假設每一個Handler都把消息往下傳,Handler並也不知道誰是啓動Netty時所設定的Handler鏈的最後一員,

所以Netty在Handler鏈的最末補了一個TailHandler,如果此時消息仍然是ReferenceCounted類型就會被release掉。

未完待續

完整演示與介紹,請參考40歲老架構師尼恩的視頻:《徹底穿透netty架構與源碼》

參考文獻

https://blog.csdn.net/qq_41652863/article/details/99095769#PoolArena

http://anyteam.me/netty-memory-allocation-PoolArena/
http://www.programmersought.com/article/9322400832/
https://www.jianshu.com/p/4856bd30dd56
https://juejin.im/post/5d4f6d74f265da03e83b5e07

https://www.jianshu.com/p/550704d5a628

https://blog.csdn.net/ClarenceZero/article/details/112971237

https://juejin.cn/post/6922783552580878349

https://blog.csdn.net/m0_64383449/article/details/121707936

https://blog.csdn.net/ClarenceZero/article/details/112971237

https://blog.csdn.net/cq_pf/article/details/107794567

https://blog.csdn.net/wangwei19871103/article/details/104341612

https://people.freebsd.org/~jasone/jemalloc/bsdcan2006/jemalloc.pdf

https://juejin.cn/post/6922783552580878349

https://blog.csdn.net/wangwei19871103/article/details/104341612

https://blog.csdn.net/wangwei19871103/article/details/104341612

http://blog.ouyangsihai.cn/zhi-cheng-bai-wan-ji-bing-fa-netty-ru-he-shi-xian-gao-xing-neng-nei-cun-guan-li.html

https://www.cnblogs.com/wuzhenzhao/p/11290533.html

http://www.javashuo.com/article/p-vtghkove-ez.html

https://blog.csdn.net/gaoliang1719/article/details/115649976

https://www.jianshu.com/p/a97724153d88

https://blog.csdn.net/helloworld_ptt/article/details/115599230

http://www.shangmayuan.com/a/2b8575b1f6754fae8e6b7d86.html

https://zhuanlan.zhihu.com/p/321780626

https://blog.csdn.net/helloworld_ptt/article/details/115599230

https://blog.csdn.net/helloworld_ptt/article/details/115599230

http://www.shangmayuan.com/a/2b8575b1f6754fae8e6b7d86.html

https://www.cnblogs.com/s686zhou/p/15701801.html

https://my.oschina.net/zhangxufeng/blog/3040834

https://my.oschina.net/zhangxufeng/blog/3030653

https://zhuanlan.zhihu.com/p/115521554

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