談談Netty內存管理

點擊上方藍色字體,選擇「標星公衆號」
java

優質文章,第一時間送達web

  做者 |  insaneXs 數組

來源 |  urlify.cn/mAzmYf緩存

66套java從入門到精通實戰課程分享安全

前言

正是Netty的易用性和高性能成就了Netty,讓其可以如此流行。
而做爲一款通訊框架,首當其衝的即是對IO性能的高要求。
很多讀者都知道Netty底層經過使用Direct Memory,減小了內核態與用戶態之間的內存拷貝,加快了IO速率。可是頻繁的向系統申請Direct Memory,並在使用完成後釋放自己就是一件影響性能的事情。爲此,Netty內部實現了一套本身的內存管理機制,在申請時,Netty會一次性向操做系統申請較大的一塊內存,而後再將大內存進行管理,按需拆分紅小塊分配。而釋放時,Netty並不着急直接釋放內存,而是將內存回收以待下次使用。
這套內存管理機制不只能夠管理Directory Memory,一樣能夠管理Heap Memory。微信

內存的終端消費者——ByteBuf

這裏,我想向讀者們強調一點,ByteBuf和內存實際上是兩個概念,要區分理解。
ByteBuf是一個對象,須要給他分配一塊內存,它才能正常工做。
而內存能夠通俗的理解成咱們操做系統的內存,雖然申請到的內存也是須要依賴載體存儲的:堆內存時,經過byte[], 而Direct內存,則是Nio的ByteBuffer(所以Java使用Direct Memory的能力是JDK中Nio包提供的)。
爲何要強調這兩個概念,是由於Netty的內存池(或者稱內存管理機制)涉及的是針對內存的分配和回收,而Netty的ByteBuf的回收則是另外一種叫作對象池的技術(經過Recycler實現)。
雖然這二者老是伴隨着一塊兒使用,但這兩者是獨立的兩套機制。可能存在着某次建立ByteBuf時,ByteBuf是回收使用的,而內存倒是新向操做系統申請的。也可能存在某次建立ByteBuf時,ByteBuf是新建立的,而內存倒是回收使用的。
由於對於一次建立過程而言,能夠分紅三個步驟:數據結構

  1. 獲取ByteBuf實例(可能新建,也多是之間緩存的)app

  2. 向Netty內存管理機制申請內存(可能新向操做系統申請,也多是以前回收的)框架

  3. 將申請到的內存分配給ByteBuf使用性能

本文只關注內存的管理機制,所以不會過多的對對象回收機制作解釋。

Netty中內存管理的相關類

Netty中與內存管理相關的類有不少。框架內部提供了PoolArena,PoolChunkList,PoolChunk,PoolSubpage等用來管理一塊或一組內存。
而對外,提供了ByteBufAllocator供用戶進行操做。
接下來,咱們會先對這幾個類作必定程度的介紹,在經過ByteBufAllocator瞭解內存分配和回收的流程。
爲了篇幅和可讀性考慮,本文不會涉及到大量很詳細的代碼說明,而主要是經過圖輔之必要的代碼進行介紹。
針對代碼的註解,能夠見我GitHub上的netty項目。

PoolChunck——Netty向OS申請的最小內存

上文已經介紹了,爲了減小頻繁的向操做系統申請內存的狀況,Netty會一次性申請一塊較大的內存。然後對這塊內存進行管理,每次按需將其中的一部分分配給內存使用者(即ByteBuf)。這裏的內存就是PoolChunk,其大小由ChunkSize決定(默認爲16M,即一次向OS申請16M的內存)。

Page——PoolChunck所管理的最小內存單位

PoolChunk所能管理的最小內存叫作Page,大小由PageSize(默認爲8K),即一次向PoolChunk申請的內存都要以Page爲單位(一個或多個Page)。
當須要由PoolChunk分配內存時,PoolChunk會查看經過內部記錄的信息找出知足這次內存分配的Page的位置,分配給使用者。

PoolChunck如何管理Page

咱們已經知道PoolChunk內部會以Page爲單位組織內存,一樣以Page爲單位分配內存。
那麼PoolChunk要如何管理才能兼顧分配效率(指儘量快的找出可分配的內存且保證這次分配的內存是連續的)和使用效率(儘量少的避免內存浪費,作到物盡其用)的?
Netty採用了Jemalloc的想法。
首先PoolChunk經過一個徹底二叉樹來組織內部的內存。以默認的ChunkSize爲16M, PageSize爲8K爲例,一個PoolChunk能夠劃分紅2048個Page。將這2048個Page看做是葉子節點的寬度,能夠獲得一棵深度爲11的樹(2^11=2048)。
咱們讓每一個葉子節點管理一個Page,那麼其父節點管理的內存即爲兩個Page(其父節點有左右兩個葉子節點),以此類推,樹的根節點管理了這個PoolChunk全部的Page(由於全部的葉子結點都是其子節點),而樹中某個節點所管理的內存大小便是以該節點做爲根的子樹所包含的葉子節點管理的所有Page。
這樣作的好處就是當你須要內存時,很快能夠找到從何處分配內存(你只須要從上往下找到所管理的內存爲你須要的內存的節點,而後將該節點所管理的內存分配出去便可),而且所分配的內存仍是連續的(只要保證相鄰葉子節點對應的Page是連續的便可)。

