UE4內存分配器介紹與ptmalloc對比


UE4內存分配器介紹與ptmalloc對比

內存體系結構

  1. 我們都知道原生的libc提供了malloc、alloc、realloc、free等內存分配相關的函數。

  2. 在UE4自己也封裝了一套相關的內存分配器的實現,並且提供了多個不同的內存分配器,這些內存分配器的基類是FMalloc類,其中提供了幾個基本的內存分配函數與Free釋放函數。

  1. 如下圖是UE4的內存分配體系架構,可以看出來UE4的內存管理都是基於這些內存分配器實現的,而這些內存分配器是基於最基本的系統調用VirtualAlloc、mmap實現的。

     

  2. 每個平台都有適用於自己的內存管理器

  Ansi TBB jemalloc Binned Binned2 Binned3 Mimalloc Stomp
Android 支持     支持 默認 支持(64)    
IOS 支持     默認     支持 支持
Windows 支持 默認   支持 默認 支持(64)   支持
Linux 支持   支持 支持 默認     支持
Mac 支持 默認   支持 支持     支持
HoloLens 支持         默認    
  1. 不同內存管理器的特點
    Ansi內存分配器(標准C):直接調用malloc、free、realloc函數

    TBB(Thread Building Blocks)內存分配器:Intel 提供的第三方庫的一個可伸縮內存分配器(Scalable Memory Allocator)

    Jemalloc內存分配器(Linux / FreeBSD):適合多線程下的內存分配管理 http://www.canonware.com/jemalloc/

    Stomp:用於查非法內存操作(如:內存越界,野指針)的管理方式,目前只支持windows、mac、unix等pc平台。帶命令行參數-stompmalloc來啟用該分配器

 

內存管理對象的初始化

UE4內存管理通過創建一個全局的管理器: GMalloc。在引擎初始化,第一次內存分配時,會調用以下FMemory_GCreateMalloc_ThreadUnsafe函數對GMalloc進行初始化。

 

static int FMemory_GCreateMalloc_ThreadUnsafe(){ ... GMalloc = FPlatformMemory::BaseAllocator(); ... } 

可以看到,GMalloc的初始化調用到了FPlatformMemory這個類的BaseAllocator函數。FPlatformMemory是一個定義,在不同平台下有不同的定義,例如在Windows下:

 

//WindowsPlatformMemory.h
... struct CORE_API FWindowsPlatformMemory : public FGenericPlatformMemory { ... } typedef FWindowsPlatformMemory FPlatformMemory; 

類似的,在安卓下FPlatformMemory是FAndroidPlatformMemory的別名……

這些內存管理類都繼承FGenericPlatformMemory類,其實這個內存管理類不僅提供了初始化內存分配器的接口,還為內存分配器提供了BinnedAllocFromOS、BinnedFreeToOS等接口。

 

 

Binned內存分配器講解

