天天看點

自頂向下的記憶體管理剖析

C++中有四種記憶體配置設定、釋放方式:

自頂向下的記憶體管理剖析

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

現在讓我們寫一些示例:

new表達式的内部實作是由三個步驟組成的,首先調用operator new配置設定一定的位元組數的記憶體,此時得到的記憶體指針是void*類型的,将之用static_cast轉換成需要的class類型,然後調用class的構造函數完成初始化。

從上面的new的實作中,不難發現,new傳回的是對象指針,失敗是抛出bad_alloc異常,我們應該通過是否抛出異常來判斷new執行狀況,而不是像malloc一樣通過判斷傳回值是否為nullptr。

對于delete,其具體操作與new相反,先調用對象的析構函數,再釋放記憶體。

array new和array delete可以擷取一組對象,new的時候不能同時初始化,是以通常結合placement new來建立對象。

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

數組的new必須和數組的delete聯合使用,否則可能發生記憶體洩漏:若隻使用delete而非delete [],則隻會将配置設定的size塊記憶體空間釋放,但是不會調用對象的析構函數(可能是因為此處将size塊對象視為一個對象之後,找不到合适的析構函數),沒有析構就釋放記憶體是很不優雅的,如果對象内部還使用了new指向其他空間,那麼這部分空間不會被釋放。如果new array配置設定的是一些析構函數沒有意義的對象,比如:

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

附圖:聲明了3個對象的時候的記憶體分布,其中cookie儲存着數組大小等資訊。

自頂向下的記憶體管理剖析
自頂向下的記憶體管理剖析

C++ new的調用鍊是圖中的2,operator new将在全局環境下尋找比對的函數。如果要重構,則最好在此處将其轉為調用類内自定義的Foo::operator new,在Foo::operator new中再調用::operator new。已經定義了重載之後,也可以通過直接調用::operator new來繞過重載。重載的原則是,盡量在高層、部分可見的局部進行重載,使影響盡可能小而且可控。繪制函數調用鍊可以很好地幫助決定重載層次。除了new之外,new array等也可以重載。

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

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

這裡直接看per-class allocator3。

embedded pointer和類型轉換

連結清單管理的記憶體池

抽象的思想

if判斷将值放在變量前面,這樣可以避免少寫等号,編譯器不報錯問題,例如 if(1!=p){} 。

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

allocator是普通的配置設定器,它通過operator new和operator delete調用malloc和free,沒有特殊的設計。

自頂向下的記憶體管理剖析

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配置設定

自頂向下的記憶體管理剖析

G2.9中的一級配置器主要是對malloc和free進行了一些封裝,當申請的記憶體較大的時候,二級配置設定器将直接調用一級配置設定器。一級配置設定器在G4.9中已經棄用。此處不再過多闡述。

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

自頂向下的記憶體管理剖析

__pool_alloc:For thread-enabled configurations, the pool is locked with a single big lock.

mt_alloc:使用了全局連結清單,配置設定到線程時移動到線程專享連結清單,在此過程中,隻對連結清單的一個bin加鎖。exponentially-increasing allocations。

tips:盡量減小鎖的粒度。

自頂向下的記憶體管理剖析

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 在記憶體管理上有很多改進,效率和安全性更好。

在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 <code>pidof yyy</code>,然後 gdb 去檢視有嫌疑的容器的長度;

如果容器的 size 正常,但是還是有洩漏,可能跟智能指針有關,例如 shared ptr,被洩漏;

......

自頂向下的記憶體管理剖析

RAII是指C++語言中的一個慣用法(idiom),它是“Resource Acquisition Is Initialization”的首字母縮寫。中文可将其翻譯為“資源擷取就是初始化”。

需要動态擷取和釋放的都可以稱為“資源”;

擷取資源和釋放資源要對應,這裡就會面臨麻煩:釋放的不徹底将會導緻memory leak,緻使程式臃腫、出錯等。

看到這裡自然而然的可以想到C++中的一對特殊函數,構造函數和析構函數。在構造函數中申請資源,以及在析構函數中釋放資源。

類是C++中的主要抽象工具,那麼就将資源抽象為類,用局部對象來表示資源,把管理資源的任務轉化為管理局部對象的任務。這就是RAII慣用法,RAII有效地實作了C++資源管理的自動化。