深度閱讀:深刻 C++ 內存管理(萬字長文)

引言前端

說到 C++ 的內存管理,咱們可能會想到棧空間的本地變量、堆上經過 new 動態分配的變量以及全局命名空間的變量等,這些變量的分配位置都是由系統來控制管理的,而調用者只須要考慮變量的生命週期相關內容便可,而無需關心變量的具體佈局。這對於普通軟件的開發已經足夠,但對於引擎開發而言,咱們必須對內存有着更爲精細的管理。算法

基礎概念

在文章的開篇,先對一些基礎概念進行簡單的介紹,以便可以更好地理解後續的內容。express

內存佈局

 

 

C/C++的學習裙【七一二 二八四 七零五 】,不管你是小白仍是進階者,是想轉行仍是想入行均可以來了解一塊兒進步一塊兒學習!裙內有開發工具,不少乾貨和技術資料分享!數組

 

 

內存分佈(可執行映像)緩存

如圖,描述了C++程序的內存分佈。安全

Code Segment(代碼區)數據結構

也稱Text Segment,存放可執行程序的機器碼。app

Data Segment (數據區)ide

存放已初始化的全局和靜態變量, 常量數據(如字符串常量)。函數

BSS(Block started by symbol)

存放未初始化的全局和靜態變量。(默認設爲0)

Heap(堆)

從低地址向高地址增加。容量大於棧,程序中動態分配的內存在此區域。

Stack(棧)

從高地址向低地址增加。由編譯器自動管理分配。程序中的局部變量、函數參數值、返回變量等存在此區域。

 

函數棧

如上圖所示,可執行程序的文件包含BSS,Data Segment和Code Segment,當可執行程序載入內存後,系統會保留一些空間,即堆區和棧區。堆區主要是動態分配的內存(默認狀況下),而棧區主要是函數以及局部變量等(包括main函數)。通常而言,棧的空間小於堆的空間。

當調用函數時,一塊連續內存(堆棧幀)壓入棧;函數返回時,堆棧幀彈出。

堆棧幀包含以下數據:

① 函數返回地址

② 局部變量/CPU寄存器數據備份

 

 

函數壓棧

 

全局變量

當全局/靜態變量(以下代碼中的x和y變量)未初始化的時候,它們記錄在BSS段。

int x;
int z = 5;
void func()
{
     static int y;
}
int main()
{
    return 0;
}

處於BSS段的變量的值默認爲0,考慮到這一點,BSS段內部無需存儲大量的零值,而只需記錄字節個數便可。

系統載入可執行程序後,將BSS段的數據載入數據段(Data Segment) ,並將內存初始化爲0,再調用程序入口(main函數)。

而對於已經初始化了的全局/靜態變量而言,如以上代碼中的z變量,則一直存儲於數據段(Data Segment)。

 

內存對齊

對於基礎類型,如float, double, int, char等,它們的大小和內存佔用是一致的。而對於結構體而言,若是咱們取得其sizeof的結果,會發現這個值有可能會大於結構體內全部成員大小的總和,這是因爲結構體內部成員進行了內存對齊。

爲何要進行內存對齊

① 內存對齊使數據讀取更高效

在硬件設計上,數據讀取的處理器只能從地址爲k的倍數的內存處開始讀取數據。這種讀取方式至關於將內存分爲了多個"塊「,假設內存能夠從任意位置開始存放的話,數據極可能會被分散到多個「塊」中,處理分散在多個塊中的數據須要移除首尾不須要的字節,再進行合併,很是耗時。

爲了提升數據讀取的效率,程序分配的內存並非連續存儲的,而是按首地址爲k的倍數的方式存儲;這樣就能夠一次性讀取數據,而不須要額外的操做。

 

 

讀取非對齊內存的過程示例

② 在某些平臺下,不進行內存對齊會崩潰

內存對齊的規則

定義有效對齊值(alignment)爲結構體中 最寬成員 和 編譯器/用戶指定對齊值 中較小的那個。

(1) 結構體起始地址爲有效對齊值的整數倍

(2) 結構體總大小爲有效對齊值的整數倍

(3) 結構體第一個成員偏移值爲0,以後成員的偏移值爲 min(有效對齊值, 自身大小) 的整數倍

至關於每一個成員要進行對齊,而且整個結構體也須要進行對齊。

示例

struct A
{
    int i;
    char c1;
    char c2;
};

int main()
{
    cout << sizeof(A) << endl; // 有效對齊值爲4, output : 8
    return 0;
}

 

 

內存排布示例

 

C/C++的學習裙【七一二 二八四 七零五 】,不管你是小白仍是進階者,是想轉行仍是想入行均可以來了解一塊兒進步一塊兒學習!裙內有開發工具,不少乾貨和技術資料分享!

 

內存碎片

程序的內存每每不是緊湊連續排布的,而是存在着許多碎片。咱們根據碎片產生的緣由把碎片分爲內部碎片和外部碎片兩種類型:

(1) 內部碎片:系統分配的內存大於實際所需的內存(因爲對齊機制);

(2) 外部碎片:不斷分配回收不一樣大小的內存,因爲內存分佈散亂,較大內存沒法分配;

 

 

內部碎片和外部碎片

爲了提升內存的利用率,咱們有必要減小內存碎片,具體的方案將在後文重點介紹。

 

繼承類佈局

繼承

若是一個類繼承自另外一個類,那麼它自身的數據位於父類以後。

含虛函數的類

若是當前類包含虛函數,則會在類的最前端佔用4個字節,用於存儲虛表指針(vpointer),它指向一個虛函數表(vtable)。

vtable中包含當前類的全部虛函數指針。

字節序(endianness)

大於一個字節的值被稱爲多字節量,多字節量存在高位有效字節和低位有效字節 (關於高位和低位,咱們以十進制的數字來舉例,對於數字482來講,4是高位,2是低位),微處理器有兩種不一樣的順序處理高位和低位字節的順序:

● 小端(little_endian):低位有效字節存儲於較低的內存位置

● 大端(big_endian):高位有效字節存儲於較低的內存位置

咱們使用的PC開發機默認是小端存儲。

 

 

大小端排布

通常狀況下,多字節量的排列順序對編碼沒有影響。但若是要考慮跨平臺的一些操做,就有必要考慮到大小端的問題。以下圖,ue4引擎使用了PLATFORM_LITTLE_ENDIAN這一宏,在不一樣平臺下對數據作特殊處理(內存排布交換,確保存儲時的結果一致)。

 

 

ue4針對大小端對數據作特殊處理(ByteSwap.h)

 

C/C++的學習裙【七一二 二八四 七零五 】,不管你是小白仍是進階者,是想轉行仍是想入行均可以來了解一塊兒進步一塊兒學習!裙內有開發工具,不少乾貨和技術資料分享!

 

操做系統

對一些基礎概念有所瞭解後,咱們能夠來關注操做系統底層的一些設計。在掌握了這些特性後,咱們才能更好地針對性地編寫高性能代碼。

SIMD

SIMD,即Single Instruction Multiple Data,用一個指令並行地對多個數據進行運算,是CPU基本指令集的擴展。

例一

處理器的寄存器一般是32位或者64位的,而圖像的一個像素點可能只有8bit,若是一次只能處理一個數據比較浪費空間;此時能夠將64位寄存器拆成8個8位寄存器,就能夠並行完成8個操做,提高效率。

例二

SSE指令採用128位寄存器,咱們一般將4個32位浮點值打包到128位寄存器中,單個指令可完成4對浮點數的計算,這對於矩陣/向量操做很是友好(除此以外,還有Neon/FPU等寄存器)

 

 

SIMD並行計算

高速緩存

通常來講CPU以超高速運行,而內存速度慢於CPU,硬盤速度慢於內存。

當咱們把數據加載內存後,要對數據進行必定操做時,會將數據從內存載入CPU寄存器。考慮到CPU讀/寫主內存速度較慢,處理器使用了高速的緩存(Cache),做爲內存到CPU中間的媒介。

 

 

L1緩存和L2緩存

引入L1和L2緩存後,CPU和內存之間的將沒法進行直接的數據交互,而是須要通過兩級緩存(目前也已出現L3緩存)。

① CPU請求數據:若是數據已經在緩存中,則直接從緩存載入寄存器;若是數據不在緩存中(緩存命中失敗),則須要從內存讀取,並將內存載入緩存中。

② CPU寫入數據:有兩種方案,(1) 寫入到緩存時同步寫入內存(write through cache) (2) 僅寫入到緩存中,有必要時再寫入內存(write-back)

爲了提升程序性能,則須要儘量避免緩存命中失敗。通常而言,遵循儘量地集中連續訪問內存,減小」跳變「訪問的原則(locality of reference)。這裏其實隱含了兩個意思,一個是內存空間上要儘量連續,另一個是訪問時序上要儘量連續。像節點式的數據結構的遍歷就會差於內存連續性的容器。

虛擬內存

虛擬內存,也就是把不連續的物理內存塊映射到虛擬地址空間(virtual address space)。使內存頁對於應用程序來講看起來是連續的。通常而言,出於程序安全性和物理內存可能不足的考慮,咱們的程序都會運行在虛擬內存上。

這意味着,每一個程序都有本身的地址空間,咱們使用的內存存在一個虛擬地址和一個物理地址,二者之間須要進行地址翻譯。

缺頁

在虛擬內存中,每一個程序的地址空間被劃分爲多個塊,每一個內存塊被稱做頁,每一個頁的包含了連續的地址,而且被映射到物理內存。並不是全部頁都在物理內存中,當咱們訪問了不在物理內存中的頁時,這一現象稱爲缺頁,操做系統會從磁盤將對應內容裝載到物理內存;當內存不足,部分頁也會寫回磁盤。

在這裏,咱們將CPU,高速緩存和主存視爲一個總體,統稱爲DRAM。因爲DRAM與磁盤之間的讀寫也比較耗時,爲了提升程序性能,咱們依然須要確保本身的程序具備良好的「局部性」——在任意時刻都在一個較小的活動頁面上工做。

分頁

當使用虛擬內存時,會經過MMU將虛擬地址映射到物理內存,虛擬內存的內存塊稱爲頁,而物理內存中的內存塊稱爲頁框,二者大小一致,DRAM和磁盤之間以頁爲單位進行交換。

簡單來講,若是想要從虛擬內存翻譯到物理地址,首先會從一個TLB(Translation Lookaside Buffer)的設備中查找,若是找不到,在虛擬地址中也記錄了虛擬頁號和偏移量,能夠先經過虛擬頁號找到頁框號,再經過偏移量在對應頁框進行偏移,獲得物理地址。爲了加速這個翻譯過程,有時候還會使用多級頁表,倒排頁表等結構。

 

置換算法

到目前爲止,咱們已經接觸了很多和「置換」有關的內容:例如寄存器和高速緩存之間,DRAM和磁盤之間,以及TLB的緩存等。這個問題的本質是,咱們在有限的空間內存儲了一些快速查詢的結構,可是咱們沒法存儲全部的數據,因此當查詢未命中時,就須要花更大的代價,而所謂置換,也就是咱們的快速查詢結構是在不斷更新的,會隨着咱們的操做,使得一部分數據被裝在到快速查詢結構中,又有另外一部分數據被卸載,至關於完成了數據的置換。

常見的置換有以下幾種:

● 最近未使用置換(NRU)

出現未命中現象時,置換最近一個週期未使用的數據。

● 先入先出置換(FIFO)

出現未命中現象時,置換最先進入的數據。

● 最近最少使用置換(LRU)

出現未命中現象時,置換未使用時間最長的數據。

 

C++語法

位域(Bit Fields)

表示結構體位域的定義,指定變量所佔位數。它一般位於成員變量後,用 聲明符:常量表達式 表示。(參考資料)

聲明符是可選的,匿名字段可用於填充。

如下是ue4中Float16的定義:

struct
{
#if PLATFORM_LITTLE_ENDIAN
    uint16 Mantissa : 10;
    uint16 Exponent : 5;
    uint16 Sign : 1;
#else
    uint16 Sign : 1;
    uint16 Exponent : 5;
    uint16 Mantissa : 10;   
#endif
} Components;

new和placement new

new是C++中用於動態內存分配的運算符,它主要完成了如下兩個操做:

① 調用operator new()函數,動態分配內存。

② 在分配的動態內存塊上調用構造函數,以初始化相應類型的對象,並返回首地址。

當咱們調用new時,會在堆中查找一個足夠大的剩餘空間,分配並返回;當咱們調用delete時,則會將該內存標記爲再也不使用,而指針仍然執行原來的內存。

new的語法

::(optional) new (placement_params)(optional) ( type ) initializer(optional)

● 通常表達式

p_var = new type(initializer); // p_var = new type{initializer};

● 對象數組表達式

p_var = new type[size]; // 分配
delete[] p_var; // 釋放

● 二維數組表達式

auto p = new double[2][2];
auto p = new double[2][2]{ {1.0,2.0},{3.0,4.0} };

● 不拋出異常的表達式

new (nothrow) Type (optional-initializer-expression-list)

默認狀況下,若是內存分配失敗,new運算符會選擇拋出std::bad_alloc異常,若是加入nothrow,則不拋出異常,而是返回nullptr。

● 佔位符類型

咱們可使用placeholder type(如auto/decltype)指定類型:

auto p = new auto('c');

● 帶位置的表達式(placement new)

能夠指定在哪塊內存上構造類型。

它的意義在於咱們能夠利用placement new將內存分配和構造這兩個模塊分離(後續的allocator更好地踐行了這一律念),這對於編寫內存管理的代碼很是重要,好比當咱們想要編寫內存池的代碼時,能夠預申請一塊內存,而後經過placement new申請對象,一方面能夠避免頻繁調用系統new/delete帶來的開銷,另外一方面能夠本身控制內存的分配和釋放。

預先分配的緩衝區能夠是堆或者棧上的,通常按字節(char)類型來分配,這主要考慮瞭如下兩個緣由:

① 方便控制分配的內存大小(經過sizeof計算便可)

② 若是使用自定義類型,則會調用對應的構造函數。可是既然要作分配和構造的分離,咱們其實是不指望它作任何構造操做的,並且對於沒有默認構造函數的自定義類型,咱們是沒法預分配緩衝區的。

如下是一個使用的例子:

class A
{
private:
 int data;
public:
 A(int indata) 
  : data(indata) { }
 void print()
 {
  cout << data << endl;
 }
};
int main()
{
 const int size = 10;
 char buf[size * sizeof(A)]; // 內存分配
 for (size_t i = 0; i < size; i++)
 {
  new (buf + i * sizeof(A)) A(i); // 對象構造
 }
 A* arr = (A*)buf;
 for (size_t i = 0; i < size; i++)
 {
  arr[i].print();
  arr[i].~A(); // 對象析構
 }
 // 棧上預分配的內存自動釋放
 return 0;
}

和數組越界訪問不必定崩潰相似,這裏若是在未分配的內存上執行placement new,可能也不會崩潰。

● 自定義參數的表達式

當咱們調用new時,實際上執行了operator new運算符表達式,和其它函數同樣,operator new有多種重載,如上文中的placement new,就是operator new如下形式的一個重載:

 

 

placement new的定義

新語法(C++17)還支持帶對齊的operator new:

 

 

aligned new的聲明

調用示例:

auto p = new(std::align_val_t{ 32 }) A;

new的重載

在C++中,咱們通常說new和delete動態分配和釋放的對象位於自由存儲區(free store),這是一個抽象概念。默認狀況下,C++編譯器會使用堆實現自由存儲。

前文已經說起了new的幾種重載,包括數組,placement,align等。

若是咱們想要實現本身的內存分配自定義操做,咱們能夠有以下兩個方式:

① 編寫重載的operator new,這意味着咱們的參數須要和全局operator new有差別。

② 重定義operator new,根據名字查找規則,會優先在申請內存的數據內部/數據定義處查找new運算符,未找到纔會調用全局::operator new()。

須要注意的是,若是該全局operator new已經實現爲inline函數,則咱們不能重定義相關函數,不然沒法經過編譯,以下:

// Default placement versions of operator new.
inline void* operator new(std::size_t, void* __p) throw() { return __p; }
inline void* operator new[](std::size_t, void* __p) throw() { return __p; }

// Default placement versions of operator delete.
inline void  operator delete  (void*, void*) throw() { }
inline void  operator delete[](void*, void*) throw() { }

可是,咱們能夠重寫以下nothrow的operator new:

void* operator new(std::size_t, const std::nothrow_t&) throw();
void* operator new[](std::size_t, const std::nothrow_t&) throw();
void operator delete(void*, const std::nothrow_t&) throw();
void operator delete[](void*, const std::nothrow_t&) throw();

爲何說new是低效的

① 通常來講,操做越簡單,意味着封裝了更多的實現細節。new做爲一個通用接口,須要處理任意時間、任意位置申請任意大小內存的請求,它在設計上就沒法兼顧一些特殊場景的優化,在管理上也會帶來必定開銷。

② 系統調用帶來的開銷。多數操做系統上,申請內存會從用戶模式切換到內核模式,當前線程會block住,上下文切換將會消耗必定時間。

③ 分配多是帶鎖的。這意味着分配難以並行化。

 

alignas和alignof

不一樣的編譯器通常都會有默認的對齊量,通常都爲2的冪次。

在C中,咱們能夠經過預編譯命令修改對齊量:

#pragma pack(n)

在內存對齊篇已經說起,咱們最終的有效對齊量會取結構體最寬成員 和 編譯器默認對齊量(或咱們本身定義的對齊量)中較小的那個。

C++中也提供了相似的操做:

alignas

用於指定對齊量。

能夠應用於類/結構體/union/枚舉的聲明/定義;非位域的成員變量的定義;變量的定義(除了函數參數或異常捕獲的參數);

alignas會對對齊量作檢查,對齊量不能小於默認對齊,以下面的代碼,struct U的對齊設置是錯誤的:

struct alignas(8) S 
{
    // ...
};

struct alignas(1) U 
{
    S s;
};

如下對齊設置也是錯誤的:

struct alignas(2) S {
 int n;
};

此外,一些錯誤的格式也沒法經過編譯,如:

struct alignas(3) S { };

例子:

// every object of type sse_t will be aligned to 16-byte boundary
struct alignas(16) sse_t
{
    float sse_data[4];
};

// the array "cacheline" will be aligned to 128-byte boundary
alignas(128)
char cacheline[128];

alignof operator

返回類型的std::size_t。若是是引用,則返回引用類型的對齊方式,若是是數組,則返回元素類型的對齊方式。

例子:

struct Foo {
    int i;
    float f;
    char c;
};

struct Empty { };

struct alignas(64) Empty64 { };

int main()
{
    std::cout << "Alignment of" "\n"
                 "- char          :"    << alignof(char)    << "\n"   // 1
                 "- pointer       :"    << alignof(int*)    << "\n"   // 8
                 "- class Foo     :"    << alignof(Foo)     << "\n"   // 4
                 "- empty class   :"    << alignof(Empty)   << "\n"   // 1
                 "- alignas(64) Empty:" << alignof(Empty64) << "\n";  // 64
}

std::max_align_t

通常爲16bytes,malloc返回的內存地址,對齊大小不能小於max_align_t。

 

allocator

當咱們使用C++的容器時,咱們每每須要提供兩個參數,一個是容器的類型,另外一個是容器的分配器。其中第二個參數有默認參數,即C++自帶的分配器(allocator):

template < class T, class Alloc = allocator<T> > class vector; // generic template

咱們能夠實現本身的allocator,只需實現分配、構造等相關的操做。在此以前,咱們須要先對allocator的使用作必定的瞭解。

new操做將內存分配和對象構造組合在一塊兒,而allocator的意義在於將內存分配和構造分離。這樣就能夠分配大塊內存,而只在真正須要時才執行對象建立操做。

假設咱們先申請n個對象,再根據狀況逐一給對象賦值,若是內存分配和對象構造不分離可能帶來的弊端以下:

① 咱們可能會建立一些用不到的對象;

② 對象被賦值兩次,一次是默認初始化時,一次是賦值時;

③ 沒有默認構造函數的類甚至不能動態分配數組;

使用allocator以後,咱們即可以解決上述問題。

分配

爲n個string分配內存:

allocator<string> alloc; // 構造allocator對象
auto const p = alloc.allocate(n); // 分配n個未初始化的string

構造

在剛纔分配的內存上構造兩個string:

auto q = p;
alloc.construct(q++, "hello"); // 在分配的內存處建立對象
alloc.construct(q++, 10, 'c');

銷燬

將已構造的string銷燬:

while(q != p)
    alloc.destroy(--q);

釋放

將分配的n個string內存空間釋放:

alloc.deallocate(p, n);

注意:傳遞給deallocate的指針不能爲空,且必須指向由allocate分配的內存,並保證大小參數一致。

拷貝和填充

uninitialized_copy(b, e, b2)
// 從迭代器b, e 中的元素拷貝到b2指定的未構造的原始內存中;

uninitialized_copy(b, n, b2)
// 從迭代器b指向的元素開始,拷貝n個元素到b2開始的內存中;

uninitialized_fill(b, e, t)
// 從迭代器b和e指定的原始內存範圍中建立對象,對象的值均爲t的拷貝;

uninitialized_fill_n(b, n, t)
// 從迭代器b指向的內存地址開始建立n個對象;

爲何stl的allocator並很差用

若是仔細觀察,咱們會發現不少商業引擎都沒有使用stl中的容器和分配器,而是本身實現了相應的功能。這意味着allocator沒法知足某些引擎開發一些定製化的需求:

① allocator內存對齊沒法控制

② allocator難以應用內存池之類的優化機制

③ 綁定模板簽名

shared_ptr, unique_ptr和weak_ptr

智能指針是針對裸指針可能出現的問題封裝的指針類,它可以更安全、更方便地使用動態內存。

shared_ptr

shared_ptr的主要應用場景是當咱們須要在多個類中共享指針時。

多個類共享指針存在這麼一個問題:每一個類都存儲了指針地址的一個拷貝,若是其中一個類刪除了這個指針,其它類並不知道這個指針已經失效,此時就會出現野指針的現象。爲了解決這一問題,咱們可使用引用指針來計數,僅當檢測到引用計數爲0時,才主動刪除這個數據,以上就是shared_ptr的工做原理。

shared_ptr的基本語法以下:

初始化

shared_ptr<int> p = make_shared<int>(42);

拷貝和賦值

auto p = make_shared<int>(42);
auto r = make_shared<int>(42);
r = q; // 遞增q指向的對象,遞減r指向的對象

只支持直接初始化

因爲接受指針參數的構造函數是explicit的,所以不能將指針隱式轉換爲shared_ptr:

shared_ptr<int> p1 = new int(1024); // err
shared_ptr<int> p2(new int(1024)); // ok

不與普通指針混用

(1) 經過get()函數,咱們能夠獲取原始指針,但咱們不該該delete這一指針,也不該該用它賦值/初始化另外一個智能指針;

(2) 當咱們將原生指針傳給shared_ptr後,就應該讓shared_ptr接管這一指針,而再也不直接操做原生指針。

從新賦值

p.reset(new int(1024));

unique_ptr

有時候咱們會在函數域內臨時申請指針,或者在類中聲明非共享的指針,但咱們頗有可能忘記刪除這個指針,形成內存泄漏。此時咱們能夠考慮使用unique_ptr,由名字可見,某一時刻只有一個unique_ptr指向給定的對象,且它會在析構的時候自動釋放對應指針的內存。

unique_ptr的基本語法以下:

初始化

unique_ptr<string> p = make_unique<string>("test");

不支持直接拷貝/賦值

爲了確保某一時刻只有一個unique_ptr指向給定對象,unique_ptr不支持普通的拷貝或賦值。