Binned內存分配器對小內存進行管理,大的內存直接調用操作系統的接口進行申請和釋放。UE4提供了40多種不同大小的內存池進行管理,(Size大小不在表里的,向上取最近值)。

 

	static const uint32 BlockSizes[POOL_COUNT] = { 16, 32, 48, 64, 80, 96, 112, 128, //單位都是byte 160, 192, 224, 256, 288, 320, 384, 448, 512, 576, 640, 704, 768, 896, 1024, 1168, 1360, 1632, 2048, 2336, 2720, 3264, 4096, 4672, 5456, 6544, 8192, 9360, 10912, 13104, 16384, 21840, 32768 }; 

 

Binned內存分配器數據結構

要想理解Binned內存分配器,就要理解其中內存塊的數據結構,總結如圖所示:

 


  • MemSizeToPoolTable:是一個FPoolTable數組,將不同大小Size映射到對應的FPoolTable。MallocBinned的初始化主要就是對所有PoolTable的初始化和MemSizeToPoolTable的初始化。

  • FPoolTable:管理一類大小槽位的Pool

    • FPoolInfo* FirstPool:指向可用的Pool。
    • FPoolInfo* ExhaustedPool:指向已滿的Pool。
    • uint32 BlockSize 該內存池管理的內存塊大小
  • FPoolInfo:用來管理一個Pool,同一個PoolTable中的PoolInfo使用雙向鏈表鏈接起來。(PoolInfo實例使用HashBuckets管理)

    • uint16 Taken: Pool中已分配的元素數量,減為0時可釋放pool中的FFreeMem內存,Pool會從FPoolTable里面踢除,但是不會立即釋放,會緩存起來。
    • FFreeMem* FirstMem:指向Pool中可用槽位的起始地址,如果PoolInfo描述操作系統直接分配的大內存塊,這個值存儲分配的大小。
    • FPoolInfo* Next:指向下一個PoolInfo
    • FPoolInfo* PrevLink:指向前一個PoolInfo
    • uint16 TableIndex:首次用該Pool存儲數據時,槽位內分配的內存大小(並不是Index)
  • FFreeMem:描述了一塊可分配內存,雖然位於Pool的內存塊頭部,但是不占分配內存。FFreeMem在被分配之后會完全歸屬調用者,不占用內存。(只有未被分配的內存塊才會存儲FFreeMem的數據,被分配后的塊會全部被應用程序管理)

    • FFreeMem* Next:下一個可用槽位
    • uint32 NumFreeBlocks:連續空閑槽位的數量。
    • 這個數據結構在初始的時候可以看做是一個簡單的數組,隨着程序釋放運行的過程會退化成鏈表的結構,一些設計細節這里並不多講。
    • FFreeMem是幾個連續的內存塊組成,其所有內存塊之和為操作系統的一個頁。由於是直接通過系統調用申請的,所以地址頁對齊,起始地址的最后16位都是0。
  • HashBuckets:

    • 一個哈希表,把FFreeMem的起始地址與FPoolInfo對應。
    • 主要功能是可以在釋放指針的時候可以直接通過內存地址來尋找其對應的PoolInfo便於釋放。
    • 剛才提到FFreeMem的起始地址以0x0000結尾,所以每個指針被釋放的時候可以通過地址位運算來直接找到到起始FFreeMem,進而找到FPoolInfo。

 

Malloc申請內存主要流程

理解完內存分配器的結構模型之后,對申請內存的流程便會很輕松理解:
注:本圖和Malloc的實際流程有一些偏差,其中忽略了一些細節與優化的部分,僅僅表達了主要流程。

申請內存主要先在Size對應的PoolTable里面尋找到可用的Pool,如果沒有就創建一個。每個Pool里面存了1頁大小的內存,每次從中間申請對應大小的內存塊。

 

 

Free釋放流程

內存釋放流程整體相對簡單,主要理解一點:被釋放的指針要先通過位運算找到頁起始地址,然后在通過頁起始地址找到對應的Pool。

 

 

MallocBinned的優缺點簡單分析

注:該一下分析只是通過算法特性來進行簡單的分析,其中主要以個人觀點為主,還沒有進行測試試驗。

優點:

  • MallocBinned在分配大量Size相近的內存塊時表現良好。

    1. 從數據結構中看出Binned分配器會盡量的把內存大小相似的內存塊放在同一頁上,由於操作系統的緩存機制經常訪問同一頁的內存效率會更高一些。
    2. 由於內存相似度比較高的內存片公用一個內存池,所以在此情況下Binned的內存利用率會更高一些。
  • 申請速度比較快,在大部分分配內存的情況下,每一次內存分配,Binned只是簡單的從固定內存池里面找到一塊可用的內存,沒有做過多的操作;及時偶爾內存池不夠用了,重新申請一篇內存池也並沒有太高的消費。整體下來每一次操作都是穩定O(1)的。

  • 無外部內存碎片,binnned內存分配器不會出現無法利用到的零散的內存碎片。(如果外部碎片過多,內存就會變得難以管理)。

缺點:

  • 與優點相對,Binned在申請內存分布及其不均勻的情況下表現不太良好,極端情況下可能每個指針都要占領1頁的內存,這樣不僅內存利用率極其低下,內存訪問效率也不是很友好。
  • 內部碎片較多。在申請513、1025大小的內存時候,由於並沒有恰好合適的內存池,只能從大一點的內存池中分配內存,導致一些內存浪費。

 

glibc內存分配器講解

 

X86 平台 Linux 進程內存布

由於glibc是linux下的c語言庫,所以想要了解glibc的內存分配器就要了解Linux中進程的內存布局。

如圖所示是一個 X86平台Linux32位下進程默認內存布局,64位會更大一些,但是差不多。

Kernel space:儲存操作系統相關的一些數據
Stack:棧區,C語言運行過程中局部變量的存儲位置,即用即回收。
MMemory Mapping Region:使用mmap分配此片內存,可以用於文件映射,也可以直接分配操作系統物理頁直接使用(在glibc中用於大內存的分配)。
Heap:堆區,在glibc中用於管理動態申請的小片內存。
bss段(bss segment):通常用來存放程序中未初始化的全局變量的一塊內存區域。
data段:數據段(data segment)通常是指用來存放程序中已初始化的全局變量的一塊內存區域。數據段屬於靜態內存分配。
text段:代碼段(code segment/text segment)通常是指用來存放程序執行代碼的一塊內存區域。這部分區域的大小在程序運行前就已經確定,並且內存區域通常屬於只讀(某些架構也允許代碼段為可寫,即允許修改程序)。在代碼段中,也有可能包含一些只讀的常數變量,例如字符串常量等。

 

 

brk、sbrk和mmap、munmap

brk和sbrk的功能差不多,都是改變heap頂部的位置,可以指定向上增長,也可以指定向下減少。移動的目的是將程序虛擬地址映射到內存,但是移動brk只是簡單的映射並沒有實際申請物理頁,只有真正訪問該片內存時候才會實際分配物理頁。

mmap和munmap在linux中有文件映射的功能,但是在內存管理器中我們可以簡單的理解他只是簡單的從memory maping region中申請和釋放一片內存。(申請大小會頁對齊)。

 

基本的數據結構

和ue4的內存分配器類似,glibc的內存分配器也只是對小內存指定,大內存走的是另一套系統(mmap)。而小內存使用brk和sbrk來管理。

  1. chunk:用戶申請分配的內存都以一個chunk來表示:

  2. Bin:被用戶free掉的內存不會立刻還給操作系統,而是會存在來放到鏈表中,每個鏈表稱為一個bin。其中小於512k的叫做small bins,大於512k的叫做large bins。二者的區別在於申請不足的內存塊是largebins會將內存塊分割后返回(如果程序要申請600b的內存,但是bin里面只提供了640大小的內存,這時候會分割成600+40兩塊內存,並將600的返回給程序,40的放到unsorted bin里面)。:

 

 

ptmalloc

  1. UnsortedBin
    Bin數組的第一個,存儲了不同大小的內存塊。可以看做是Bin的一個緩存區。

  2. Top chunk
    永遠在堆頂的一個空閑chunk塊,如果釋放的內存和top chunk相鄰,則會合並到topchunk,如果topchunk超過一定大小,則會釋放這片內存並棧頂指針下移。

 

ptmalloc申請、釋放內存流程

這里沒有看源碼,只是通過一些資料了解了簡單的流程並繪制此圖。

ptmalloc申請內存流程如圖所示

 


相對於申請內存來說,釋放內存的過程比較簡單

  1. chunk和top chunk相鄰,則和top chunk合並。如果此時top chunk足夠大,則調用brk移動堆頂指針,減少堆內存占用。
  2. chunk和top chunk不相鄰,則直接插入到unsorted_list中

 

ptmalloc優缺點分析

優點:

  • 作為基礎的庫,ptmalloc相對來說通用性更強一些,對各種大小內存分配都比較適配。
  • 由於回收內存的操作比較少,所以回收內存的效率也比較高。

缺點:

  • 雖然開始的時候ptmalloc分配的內存是從堆中一個個取得,但是隨着程序得內存申請和釋放,返回得內存地址就會變得非常不連續,局部緩存性也會比較差。
  • 在管理長周期對象時,如果對象地址恰好在堆頂會很容易產生內存空洞,中間很大一部分內存即使被程序釋放了也無法返還給操作系統。
  • 在分配內存時,由於流程過於復雜,極端情況下要遍歷很多鏈表,導致ptmalloc申請內存的時候也非常不友好。

 

總結與反思

 

MallocBinned和pt_malloc對比

這些對比只是看簡單的算法實現下的猜測,並沒有實際的數據驗證,所以僅供參考。

參考《glibc內存管理ptmalloc源代碼分析》中對內存管理器的設計目標,對內存管理器的評價需要考慮以下內容:

  1. 最大化兼容性:這個不作對比。
  2. 最大化可移植性:不作對比
  3. (內存管理器本身)浪費最小的空間:雖然mallocbinned中數據結構參數眾多,並沒有對單個內存塊單獨做數據結構進行維護。所以這點我覺得應該是mallocbinned更優一些。
  4. (內存分配、釋放)最快的速度:由於UE4在申請內存時候的流程並沒有涉及太多的遍歷操作,每個步驟都是穩定O(1)的,所以Mallocbinned更優。
  5. 最大化局部性:由於UE4會盡量把相同大小的內存塊放到同一頁上,glibc分配的內存塊相對散亂,所以UE4更優一些。
  6. 最大化調試功能:不做比較
  7. 最大化適應性(通用性):前面提到了,由於glibc身為基礎的內存分配器,通用性相對更好一些。

 

問題解答

Q1:為什么有這么多的內存分配器,我們直接調用操作系統的接口來分配內存不好嗎?
A:主要原因有兩點:

  • 首先當前流行的操作系統大部分都是段頁式管理,操作系統底層接口都是以頁來分配的,所以需要有一個東西把這些內存管理起來。
  • 操作系統提供的內存分配接口都是系統調用,總所周知系統調用的耗費是比較大的,malloc作為一個高頻率調用的函數自然不能頻繁的進行系統調用,所以自然要用池機制緩存起來。

 

Q2:UE4為什么要自己造輪子?
A:這個問題的解釋有很多,這里選擇幾點進行說明。

  • 雖然現在很多基礎的C語言函數庫都提供了很優秀的內存管理器,但是因為UE4要考慮到各種設備,所以自己也提供了一套內存管理器。
  • 因為很多基礎C語言庫中的內存分配器都很優秀,但是大部分因為需要考慮到通用性這個點折損了太多東西。相對來說UE4可以不用考慮太多的通用性。
  • 除了效率之外,UE4自己提供內存分配器更是方便了自己做內存跟蹤、內存檢查等功能。
  • 另外UE4不僅提供了自己的內存分配器,而且提供了相關的參數以便選擇,可以為用戶結合自己的項目提供更多的選擇。

 

反思

了解了Binned這么多東西,有沒有哪些知識可以對項目帶來什么幫助呢?

這個問題需要不斷的思考與試驗,目前有一個想法是,binned目前提供的四十多個大小的內存池中,每個blocksize都是UE4自定的。關於這個點我覺得我們可以根據項目中每個內存塊的大小來計算一下我們選擇哪些blockSize比較合適,比如我們項目中如果大量申請336b大小的內存,那我們是否可以單獨加一個336b大小的blocksize。但是這些想法還需要不斷驗證。

 

參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM