C++記憶體管理剖析

妙妙園發表於2021-11-19

C++記憶體管理

C++中有四種記憶體分配、釋放方式:
image-20210805155457253

最高階的是std::allocator,對應的釋放方式是std::deallocate,可以自由設計來搭配任何容器;new/delete系列是C++函式,可過載;malloc/free屬於C++表示式,不可過載;更低階的記憶體管理函式是作業系統直接提供的系統呼叫,通常不會到這個層次來寫C++應用程式。接下來的闡述集中在上三層。

現在讓我們寫一些示例:

// c語言中的malloc/free
void *p1 = malloc(512);
*(int*) p1 = 100;
free(p1);

// c++表示式new/delete 
int *p2 = new int(100);// 這裡應該是初始化為100,若要宣告陣列,用new int[n];
delete p2;

// c++函式,等價於malloc和free
void *p3 = ::operator new(512);
*(int *)p3 = 100;
::operator delete(p3);

// c++標準庫,通過object呼叫,以GNU為例
int *p4 = alloctor<int>().allocate(7);// 這裡是分配7個int單元而不是7bytes
*p4 = 9;
allocator<int>().deallocate((int *)p4,7);// 7應該與上文匹配

new/delete表示式

new表示式的內部實現是由三個步驟組成的,首先呼叫operator new分配一定的位元組數的記憶體,此時得到的記憶體指標是void*型別的,將之用static_cast轉換成需要的class型別,然後呼叫class的建構函式完成初始化。

Complex *pc;
try {
   void* mem = operator new(sizeof(Complex)); // 分配記憶體
   pc = static_cast<Complex*>(mem);    // cast 轉型 以符合對應的型別,這裡對應為Complex*
   pc->Complex::Complex(1,2); // construct
   // 注意:只有編譯器才可以像上面那樣直接呼叫constructor 欲直接呼叫constructor可通用placement new: new(p) Complex(1,2);
}
catch(std::bad_alloc) {
   // 若allocation失敗就不執行constructor
}

從上面的new的實現中,不難發現,new返回的是物件指標,失敗是丟擲bad_alloc異常,我們應該通過是否丟擲異常來判斷new執行狀況,而不是像malloc一樣通過判斷返回值是否為nullptr。

對於delete,其具體操作與new相反,先呼叫物件的解構函式,再釋放記憶體。

// delete pc;
pc->~Complex();  //先析構
operator delete(pc);   //然後釋放記憶體

array new和array delete可以獲取一組物件,new的時候不能同時初始化,因此通常結合placement new來建立物件。

class A{
public:
	int id;
	
    A(): id(0){}
    A(int i): id(i){}
    ~A(){}
};

A* buf = new A[size]; // 呼叫三次預設建構函式A():id(0)
A* tmp = buf;
// placement new指定位置new
for(int i = 0; i<size; i++)
    new(tmp++) A(i);  //呼叫A(int i): id(i)自定義初始化
delete []buf; // 呼叫三次~A()

new array的構造是從0到size-1依次構造,析構的時候恰恰相反(但這不重要)。new array返回的指標指向第0個物件的起始位置。

陣列的new必須和陣列的delete聯合使用,否則可能發生記憶體洩漏:若只使用delete而非delete [],則只會將分配的size塊記憶體空間釋放,但是不會呼叫物件的解構函式(可能是因為此處將size塊物件視為一個物件之後,找不到合適的解構函式),沒有析構就釋放記憶體是很不優雅的,如果物件內部還使用了new指向其他空間,那麼這部分空間不會被釋放。如果new array分配的是一些解構函式沒有意義的物件,比如:

int* pi = new int[10];
delete pi;

那麼是完全沒有問題的,delete等價於delete[]。

附圖:宣告瞭3個物件的時候的記憶體分佈,其中cookie儲存著陣列大小等資訊。

image-20210805155457253

new/delete過載

image-20210805155457253

C++ new的呼叫鏈是圖中的2,operator new將在全域性環境下尋找匹配的函式。如果要重構,則最好在此處將其轉為呼叫類內自定義的Foo::operator new,在Foo::operator new中再呼叫::operator new。已經定義了過載之後,也可以通過直接呼叫::operator new來繞過過載。過載的原則是,儘量在高層、部分可見的區域性進行過載,使影響儘可能小而且可控。繪製函式呼叫鏈可以很好地幫助決定過載層次。除了new之外,new array等也可以過載。

類內自定義allocator(per-class allocator)

本節介紹的是一個類內藉助記憶體池的記憶體管理。雖然malloc不慢,但減少malloc呼叫次數總是好的;此外,一次malloc得到的記憶體塊前總是帶有一個cookie,它佔有8個位元組。基於以上兩個原因,從時間和空間的角度看,建立記憶體池都是有必要的。

思考:當每次alloc的時候都alloc固定大小的一大塊的時候,應該更難以產生外部碎片(雖然可能更容易產生內部碎片),而且固定大小對於OS的高階分配器來說是十分友好的。

這裡直接看per-class allocator3。

  • embedded pointer和型別轉換
  • 連結串列管理的記憶體池
  • 抽象的思想
  • if判斷將值放在變數前面,這樣可以避免少寫等號,編譯器不報錯問題,例如 if(1!=p){} 。
#include <iostream>
#include <complex>
using namespace std;

class my_allocator{
    private:
    	struct obj{
            struct obj* next;  // embedded pointer
        };
    	obj* freestore = nullptr;
    	const int CHUNK = 5;
    public:
    	my_allocator(){};
    	~my_allocator(){};
    	void* allocate(size_t);
    	void deallocate(void*, size_t);
};

void* my_allocator::allocate(size_t size){
    // 從記憶體池分配一個obj物件大小的記憶體
    assert(size>0);
    obj* p;
    if(!freestore){
        freestore = p = static_cast<obj*>(malloc(CHUNK*size));
        for(int i = 0;i<CHUNK-1;i++){
            p->next = (obj*)((char*)(p+size));
            p = p->next;
        }
        p->next = nullptr;
    }
    p = freestore;
    freestore = freestore->next;
    return p;
}

void deallocate(void* p, size_t size){
    // 插入到記憶體池
    (static_cast<obj*>(p))->next = freestore;
    freestore = static_cast<obj*>(p);
}

// example
class Foo{
    public:
    	long L;
    	string str;
    	static my_allocator myAlloc;
    	Foo(long l): L(l){}
    	static void* operator new(size_t size){
            return myAlloc.allocate(size);
        }
    	static void operator delete(void* dead, size_t size){
            return myAlloc.deallocate(dead, size);
        }
};
my_allocator Foo::myAlloc; // 靜態成員變數一定要在類宣告之外定義

以下討論GNU編譯器中的記憶體管理機制。

allocator是普通的分配器,它通過operator new和operator delete呼叫malloc和free,沒有特殊的設計。

image-20210805155457253

G4.9的__pool_alloc(相當於G2.9的std::alloc)是在容器中使用的分配器,是利用上了記憶體池的分配器。std::alloc使用一個16個寫代指標頭的陣列來管理記憶體連結串列,陣列的不同元素管理不同大小的區塊,每種區塊大小相差8個位元組。記憶體首先由malloc分配到戰備池pool中,再從戰備池挖適當的空間到連結串列。假設使用者需要32位元組的記憶體,std::alloc首先申請一塊區間,大小為32*20*2,用一條連結串列管理,然後讓陣列的#3指標管理這條連結串列,接著將其中一個單元(32位元組)分給使用者。這32*20*2中,一半是給使用者的,後一半預留在戰備池中,如果此時使用者需要一個64位元組的空間,那麼剩下的一半將變成64*10(通常是申請64*20),由另一個連結串列指標指向這裡,然後將其中64位元組分配給使用者,而不用再一次構建連結串列和申請空間。連結串列陣列維護的連結串列最大塊是128位元組,如果申請超過了這個大小,那麼直接呼叫malloc給使用者分配,這樣每一塊都會帶上cookie頭和尾。

  • 戰備池,池中記憶體沒有固定塊大小
  • 多級大小記憶體池連結串列
  • 兩級分配器:超過最大大小直接使用malloc分配
image-20210805155457253 G2.9中的一級配置器主要是對malloc和free進行了一些封裝,當申請的記憶體較大的時候,二級分配器將直接呼叫一級分配器。一級分配器在G4.9中已經棄用。此處不再過多闡述。

二級配置器執行分配器的主要功能。流程圖和部分原始碼如下。

static const int __ALLGN = 8; // 上調邊界
static const int __MAX_BYTES = 8; // 分配Chunk的上限
static const int __NFREELISTS = __MAX_BYTES/__ALLGN; // 連結串列的條數

template<bool threads, int inst>
class __default_alloc_template{
	private:
		static size_t ROUND_UP(size_t bytes){ // 向上取整8 
			return (bytes+__ALLGN-1) & ~(__ALLGN-1);
		}
		union obj{ // 亦可用struct
			union obj* free_list_link; // 連結串列的next指標,老規矩用了ebedded pointer
		}
		static obj* volatile free_list[__NFREELISTS]; // 多級大小記憶體池
		static size_t FREELIST_INDEX(size_t bytes){ // 根據大小確定連結串列index
			return ((bytes+ALLGN-1)/__ALLGN-1);
		}
		static void *refill(size_t size);
		static char* chunk_alloc(size_t size, int &nobjs);
		
		// 戰備池
		static char* start_free; // 指向pool的頭
		static char* end_free; // 指向pool的尾
		static size_t heap_size; // 分配累積量
		
	public:
		static void* allocate(size_t size){
			obj* volatile *my_free_list; // 連結串列的連結串列
			obj* result;
			
			if(size > (size_t)__MAX_BYTES) // 大於128改用第一級分配器
				return (malloc_alloc::allocate(size));
			my_free_list = free_list+FREELIST_INDEX(size);
			result = *my_free_list;
			if(0==result){
				void* t = refill(ROUND_UP(size)); // 對此連結串列充值
				return t;
			}
			*my_free_list = result->free_list_link;
			return result;
		}
		static deallocate(void* p, size_t size){
            obj* q = (obj*)p;
            obj* volatile* my_free_list;
			if(size > static_cast<size_t>(__MAX_BYTES)){
				malloc_alloc::deallocate(p,size); // 大於128改用第一級分配器
				return;
			}
			my_free_list = free_list + FREELIST_INDEX(size);
			q->free_list_link = *my_free_list;
			*my_free_list = q;
		}
		static void* reallocate(void* p, size_t old_size,size_t new_size);
}


/*
    We allocate memory in large chunks inn order to avoid fragmenting the malloc
   heap too much, We assume that size is properly aligned.
    We hold the allocation lock. 
*/
template<bool threads, int inst>
char* __default_alloc_template<threads, inst>::chunk_alloc(size_t size, int& nobjs) {
 
    char* result;
    size_t total_bytes = size * nobjs;
    size_t bytees_left = end_free - start_free;
    
    if(bytees_left >= total_bytes) {  //pool空間足以滿足需求
 
        result = start_free;
        start_free += total_bytes; // 【Q1:如果pool中的空間不連續還能直接分配和相加嗎?A:虛擬地址是連續的】
        return(result); 
    }else if(bytees_left >= size) {  //pool空間只滿足一塊以上
        
        nobjs = bytees_left / size;   //改變需求個數
        total_bytes  = size * nobjs;  //改變需求總量   pass-by-value會改變引數
        result = start_free;
        start_free += total_bytes;
        return (result); 
    }else {   //pool空間不足以滿足一塊需求  碎片&&0
 
        //打算從system free-store上去這麼多來充值
        size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);
        //處理碎片(將其掛到相應的chunk指標埠)
        if(bytes_to_get > 0) {
            obj* volatile *my_free_list =   //重新定位碎片的指標
               free_list + FREELIST_INDEX(bytees_left); 
            ((obj*)start_free)->free_list_link = *my_free_list;
            *my_free_list = (obj*)start_free; 
        }
 
        //從system free-store中取
        start_free = (char*)malloc(bytes_to_get); 
        if(0 == start_free) {  //如果當前的chunk分配失敗,則向上繼續找相鄰的chunk繼續分配
            obj* volatile *my_free_list, *p;
            for(int i = size; i <= __MAX_BYTES; i += __ALLGN) {
                my_free_list = free_list + FREELIST_INDEX(i); 
                p = *my_free_list;
                if(0 != p) {  //該free-list有可用區塊
                    *my_free_list = p->free_list_link; 
                    start_free  = (char*)p;
                    end_free = start_free + i; 
                    return (chunk_alloc(size, nobjs));   //結果再試一次
                }
            }
            end_free = 0;
            start_free = (char*)malloc_alloc::allocate(bytes_to_get); 
        }
 
        //至此,表示已經從system free-store成功取得很多memory
        heap_size += bytes_to_get;
        end_free = start_free + bytes_to_get;
        return (chunk_alloc(size, nobjs));   //戰備池有記憶體了,所以遞迴重新處理分配邏輯
    }
}
 
//靜態定義(分配記憶體)
template<bool threads, int inst>
char* __default_alloc_template<threads, inst>::start_free = 0;
 
 
template<bool threads, int inst>
char* __default_alloc_template<threads, inst>::end_free = 0;
 
template<bool threads, int inst>
size_t __default_alloc_template<threads, inst>::heap_size = 0;
 
 
template<bool threads, int inst>
__default_alloc_template<threads, inst>::obj* volatile
__default_alloc_template<threads, inst>::free_list[__NFREELISTS] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
 
 
//std::alloc為第二級分配器
typedef  __default_alloc_template<false, 0> alloc;

int main(void){
    std::vector<int,MyAllocator<int>> v;
}

多執行緒記憶體分配器

__pool_alloc:For thread-enabled configurations, the pool is locked with a single big lock.
mt_alloc:使用了全域性連結串列,分配到執行緒時移動到執行緒專享連結串列,在此過程中,只對連結串列的一個bin加鎖。exponentially-increasing allocations。
tips:儘量減小鎖的粒度。

malloc/free

malloc/free是 libc實現的庫函式,主要實現了一套記憶體管理機制,當其管理的記憶體不夠時,通過brk/mmap等系統呼叫向核心申請程式的虛擬地址區間,如果其維護的記憶體能滿足malloc呼叫,則直接返回,free時會將地址塊返回空閒連結串列。

malloc(size) 的時候,這個函式會多分配一塊空間,用於儲存size變數,free的時候,直接通過指標前移一定大小,就可以獲取malloc時儲存的size變數,從而free只需要一個指標作為引數就可以了calloc 庫函式相當於 malloc + memset(0)

malloc和free碎片化嚴重(記憶體站崗),在高併發下效能低下。除了libc自帶的動態記憶體管理庫malloc, 有時候還可以使用其他的記憶體管理庫替換,比如使用google實現的tcmalloc ,只需要編譯程式時連結上 tcmalloc的靜態庫幷包含響應標頭檔案,就可以透明地使用tcmalloc 了,與libc 的malloc相比, tcmalloc 在記憶體管理上有很多改進,效率和安全性更好。

brk和mmap

在Linux下,glibc 的malloc提供了下面兩種動態記憶體管理的方法:堆記憶體分配和mmap的記憶體分配,此兩種分配方法都是通過相應的Linux 系統呼叫來進行動態記憶體管理的。具體使用哪一種方式分配,根據glibc的實現,主要取決於所需分配記憶體的大小。一般情況中,應用層面的記憶體從程式堆中分配,當程式堆大小不夠時,可以通過系統呼叫brk來改變堆的大小,但是在以下情況,一般由mmap系統呼叫來實現應用層面的記憶體分配:A、應用需要分配大於1M的記憶體,B、在沒有連續的記憶體空間能滿足應用所需大小的記憶體時。
(1)、呼叫brk實現程式裡堆記憶體分配
在glibc中,當程式所需要的記憶體較小時,該記憶體會從程式的堆中分配,但是堆分配出來的記憶體空間,系統一般不會回收,只有當程式的堆大小到達最大限額時或者沒有足夠連續大小的空間來為程式繼續分配所需記憶體時,才會回收不用的堆記憶體。在這種方式下,glibc會為程式堆維護一些固定大小的記憶體池以減少記憶體碎片。
(2)、使用mmap的記憶體分配(堆和棧中間,稱為“檔案對映區域”的地方)
在glibc中,一般在比較大的記憶體分配時使用mmap系統呼叫,它以頁為單位來分配記憶體的(在Linux中,一般一頁大小定義為4K),這不可避免會帶來記憶體浪費,但是當程式呼叫free釋放所分配的記憶體時,glibc會立即呼叫unmmap,把所分配的記憶體空間釋放回系統。

