1. 程式人生 > >記憶體分配方式和控制記憶體分配

記憶體分配方式和控制記憶體分配

    記憶體管理是C++最令人切齒痛恨的問題,也是C++最有爭議的問題,C++高手從中獲得了更好的效能,更大的自由,C++菜鳥的收穫則是一遍一遍的檢查程式碼和對C++的痛恨,但記憶體管理在C++中無處不在,記憶體洩漏幾乎在每個C++程式中都會發生,因此要想成為C++高手,記憶體管理一關是必須要過的,除非放棄C++,轉到Java或者.NET,他們的記憶體管理基本是自動的,當然你也放棄了自由和對記憶體的支配權,還放棄了C++超絕的效能。

一、記憶體分配方式

簡介

    在C++中,記憶體分成5個區,他們分別是堆、棧、自由儲存區、全域性/靜態儲存區和常量儲存區。
 :在執行函式時,函式內區域性變數的儲存單元都可以在棧上建立,函式執行結束時這些儲存單元自動被釋放

。棧記憶體分配運算內置於處理器的指令集中,效率很高,但是分配的記憶體容量有限。
 :就是那些由 new分配的記憶體塊,他們的釋放編譯器不去管,由我們的應用程式去控制,一般一個new就要對應一個 delete。如果程式設計師沒有釋放掉,那麼在程式結束後,作業系統會自動回收。
 自由儲存區:就是那些由malloc等分配的記憶體塊,他和堆是十分相似的,不過它是用free來結束自己的生命的。
 全域性/靜態儲存區全域性變數和靜態變數被分配到同一塊記憶體中,在以前的C語言中,全域性變數又分為初始化的和未初始化的,在C++裡面沒有這個區分了,他們共同佔用同一塊記憶體區。
 常量儲存區:這是一塊比較特殊的儲存區,他們裡面存放的是常量
,不允許修改。
明確區分堆與棧
    堆與棧的區分問題,似乎是一個永恆的話題,由此可見,初學者對此往往是混淆不清的,所以我決定拿他第一個開刀。
 首先,我們舉一個例子:

  void f() { int* p=new int[5]; }
    這條短短的一句話就包含了堆與棧,看到new,我們首先就應該想到,我們分配了一塊堆記憶體,那麼指標p呢?他分配的是一塊棧記憶體,所以這句話的意思就是:在棧記憶體中存放了一個指向一塊堆記憶體的指標p。在程式會先確定在堆中分配記憶體的大小,然後呼叫operator new分配記憶體,然後返回這塊記憶體的首地址,放入棧中。

    這裡,我們為了簡單並沒有釋放記憶體,那麼該怎麼去釋放呢?是delete p麼?澳,錯了,應該是delete [] p,這是為了告訴編譯器:我刪除的是一個數組,編譯器就會根據相應的Cookie資訊去進行釋放記憶體的工作。