unique_ptr<string> p1(new string("test"));
unique_ptr<string> p2(p1); // err
unique_ptr<string> p3;
p3 = p2; // err

全部權轉移

能夠經過調用release或reset將指針的全部權在unique_ptr之間轉移:

unique_ptr<string> p2(p1.release());
unique_ptr<string> p3(new string("test"));
p2.reset(p3.release());

不能忽視release返回的結果

release返回的指針一般用來初始化/賦值另外一個智能指針,若是咱們只調用release,而沒有刪除其返回值,會形成內存泄漏:

p2.release(); // err
auto p = p2.release(); // ok, but remember to delete(p)

支持移動

unique_ptr<int> clone(int p) {
    return unique_ptr<int>(new int(p));
}

weak_ptr

weak_ptr不控制所指向對象的生存期,即不會影響引用計數。它指向一個shared_ptr管理的對象。一般而言,它的存在有以下兩個做用:

(1) 解決循環引用的問題

(2) 做爲一個「觀察者」:

詳細來講,和以前提到的多個類共享內存的例子同樣,使用普通指針可能會致使一個類刪除了數據後其它類沒法同步這一信息,致使野指針;以前咱們提出了shared_ptr,也就是每一個類記錄一個引用,釋放時引用數減一,直到減爲0才釋放。

但在有些狀況下,咱們並不但願當前類影響到引用計數,而是但願實現這樣的邏輯:假設有兩個類引用一個數據,其中有一個類將主動控制類的釋放,而無需等待另一個類也釋放才真正銷燬指針所指對象。對於另外一個類而言,它只須要知道這個指針已經失效便可,此時咱們就可使用weak_ptr。

咱們能夠像以下這樣檢測weak_ptr全部對象是否有效,並在有效的狀況下作相關操做:

auto p = make_shared<int>(42);
weak_ptr<int> wp(p);

if(shared_ptr<int> np = wp.lock())
{
   // ...
}

分配與管理機制

到目前爲止,咱們對內存的概念有了初步的瞭解,也掌握了一些基本的語法。接下來咱們要討論如何進行有效的內存管理。

設計高效的內存分配器一般會考慮到如下幾點:

① 儘量減小內存碎片,提升內存利用率

② 儘量提升內存的訪問局部性

③ 設計在不一樣場合上適用的內存分配器

④ 考慮到內存對齊

含freelist的分配器

咱們首先來考慮一種可以處理任何請求的通用分配器。

一個很是樸素的想法是,對於釋放的內存,經過鏈表將空閒內存連接起來,稱爲freelist。

分配內存時,先從freelist中查找是否存在知足要求的內存塊,若是不存在,再從未分配內存中獲取;當咱們找到合適的內存塊後,分配合適的內存,並將多餘的部分放回freelist。

釋放內存時,將內存插入到空閒鏈表,可能的話,合併先後內存塊。

其中,有一些細節問題值得考慮:

① 空閒空間應該如何進行管理?

咱們知道freelist是用於管理空閒內存的,可是freelist自己的存儲也須要佔用內存。咱們能夠按以下兩種方式存儲freelist:

● 隱式空閒鏈表

將空閒鏈表信息與內存塊存儲在一塊兒。主要記錄大小,已分配位等信息。

● 顯式空閒鏈表

單獨維護一塊空間來記錄全部空閒塊信息。

● 分離適配(segregated-freelist)

將不一樣大小的內存塊放在一塊兒容易形成外部碎片,能夠設置多個freelist,並讓每一個freelist存儲不一樣大小的內存塊,申請內存時選擇知足條件的最小內存塊。

● 位圖

除了freelist以外,還能夠考慮用0,1表示對應內存區域是否已分配,稱爲位圖。

② 分配內存優先分配哪塊內存?

通常而言,從策略不一樣來分,有如下幾種常見的分配方式:

● 首次適應(first-fit):找到的第一個知足大小要求的空閒區

● 最佳適應(best-fit) : 知足大小要求的最小空閒區

● 循環首次適應(next-fit) :在先前中止搜索的地方開始搜索找到的第一個知足大小要求的空閒區

③ 釋放內存後如何放置到空閒鏈表中?

● 直接放回鏈表頭部/尾部

● 按照地址順序放回

這幾種策略本質上都是取捨問題:分配/放回時間複雜度若是低,內存碎片就有可能更多,反之亦然。

buddy分配器

按照一分爲二,二分爲四的原則,直到分裂出一個知足大小的內存塊;合併的時候看buddy是否空閒,若是是就合併。

能夠經過位運算直接算出buddy,buddy的buddy,速度較快。但內存碎片較多。

含對齊的分配器

通常而言,對於通用分配器來講,都應當傳回對齊的內存塊,即根據對齊量,分配比請求多的對齊的內存。

以下,是ue4中計算對齊的方式,它返回和對齊量向上對齊後的值,其中Alignment應爲2的冪次。

template <typename T>
FORCEINLINE constexpr T Align(T Val, uint64 Alignment)
{
 static_assert(TIsIntegral<T>::Value || TIsPointer<T>::Value, "Align expects an integer or pointer type");

 return (T)(((uint64)Val + Alignment - 1) & ~(Alignment - 1));
}

其中~(Alignment - 1) 表明的是高位掩碼,相似於11110000的格式,它將剔除低位。在對Val進行掩碼計算時,加上Alignment - 1的作法相似於(x + a) % a,避免Val值太小獲得0的結果。

單幀分配器模型

用於分配一些臨時的每幀生成的數據。分配的內存僅在當前幀適用,每幀開始時會將上一幀的緩衝數據清除,無需手動釋放。

 

 

雙幀分配器模型

它的基本特色和單幀分配器相近,區別在於第i+1幀適用第i幀分配的內存。它適用於處理非同步的一些數據,避免當前緩衝區被重寫(同時讀寫)

 

 

堆棧分配器模型

堆棧分配器,它的優勢是實現簡單,而且徹底避免了內存碎片,如前文所述,函數棧的設計也使用了堆棧分配器的模型。

 

 

堆棧分配器

雙端堆棧分配器模型

能夠從兩端開始分配內存,分別用於處理不一樣的事務,可以更充分地利用內存。

 

 

雙端堆棧分配器

池分配器模型

池分配器能夠分配大量同尺寸的小塊內存。它的空閒塊也是由freelist管理的,但因爲每一個塊的尺寸一致,它的操做複雜度更低,且也不存在內存碎片的問題。

tcmalloc的內存分配

tcmalloc是一個應用比較普遍的內存分配第三方庫。

對於大於頁結構和小於頁結構的內存塊申請,tcmalloc分別作不一樣的處理。

小於頁的內存塊分配

使用多個內存塊定長的freelist進行內存分配,如:8,16,32……,對實際申請的內存向上「取整」。

freelist採用隱式存儲的方式。

 

 

多個定長的freelist

大於頁的內存塊分配

能夠一次申請多個page,多個page構成一個span。一樣的,咱們使用多個定長的span鏈表來管理不一樣大小的span。

 

 

多個定長的spanlist

對於不一樣大小的對象,都有一個對應的內存分配器,稱爲CentralCache。具體的數據都存儲在span內,每一個CentralCache維護了對應的spanlist。若是一個span能夠存儲多個對象,spanlist內部還會維護對應的freelist。

容器的訪問局部性

因爲操做系統內部存在緩存命中的問題,因此咱們須要考慮程序的訪問局部性,這個訪問局部性實際上有兩層意思:

(1) 時間局部性:若是當前數據被訪問,那麼它將在不久後極可能在此被訪問;

(2) 空間局部性:若是當前數據被訪問,那麼它相鄰位置的數據極可能也被訪問;

咱們來認識一下經常使用的幾種容器的內存佈局:

數組/順序容器:內存連續,訪問局部性良好;

map:內部是樹狀結構,爲節點存儲,沒法保證內存連續性,訪問局部性較差(flat_map支持順序存儲);

鏈表:初始狀態下,若是咱們連續順序插入節點,此時咱們認爲內存連續,訪問較快;但經過屢次插入、刪除、交換等操做,鏈表結構變得散亂,訪問局部性較差;

碎片整理機制

內存碎片幾乎是不可徹底避免的,當一個程序運行必定時間後,將會出現愈來愈多的內存碎片。一個優化的思路就是在引擎底層支持按期地整理內存碎片。

簡單來講,碎片整理經過不斷的移動操做,使全部的內存塊「貼合」在一塊兒。爲了處理指針可能失效的問題,能夠考慮使用智能指針。

因爲內存碎片整理會形成卡頓,咱們能夠考慮將整理操做分攤到多幀完成。

ue4內存管理

自定義內存管理

 

 

ue4的內存管理主要是經過FMalloc類型的GMalloc這一結構來完成特定的需求,這是一個虛基類,它定義了malloc,realloc,free等一系列經常使用的內存管理操做。其中,Malloc的兩個參數分別是分配內存的大小和對應的對齊量,默認對齊量爲0。

/** The global memory allocator's interface. */
class CORE_API FMalloc  : 
 public FUseSystemMallocForNew,
 public FExec
{
public:
 virtual void* Malloc( SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT ) = 0;
 virtual void* TryMalloc( SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT );
 virtual void* Realloc( void* Original, SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT ) = 0;
 virtual void* TryRealloc(void* Original, SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT);
 virtual void Free( void* Original ) = 0;
  
 // ...
};

FMalloc有許多不一樣的實現,如FMallocBinned,FMallocBinned2等,能夠在HAL文件夾下找到相關的頭文件和定義,以下:

 

 

內部經過枚舉量來肯定對應使用的Allocator:

/** Which allocator is being used */
 enum EMemoryAllocatorToUse
 {
  Ansi, // Default C allocator
  Stomp, // Allocator to check for memory stomping
  TBB, // Thread Building Blocks malloc
  Jemalloc, // Linux/FreeBSD malloc
  Binned, // Older binned malloc
  Binned2, // Newer binned malloc
  Binned3, // Newer VM-based binned malloc, 64 bit only
  Platform, // Custom platform specific allocator
  Mimalloc, // mimalloc
 };

對於不一樣平臺而言,都有本身對應的平臺內存管理類,它們繼承自FGenericPlatformMemory,封裝了平臺相關的內存操做。具體而言,包含FAndroidPlatformMemory,FApplePlatformMemory,FIOSPlatformMemory,FWindowsPlatformMemory等。

經過調用PlatformMemory的BaseAllocator函數,咱們取得平臺對應的FMalloc類型,基類默認返回默認的C allocator,而不一樣平臺會有本身特殊的實現。

在PlatformMemory的基礎上,爲了方便調用,ue4又封裝了FMemory類,定義通用內存操做,如在申請內存時,會調用FMemory::Malloc,FMemory內部又會繼續調用GMalloc->Malloc。以下爲節選代碼:

struct CORE_API FMemory
{
 /** @name Memory functions (wrapper for FPlatformMemory) */

 static FORCEINLINE void* Memmove( void* Dest, const void* Src, SIZE_T Count )
 {
  return FPlatformMemory::Memmove( Dest, Src, Count );
 }