上圖中編號爲512的節點管理了4個Page,爲Page0, Page1, Page2, Page3(由於其下面有四個葉子節點2048,2049,2050, 2051)。
而編號爲1024的節點管理了2個Page,爲Page0和Page1(其對應的葉子節點爲Page0和Page1)。
當須要分配32K的內存時,只須要將編號512的節點分配出去便可(512分配出去後會默認其下全部子節點都不能分配)。而當須要分配16K的內存時,只須要將編號1024的節點分配出去便可(一旦節點1024被分配,下面的2048和2049都不容許再被分配)。

瞭解了PoolChunk內部的內存管理機制後,讀者可能會產生幾個問題:

  • PoolChunk內部如何標記某個節點已經被分配?

  • 當某個節點被分配後,其父節點所能分配的內存如何更新?即一旦節點2048被分配後,當你再須要16K的內存時,就不能從節點1024分配,由於如今節點1024可用的內存僅有8K。

爲了解決以上這兩點問題,PoolChunk都是內部維護了的byte[] memeoryMap和byte[] depthMap兩個變量。
這兩個數組的長度是相同的,長度等於樹的節點數+1。由於它們把根節點放在了1的位置上。而數組中父節點與子節點的位置關係爲:

假設parnet的下標爲i,則子節點的下標爲2i和2i+1

用數組表示一顆二叉樹,大家是否是想到了堆這個數據結構。

已經知道了兩個數組都是表示二叉樹,且數組中的每一個元素能夠當作二叉樹的節點。那麼再來看看元素的值分別代碼什麼意思。
對於depthMap而言,該值就表明該節點所處的樹的層數。例如:depthMap[1] == 1,由於它是根節點,而depthMap[2] = depthMap[3] = 2,表示這兩個節點均在第二層。因爲樹一旦肯定後,結構就不在發生改變,所以depthMap在初始化後,各元素的值也就不發生變化了。

而對於memoryMap而言,其值表示該節點下可用於完整內存分配的最小層數(或者說最靠近根節點的層數)。
這話理解起來可能有點彆扭,仍是用上文的例子爲例 。
首先在內存都未分配的狀況下,每一個節點所能分配的內存大小就是該層最初始的狀態(即memoryMap的初始狀態和depthMap的一致的)。而一旦其有個子節點被分配出後去,父節點所能分配的完整內存(完整內存是指該節點所管理的連續的內存塊,而非該節點剩餘的內存大小)就減少了(內存的分配和回收會修改關聯的mermoryMap中相關節點的值)。
譬如,節點2048被分配後,那麼對於節點1024來講,能完整分配的內存(原先爲16K)就已經和編號2049節點(其右子節點)相同(減爲了8K),換句話說節點1024的能力已經退化到了2049節點所在的層節點所擁有的能力。
這一退化可能會影響全部的父節點。
而此時,512節點能分配的完整內存是16K,而非24K(由於內存分配都是按2的冪進行分配,儘管一個消費者真實須要的內存多是21K,可是Netty的內存管理機制會直接分配32K的內存)。

可是這並非說節點512管理的另外一個8K內存就浪費了,8K內存還能夠用來在申請內存爲8K的時候分配。

用圖片演示PoolChunk內存分配的過程。其中value表示該節點在memoeryMap的值,而depth表示該節點在depthMap的值。
第一次內存分配,申請者實際須要6K的內存:

此次分配形成的後果是其全部父節點的memoryMap的值都往下加了一層。
以後申請者須要申請12K的內存:

因爲節點1024已經沒法分配所需的內存,而節點512還可以分配,所以節點512讓其右節點再嘗試。

上述介紹的是內存分配的過程,而內存回收的過程就是上述過程的逆過程——回收後將對應節點的memoryMap的值修改回去。這裏不過多介紹。

PoolChunkList——對PoolChunk的管理

PoolChunkList內部有一個PoolChunk組成的鏈表。一般一個PoolChunkList中的全部PoolChunk使用率(已分配內存/ChunkSize)都在相同的範圍內。
每一個PoolChunkList有本身的最小使用率或者最大使用率的範圍,PoolChunkList與PoolChunkList之間又會造成鏈表,而且使用率範圍小的PoolChunkList會在鏈表中更加靠前。
而隨着PoolChunk的內存分配和使用,其使用率發生變化後,PoolChunk會在PoolChunkList的鏈表中,先後調整,移動到合適範圍的PoolChunkList內。
這樣作的好處是,使用率的小的PoolChunk能夠先被用於內存分配,從而維持PoolChunk的利用率都在一個較高的水平,避免內存浪費。

PoolSubpage——小內存的管理者

PoolChunk管理的最小內存是一個Page(默認8K),而當咱們須要的內存比較小時,直接分配一個Page無疑會形成內存浪費。
PoolSubPage就是用來管理這類細小內存的管理者。

小內存是指小於一個Page的內存,能夠分爲Tiny和Smalll,Tiny是小於512B的內存,而Small則是512到4096B的內存。若是內存塊大於等於一個Page,稱之爲Normal,而大於一個Chunk的內存塊稱之爲Huge。

而Tiny和Small內部又會按具體內存的大小進行細分。
對Tiny而言,會分紅16,32,48...496(以16的倍數遞增),共31種狀況。
對Small而言,會分紅512,1024,2048,4096四種狀況。
PoolSubpage會先向PoolChunk申請一個Page的內存,而後將這個page按規格劃分紅相等的若干個內存塊(一個PoolSubpage僅會管理一種規格的內存塊,例如僅管理16B,就將一個Page的內存分紅512個16B大小的內存塊)。
每一個PoolSubpage僅會選一種規格的內存管理,所以處理相同規格的PoolSubpage每每是經過鏈表的方式組織在一塊兒,不一樣的規格則分開存放在不一樣的地方。
而且老是管理一個規格的特性,讓PoolSubpage在內存管理時不須要使用PoolChunk的徹底二叉樹方式來管理內存(例如,管理16B的PoolSubpage只須要考慮分配16B的內存,當申請32B的內存時,必須交給管理32B的內存來處理),僅用 long[] bitmap (能夠當作是位數組)來記錄所管理的內存塊中哪些已經被分配(第幾位就表示第幾個內存塊)。
實現方式要簡單不少。

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,減小出現競爭的狀況。

PoolThreadCache——線程本地緩存,減小內存分配時的競爭

PoolArena免不了產生競爭,Netty除了建立多個PoolArena減小競爭外,還讓線程在釋放內存時緩存已經申請過的內存,而不當即歸還給PoolArena。
緩存的內存被存放在PoolThreadCache內,它是一個線程本地變量,所以是線程安全的,對它的訪問也不須要上鎖。
PoolThreadCache內部是由MemeoryRegionCache的緩存池(數組),一樣按等級能夠分爲Tiny,Small和Normal(並不緩存Huge,由於Huge效益不高)。
其中Tiny和Small這兩個等級下的劃分方式和PoolSubpage的劃分方式相同,而Normal由於組合太多,會有一個參數控制緩存哪些規格(例如,一個Page, 兩個Page和四個Page等...),不在Normal緩存規格內的內存塊將不會被緩存,直接還給PoolArena。
再看MemoryRegionCache, 它內部是一個隊列,同一隊列內的全部節點能夠當作是該線程使用過的同一規格的內存塊。同時,它還有個size屬性控制隊列過長(隊列滿後,將不在緩存該規格的內存塊,而是直接還給PoolArena)。
當線程須要內存時,會先從本身的PoolThreadCache中找對應等級的緩存池(對應的數組)。而後再從數組中找出對應規格的MemoryRegionCache。最後從其隊列中取出內存塊進行分配。

Netty內存機構總覽和PooledByteBufAllocator申請內存步驟

在瞭解了上述這麼多概念後,經過一張圖給讀者加深下印象。

上圖僅詳細畫了針對Heap Memory的部分,Directory Memory也是相似的。

最後在由PooledByteBufAllocator做爲入口,重頭梳理一遍內存申請的過程:

  1. PooledByteBufAllocator.newHeapBuffer()開始申請內存

  2. 獲取線程本地的變量PoolThreadCache以及和線程綁定的PoolArena

  3. 經過PoolArena分配內存,先獲取ByteBuf對象(多是對象池回收的也多是建立的),在開始內存分配

  4. 分配前先判斷這次內存的等級,嘗試從PoolThreadCache的找相同規格的緩存內存塊使用,沒有則從PoolArena中分配內存

  5. 對於Normal等級內存而言,從PoolChunkList的鏈表中找合適的PoolChunk來分配內存,若是沒有則先像OS申請一個PoolChunk,在由PoolChunk分配相應的Page

  6. 對於Tiny和Small等級的內存而言,從對應的PoolSubpage緩存池中找內存分配,若是沒有PoolSubpage,線會到第5步,先分配PoolChunk,再由PoolChunk分配Page給PoolSubpage使用

  7. 對於Huge等級的內存而言,不會緩存,會在用的時候申請,釋放的時候直接回收
    8.將獲得的內存給ByteBuf使用,就完成了一次內存申請的過程

總結

Netty的內存管理機制仍是很巧妙的,可是介紹起來不免有點晦澀。本想盡可能通俗易懂的撇開源碼和你們講講原理,可是不知不覺也寫了一大段的文字。但願上文的幾幅圖能幫助讀者理解。
另外,本文也沒有介紹內存釋放的過程。釋放其實就是申請的逆過程,有興趣的讀者能夠本身跟一下源碼,或者是從文章開頭的項目中找源碼註釋。





     



感謝點贊支持下哈 

本文分享自微信公衆號 - java1234(gh_27ed55ecb177)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索