注意: 這裡我們討論的都是虛擬記憶體的分配(即應用層面上的記憶體分配),主要由glibc來實現,它與核心中實際實體記憶體的分配是不同的層面,程式所分配到的虛擬記憶體可能沒有對應的實體記憶體。如果所分配的虛擬記憶體沒有對應的實體記憶體時,作業系統會利用缺頁機制來為程式分配實際的實體記憶體。
預設情況下,malloc函式分配記憶體,如果請求記憶體大於128K(可由M_MMAP_THRESHOLD選項調節),那就不是去推_edata指標了,而是利用mmap系統呼叫,從堆和棧的中間分配一塊虛擬記憶體。
這樣子做主要是因為brk分配的記憶體需要等到高地址記憶體釋放以後才能釋放(例如,在B釋放之前,A是不可能釋放的,因為只有一個_edata 指標,這就是記憶體碎片產生的原因)(圖2緊縮),而mmap分配的記憶體可以單獨釋放。
malloc當最高地址空間的空閒記憶體超過128K(可由M_TRIM_THRESHOLD選項調節)時,執行記憶體緊縮操作(trim)

缺頁中斷

  • 陷入核心態
  • 檢查要訪問的虛擬地址是否合法
  • 查詢/分配一個物理頁[......](buddy+slab)
  • 填充物理頁內容(讀取磁碟,或者直接置0,或者什麼都不做)
  • 建立對映關係(虛擬地址到實體地址的對映關係)
  • 重複執行發生缺頁中斷的那條指令

補充知識

記憶體洩漏

記憶體洩露是很隱蔽的錯誤,通常少量的記憶體洩露不會造成什麼問題,大量的記憶體洩露可能會有“out of memory(OOM)”錯誤。
記憶體洩露的檢測通常藉助於記憶體分析工具;( valgrind 或 purify )
一般如果是簡單的 new 之後,沒有 delete,這種洩漏最容易發現。真實場景可能比這複雜得多。有時候定位了相應的函式,但是程式碼比較複雜,還是找不到洩漏點,可以參考如下幾個地方:
map:c++的map,在下標訪問的時候自動構造 value 物件,可能造成 map 無限增長;
unordered_set: 在插入大量的元素之後,再刪除,記憶體佔用保持不變,需要手動 rehash;
容器的 size 很大:通過 gcore -o xxx pidof yyy,然後 gdb 去檢視有嫌疑的容器的長度;
如果容器的 size 正常,但是還是有洩漏,可能跟智慧指標有關,例如 shared ptr,被洩漏;
......

malloc/free和new/delete的比較

image-20210805155457253

RAII規則

RAII是指C++語言中的一個慣用法(idiom),它是“Resource Acquisition Is Initialization”的首字母縮寫。中文可將其翻譯為“資源獲取就是初始化”。
需要動態獲取和釋放的都可以稱為“資源”;
獲取資源和釋放資源要對應,這裡就會面臨麻煩:釋放的不徹底將會導致memory leak,致使程式臃腫、出錯等。
看到這裡自然而然的可以想到C++中的一對特殊函式,建構函式和解構函式。在建構函式中申請資源,以及在解構函式中釋放資源。
類是C++中的主要抽象工具,那麼就將資源抽象為類,用區域性物件來表示資源,把管理資源的任務轉化為管理區域性物件的任務。這就是RAII慣用法,RAII有效地實現了C++資源管理的自動化。

相關文章