 static FORCEINLINE int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count )
 {
  return FPlatformMemory::Memcmp( Buf1, Buf2, Count );
 }

 // ...
 static void* Malloc(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
 static void* Realloc(void* Original, SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
 static void Free(void* Original);
 static SIZE_T GetAllocSize(void* Original);

    // ...
};

爲了在調用new/delete可以調用ue4的自定義函數,ue4內部替換了operator new。這一替換是經過IMPLEMENT_MODULE宏引入的:

 

 

IMPLEMENT_MODULE經過定義REPLACEMENT_OPERATOR_NEW_AND_DELETE宏實現替換,以下圖所示,operator new/delete內實際調用被替換爲FMemory的相關函數。

 

 

FMallocBinned

咱們以FMallocBinned爲例介紹ue4中通用內存的分配。

基本介紹

(1) 空閒內存如何管理?

FMallocBinned使用freelist機制管理空閒內存。每一個空閒塊的信息記錄在FFreeMem結構中,顯式存儲。

(2)不一樣大小內存如何分配?

FMallocBinned使用內存池機制,內部包含POOL_COUNT(42)個內存池和2個擴展的頁內存池;其中每一個內存池的信息由FPoolInfo結構體維護,記錄了當前FreeMem內存塊指針等,而特定大小的全部內存池由FPoolTable維護;內存池內包含了內存塊的雙向鏈表。

(3)如何快速根據分配元素大小找到對應的內存池?

爲了快速查詢當前分配內存大小應該對應使用哪一個內存池,有兩種辦法,一種是二分搜索O(logN),另外一種是打表(O1),考慮到可分配內存數量並不大,MallocBinned選擇了打表的方式,將信息記錄在MemSizeToPoolTable。

(4)如何快速刪除已分配內存?

爲了可以在釋放的時候以O(1)時間找到對應內存池,FMallocBinned維護了PoolHashBucket結構用於跟蹤內存分配的記錄。它組織爲雙向鏈表形式,存儲了對應內存塊和鍵值。

內存池

● 多個小對象內存池(內存池大小均爲PageSize,但存儲的數據量不同)。數據塊大小設定以下:

 

 

● 兩個額外的頁內存池,管理大於一個頁的內存池,大小爲3*PageSize和6*PageSize

● 操做系統的內存池

分配策略

分配內存的函數爲void* FMallocBinned::Malloc(SIZE_T Size, uint32 Alignment)。

其中第一個參數爲須要分配的內存的大小,第二個參數爲對齊的內存數。

若是用戶未指定對齊的內存大小,MallocBinned內部會默認對齊於16字節,若是指定了大於16字節的對齊內存大小,則對齊於用戶指定的對齊大小。根據對齊量,計算出最終實際分配的內存大小。

MallocBinned內部對於不一樣的內存大小有三種不一樣的處理:

(1) 分配小塊內存(0,PAGE_SIZE_LIMIT/2)

根據分配大小從MemSizeToPoolTable中獲取對應內存池,並從內存池的當前空閒位置讀取一塊內存,並移動當前內存指針。若是移動後的內存指針指向的內存塊已經使用,則將指針移動到FreeMem鏈表的下一個元素;若是當前內存池已滿,將該內存池移除,並連接到耗盡的內存池。

若是當前內存池已經用盡,下次內存分配時,檢測到內存池用盡,會從系統從新申請一塊對應大小的內存池。

(2) 分配大塊內存 [PAGE_SIZE_LIMIT/2, PAGE_SIZE_LIMIT*3/4]∪(PageSize,PageSize + PAGE_SIZE_LIMIT/2)

須要從額外的頁內存池分配,分配方式和(1)同樣。

(3) 分配超大內存

從系統內存池中分配。

Allocator

對於ue4中的容器而言,它的模板有兩個參數,第一個是元素類型,第二個就是對應的分配器(Allocator):

template<typename InElementType, typename InAllocator>
class TArray
{
   // ...
};

以下圖,容器通常都指定了本身默認的分配器:

 

 

默認的堆分配器

template <int IndexSize>
class TSizedHeapAllocator { ... };

// Default Allocator
using FHeapAllocator = TSizedHeapAllocator<32>;

默認狀況下,若是咱們不指定特定的Allocator,容器會使用大小類型爲int32堆分配器,默認由FMemory控制分配(和new一致)

含對齊的分配器

template<uint32 Alignment = DEFAULT_ALIGNMENT>
class TAlignedHeapAllocator
{
    // ...
};

由FMemory控制分配,含對齊。

可擴展大小的分配器

template <uint32 NumInlineElements, typename SecondaryAllocator = FDefaultAllocator>
class TInlineAllocator
{
    //...
};

可擴展大小的分配器存儲大小爲NumInlineElements的定長數組,當實際存儲的元素數量高於NumInlineElements時,會從SecondaryAllocator申請分配內存,默認狀況下爲堆分配器。

對齊量總爲DEFAULT_ALIGNMENT。

不可重定位的可擴展大小的分配器

template <uint32 NumInlineElements>
class TNonRelocatableInlineAllocator
{
    // ...
};

在支持第二分配器的基礎上,容許第二分配器存儲指向內聯元素的指針。這意味着Allocator不該作指針重定向的操做。但ue4的Allocator一般依賴於指針重定向,所以該分配器不該用於其它Allocator容器。

固定大小的分配器

template <uint32 NumInlineElements>
class TFixedAllocator
{
    // ...
};

相似於InlineAllocator,會分配固定大小內存,區別在於當內聯存儲耗盡後,不會提供額外的分配器。

稀疏數組分配器

template<typename InElementAllocator = FDefaultAllocator,typename InBitArrayAllocator = FDefaultBitArrayAllocator>
class TSparseArrayAllocator
{
public:

 typedef InElementAllocator ElementAllocator;
 typedef InBitArrayAllocator BitArrayAllocator;
};

稀疏數組自己的定義比較簡單,它主要用於稀疏數組(Sparse Array),相關的操做也在對應數組類中完成。稀疏數組支持不連續的下標索引,經過BitArrayAllocator來控制分配哪一個位是可用的,可以以O(1)的時間刪除元素。

默認使用堆分配。

哈希分配器

template<
 typename InSparseArrayAllocator               = TSparseArrayAllocator<>,
 typename InHashAllocator                      = TInlineAllocator<1,FDefaultAllocator>,
 uint32   AverageNumberOfElementsPerHashBucket = DEFAULT_NUMBER_OF_ELEMENTS_PER_HASH_BUCKET,
 uint32   BaseNumberOfHashBuckets              = DEFAULT_BASE_NUMBER_OF_HASH_BUCKETS,
 uint32   MinNumberOfHashedElements            = DEFAULT_MIN_NUMBER_OF_HASHED_ELEMENTS
 >
class TSetAllocator
{
public:
 static FORCEINLINE uint32 GetNumberOfHashBuckets(uint32 NumHashedElements) { //... }

 typedef InSparseArrayAllocator SparseArrayAllocator;
 typedef InHashAllocator        HashAllocator;
};

用於TSet/TMap等結構的哈希分配器,一樣的實現比較簡單,具體的分配策略在TSet等結構中實現。其中SparseArrayAllocator用於管理Value,HashAllocator用於管理Key。Hash空間不足時,按照2的冪次進行擴展。

默認使用堆分配。

除了使用默認的堆分配器,稀疏數組分配器和哈希分配器都有對應的可擴展大小(InlineAllocator)/固定大小(FixedAllocator)分配版本。

 

C/C++的學習裙【七一二 二八四 七零五 】,不管你是小白仍是進階者,是想轉行仍是想入行均可以來了解一塊兒進步一塊兒學習!裙內有開發工具,不少乾貨和技術資料分享!

 

動態內存管理

TSharedPtr

template< class ObjectType, ESPMode Mode >
class TSharedPtr
{
    // ...
private:
 ObjectType* Object;
 SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

TSharedPtr是ue4提供的相似stl sharedptr的解決方案,但相比起stl,它可由第二個模板參數控制是否線程安全。

如上所示,它基於類內的引用計數實現(SharedReferenceCount),爲了確保多個TSharedPtr可以同步當前引用計數的信息,引用計數被設計爲指針類型。在拷貝/構造/賦值等操做時,會增長或減小引用計數的值,當引用計數爲0時將銷燬指針所指對象。

TSharedRef

template< class ObjectType, ESPMode Mode >
class TSharedRef
{
    // ...
private:
 ObjectType* Object;
 SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

和TSharedPtr相似,但存儲的指針不可爲空,建立時需同時初始化指針。相似於C++中的引用。

TRefCountPtr

template<typename ReferencedType>
class TRefCountPtr
{
    // ...
private:
 ReferencedType* Reference;
};

TRefCountPtr是基於引用計數的共享指針的另外一種實現。和TSharedPtr的差別在於它的引用計數並不是智能指針類內維護的,而是基於對象的,至關於TRefCountPtr內部只存儲了對應的指針信息(ReferencedType* Reference)。
基於對象的引用計數,即引用計數存儲在對象內部,這是經過從FRefCountBase繼承引入的。這也就意味着TRefCountPtr引用的對象必須從FRefCountBase繼承,它的使用是有侷限性的。

可是在如統計資源引用而判斷資源是否須要卸載的應用場景中,TRefCountPtr可手動添加/釋放引用,使用上更友好。

class FRefCountBase
{
public:
    // ...
private:
 mutable int32 NumRefs = 0;
};

TWeakPtr

template< class ObjectType, ESPMode Mode >
class TWeakPtr
{
};

相似的,TWeakObjectPtr是ue4提供的相似stl weakptr的解決方案,它將不影響引用計數。

TWeakObjectPtr

template<class T, class TWeakObjectPtrBase>
struct TWeakObjectPtr : private TWeakObjectPtrBase
{
    // ...
};

struct FWeakObjectPtr
{
    // ...

private:
 int32  ObjectIndex;
 int32  ObjectSerialNumber;
};

特別的,因爲UObject有對應的gc機制,TWeakObjectPtr爲指向UObject的弱指針,用於查詢對象是否有效(是否被回收)

 

垃圾回收

C++語言自己並無垃圾回收機制,ue4基於內部的UObject,單獨實現了一套GC機制,此處僅作簡單介紹。

首先,對於UObject相關對象,爲了維持引用(防止被回收),一般使用UProperty()宏,使用容器(如TArray存儲),或調用AddToRoot的方法。

ue4的垃圾回收代碼實現位於GarbageCollection.cpp中的CollectGarbage函數中。這一函數會在遊戲線程中被反覆調用,要麼在一些狀況下手動調用,要麼在遊戲循環Tick()中知足條件時自動調用。

GC過程當中,首先會收集全部不可到達的對象(無引用)。

 

 

以後,根據當前狀況,會在單幀(無時間限制)或多幀(有時間限制)的時間內,清理相關對象(IncrementalPurgeGarbage)

SIMD

合理的內存佈局/對齊有利於SIMD的普遍應用,在編寫定義基礎類型/底層數學算法庫時,咱們一般有必要考慮到這一點。

咱們能夠參考ue4中封裝的sse初始化、加法、減法、乘法等操做,其中,__m128類型的變量需程序確保爲16字節對齊,它適用於浮點數存儲,大部分狀況下存儲於內存中,計算時會在SSE寄存器中運用。

typedef __m128 VectorRegister;

FORCEINLINE VectorRegister VectorLoad( const void* Ptr )
{
 return _mm_loadu_ps((float*)(Ptr));
}

FORCEINLINE VectorRegister VectorAdd( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
 return _mm_add_ps(Vec1, Vec2);
}

FORCEINLINE VectorRegister VectorSubtract( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
 return _mm_sub_ps(Vec1, Vec2);
}

FORCEINLINE VectorRegister VectorMultiply( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
 return _mm_mul_ps(Vec1, Vec2);
}

除了SSE外,ue4還針對Neon/FPU等寄存器封裝了統一的接口,這意味調用者能夠無需考慮過多硬件的細節。

咱們能夠在多個數學運算庫中看到相關的調用,如球諧向量的相加:

/** Addition operator. */
 friend FORCEINLINE TSHVector operator+(const TSHVector& A,const TSHVector& B)
 {
  TSHVector Result;
  for(int32 BasisIndex = 0;BasisIndex < NumSIMDVectors;BasisIndex++)
  {
   VectorRegister AddResult = VectorAdd(
    VectorLoadAligned(&A.V[BasisIndex * NumComponentsPerSIMDVector]),
    VectorLoadAligned(&B.V[BasisIndex * NumComponentsPerSIMDVector])
    );

   VectorStoreAligned(AddResult, &Result.V[BasisIndex * NumComponentsPerSIMDVector]);
  }
  return Result;

 

原文連接:

 

若是你們若是在自學遇到困難,想找一個C++的學習環境,能夠加入咱們的C/C++技術交流羣,點擊我加入吧~會節約不少時間,可以在專業牛人大牛的幫助下,攻克不少在學習中遇到的難題。

相關文章
相關標籤/搜索