堆和棧究竟有什麼區別

    好了,我們回到我們的主題:堆和棧究竟有什麼區別?
 主要的區別由以下幾點:
 (1). 管理方式不同
 (2). 空間大小不同
 (3). 能否產生碎片不同
 (4). 生長方向不同
 (5). 分配方式不同
 (6). 分配效率不同
 管理方式:對於來講,是由編譯器自動管理,無需我們手工控制;對於來說,釋放工作由程式設計師控制,容易產生memory leak。
 空間大小:一般來講在32位系統下,堆記憶體可以達到4G的空間,從這個角度來看堆記憶體幾乎是沒有什麼限制的。但是對於棧來講,一般都是有一定的空間大小的,例如,在VC6下面,預設的棧空間大小是1M(好像是,記不清楚了)。當然,我們可以修改:
 開啟工程,依次操作選單如下:Project->Setting->Link,在Category 中選中Output,然後在Reserve中設定堆疊的最大值和commit。
 注意:reserve最小值為4Byte;commit是保留在虛擬記憶體的頁檔案裡面,它設定的較大會使棧開闢較大的值,可能增加記憶體的開銷和啟動時間。
 碎片問題:對於堆來講,頻繁的new/delete勢必會造成記憶體空間的不連續,從而造成大量的碎片,使程式效率降低。對於棧來講,則不會存在這個問題,因為棧是先進後出的佇列,他們是如此的一一對應,以至於永遠都不可能有一個記憶體塊從棧中間彈出,在他彈出之前,在他上面的後進的棧內容已經被彈出,詳細的可以參考資料結構,這裡我們就不再一一討論了。
 生長方向對於堆來講,生長方向是向上的,也就是向著記憶體地址增加的方向;對於棧來講,它的生長方向是向下的,是向著記憶體地址減小的方向增長。
 分配方式堆都是動態分配的,沒有靜態分配的堆。棧有2種分配方式:靜態分配和動態分配。靜態分配是編譯器完成的,比如區域性變數的分配。動態分配由alloca函式進行分配,但是棧的動態分配和堆是不同的,他的動態分配是由編譯器進行釋放,無需我們手工實現。
 分配效率:棧是機器系統提供的資料結構,計算機會在底層對棧提供支援:分配專門的暫存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高堆則是C/C++函式庫提供的,它的機制是很複雜的,例如為了分配一塊記憶體,庫函式會按照一定的演算法(具體的演算法可以參考資料結構/作業系統)在堆記憶體中搜索可用的足夠大小的空間,如果沒有足夠大小的空間(可能是由於記憶體碎片太多),就有可能呼叫系統功能去增加程式資料段的記憶體空間,這樣就有機會分到足夠大小的記憶體,然後進行返回。顯然,堆的效率比棧要低得多。
 從這裡我們可以看到,堆和棧相比,由於大量new/delete的使用,容易造成大量的記憶體碎片;由於沒有專門的系統支援,效率很低;由於可能引發使用者態和核心態的切換,記憶體的申請,代價變得更加昂貴。所以棧在程式中是應用最廣泛的,就算是函式的呼叫也利用棧去完成,函式呼叫過程中的引數,返回地址,EBP和區域性變數都採用棧的方式存放。所以,我們推薦大家儘量用棧,而不是用堆。
 雖然棧有如此眾多的好處,但是由於和堆相比不是那麼靈活,有時候分配大量的記憶體空間,還是用堆好一些。
 無論是堆還是棧,都要防止越界現象的發生(除非你是故意使其越界),因為越界的結果要麼是程式崩潰,要麼是摧毀程式的堆、棧結構,產生以想不到的結果,就算是在你的程式執行過程中,沒有發生上面的問題,你還是要小心,說不定什麼時候就崩掉,那時候debug可是相當困難的。

二、控制記憶體分配

    在嵌入式系統中使用C++的一個常見問題是記憶體分配,即對new 和 delete 操作符的失控。
 具有諷刺意味的是,問題的根源卻是C++對記憶體的管理非常的容易而且安全。具體地說,當一個物件被消除時,它的解構函式能夠安全的釋放所分配的記憶體。
 這當然是個好事情,但是這種使用的簡單性使得程式設計師們過度使用new 和 delete,而不注意在嵌入式C++環境中的因果關係。並且,在嵌入式系統中,由於記憶體的限制,頻繁的動態分配不定大小的記憶體會引起很大的問題以及堆破碎的風險。
 作為忠告,保守的使用記憶體分配是嵌入式環境中的第一原則
 但當你必須要使用new和delete時,你不得不控制C++中的記憶體分配。你需要用一個全域性的new 和delete來代替系統的記憶體分配符,並且一個類一個類的過載new和delete。
 一個防止堆破碎的通用方法是從不同固定大小的記憶體持中分配不同型別的物件。對每個類過載new 和delete就提供了這樣的控制。

過載全域性的new和delete操作符
 可以很容易地過載new 和 delete 操作符,如下所示:

void * operator new(size_t size){
    void *p = malloc(size);
    return (p);
}
void operator delete(void *p){
    free(p);
}
    這段程式碼可以代替預設的操作符來滿足記憶體分配的請求。出於解釋C++的目的,我們也可以直接呼叫malloc() 和free()。
 也可以對單個類的new 和 delete操作符過載。這是你能靈活的控制物件的記憶體分配。

#include<iostream>
#include<malloc.h>
using namespace std;
class TestClass
{
    public:
        void * operator new(size_t size);//size_t為無符號整形
        void operator delete(void *p);
        void * operator new [] (size_t size);
        void operator delete [] (void *p);
};
void *TestClass::operator new(size_t size)
{
    void *p=malloc(size);
    return p;
}
void TestClass::operator delete(void *p)
{
    cout << (int)p << endl;
    free(p);
}
void *TestClass::operator new [] (size_t size)
{
    void *p = malloc(size);
    return (p);
}
void TestClass::operator delete [] (void *p)
{
    cout << (int)p << endl;
    free(p);
}
int main(void)
{
    TestClass *p1 = new TestClass;
    delete  p1;
    TestClass *p2 = new TestClass[10];
    delete [] p2;
    system("pause");
}
    但是注意:對於多數C++的實現,new[]操作符中的個數引數是陣列的大小加上額外的儲存物件數目的一些位元組。在你的記憶體分配機制重要考慮的這一點。你應該儘量避免分配物件陣列,從而使你的記憶體分配策略簡單。
關於記憶體碎片

    首先看碎片,32位系統的記憶體是按“頁”管理的,一頁記憶體為64K,只有當前在使用的頁面才會在記憶體中,其他頁面不一定總是在記憶體。因此,分配連續的記憶體時,當請求少於64K,系統會盡量將其分配在一個記憶體頁面中,當大於64K時,會分配在連續的頁面中。以例項說明,例如首先請求30K記憶體,那麼會在第一頁分配,第二次請求50K,就必須在第二頁面分配。於是第一次返回的地址為0,第二次為64K,30K~64K之間的記憶體就是所謂的“碎片”。可以將“碎片”簡單理解為兩塊已分配記憶體之間的空間。當然,“碎片”也可能被利用,但是考慮一種極端的情況:如果一臺電腦擁有4G記憶體,但是每個頁面都只分配了32K,那麼你將不能申請一片大於32K的記憶體,即使目前你的實體記憶體還有2G沒有使用。這種情況是很常見的,當程式比較大時如果你沒作好這些管理,你會發現new個3~5百MB記憶體會經常失敗。
    系統中的new會實施一些演算法或者策略,防止記憶體碎片過快產生,這些演算法類似於資料結構中的“堆”,所以new被稱之為“堆分配”。但是,系統的堆管理策略是巨集觀的,通用的。你只要使用它,一定會產生記憶體碎片。同時,隨著堆的規模的加大,會有很多時間浪費在頁面在主存與虛擬記憶體的交換中,這是因為一般情況下,系統返回給你的記憶體指標是不能改變的,試想你的程式中new了一個新指標,可這片記憶體不知什麼時候被系統換到其他地方了,那麼你的程式離奔潰就不遠了。這說明在C++中,出現記憶體碎片後系統是無法執行“碎片整理”的,然而在底層的windows介面中,你能使用“記憶體控制代碼”代替指標,記憶體控制代碼代表的記憶體是作業系統管理的而不是地址本身,因此這種情況下作業系統能幫你完成“碎片整理”。只是,記憶體控制代碼即使在微軟自己的平臺上也不及指標通用,各種內庫的介面中,絕大部分只認指標,因此你可能在“記憶體控制代碼”與指標間不停轉換消耗掉程式的時間。
    重寫記憶體管理要根據實際需要,這沒有統一的方法。最簡單的做法是讓new返回一個全域性陣列的地址。全域性陣列的記憶體空間在程式啟動時就初始化好了,因此你立即就能獲取到地址並且這個分配一定是成功的(要是記憶體耗盡,程式會在啟動時掛掉)。全域性陣列好處在於它的記憶體一定是連續的,32位系統上能保證2G左右的長度,因此對於作業系統而言可以做到消除碎片,至於如何高效使用這些記憶體就是程式設計師的事情了。最後,全域性陣列的方法也要注意64K對其,否則程式效能會因為記憶體交換收到影響,特別是當記憶體使用量很大的時候。

參考:《c++記憶體管理技術內幕》