讓我們回過頭去看看這樣一個基本問題:為什麼有必要寫自己的operator new和operator delete?
答案通常是:為了效率。預設的operator new和operator
delete具有非常好的通用性,它的這種靈活性也使得在某些特定的場合下,可以進一步改善它的效能。尤其在那些需要動態分配大量的但很小的對象的應用程式裡,情況更是如此。
例如有這樣一個表示飛機的類:類airplane只包含一個指標,它指向的是飛機對象的實際描述(此技術在條款34進行說明):
class airplanerep { ... }; //
表示一個飛機對象
//
class airplane
{
public:
...
private:
airplanerep *rep; //
指向實際描述
};
一個airplane對象並不大,它只包含一個指標(正如條款14和m24所說明的,如果airplane類聲明了虛函數,會隱式包含第二個指標)。但當調用operator
new來分配一個airplane對象時,得到的記憶體可能要比儲存這個指標(或一對指標)所需要的要多。之所以會產生這種看起來很奇怪的行為,在於operator
new和operator delete之間需要互相傳遞資訊。
因為預設版本的operator new是一種通用型的記憶體 Clerk,它必須可以分配任意大小的記憶體塊。同樣,operator
delete也要可以釋放任意大小的記憶體塊。operator delete想弄清它要釋放的記憶體有多大,就必須知道當初operator
new分配的記憶體有多大。有一種常用的方法可以讓operator new來告訴operator
delete當初分配的記憶體大小是多少,就是在它所返回的記憶體裡預先附帶一些額外資訊,用來指明被分配的記憶體塊的大小。也就是說,當你寫了下面的語句,
airplane *pa = new airplane;
你不會得到一塊看起來象這樣的記憶體塊:
pa——> airplane對象的記憶體
而是得到象這樣的記憶體塊:
pa——> 記憶體塊大小資料 + airplane對象的記憶體
對於象airplane這樣很小的對象來說,這些額外的資料資訊會使得動態指派至時所需要的的記憶體的大小翻番(特別是類裡沒有虛擬函數的時候)。
如果軟體運行在一個記憶體很寶貴的環境中,就承受不起這種奢侈的記憶體配置方案了。為airplane類專門寫一個operator
new,就可以利用每個airplane的大小都相等的特點,不必在每個分配的記憶體塊上加上附帶資訊了。
具體來說,有這樣一個方法來實現你的自訂的operator new:先讓預設operator
new分配一些大塊的原始記憶體,每塊的大小都足以容納很多個airplane對象。airplane對象的記憶體塊就取自這些大的記憶體塊。當前沒被使用的記憶體塊被組織成鏈表——稱為自由鏈表——以備未來airplane使用。聽起來好象每個對象都要承擔一個next域的開銷(用於支援鏈表),但不會:rep域的空間也被用來儲存next指標(因為只是作為airplane對象來使用的記憶體塊才需要rep指標;同樣,只有沒作為airplane對象使用的記憶體塊才需要next指標),這可以用union來實現。
具體實現時,就要修改airplane的定義,從而支援自訂的記憶體管理。可以這麼做:
class airplane { // 修改後的類 —
支援自訂的記憶體管理
public: //
static void * operator new(size_t size);
...
private:
union {
airplanerep *rep; // 用於被使用的對象
airplane *next; // 用於沒被使用的(在自由鏈表中)對象
};
// 類的常量,指定一個大的記憶體塊中放多少個
// airplane對象,在後面初始化
static const int
block_size;
static airplane *headoffreelist;
};
上面的代碼增加了的幾個聲明:一個operator
new函數,一個聯合(使得rep和next域佔用同樣的空間),一個常量(指定大記憶體塊的大小),一個靜態指標(跟蹤自由鏈表的表頭)。表頭指標聲明為靜態成員很重要,因為整個類只有一個自由鏈表,而不是每個airplane對象都有。
下面該寫operator new函數了:
void * airplane::operator new(size_t size)
{
//
把“錯誤”大小的請求轉給::operator new()處理;
// 詳見條款8
if (size !=
sizeof(airplane))
return ::operator new(size);
airplane *p = // p指向自由鏈表的表頭
headoffreelist; //
// p 若合法,則將表頭移動到它的下一個元素
//
if (p)
headoffreelist =
p->next;
else {
// 自由鏈表為空白,則分配一個大的記憶體塊,
//
可以容納block_size個airplane對象
airplane *newblock =
static_cast<airplane*>(::operator new(block_size
*
sizeof(airplane)));
// 將每個小記憶體塊連結起來形成一個新的自由鏈表
// 跳過第0個元素,因為它要被返回給operator
new的調用者
//
for (int i = 1; i < block_size-1; ++i)
newblock[i].next = &newblock[i+1];
// 用null 指標結束鏈表
newblock[block_size-1].next = 0;
// p 設為表的頭部,headoffreelist指向的
// 記憶體塊緊跟其後
p =
newblock;
headoffreelist = &newblock[1];
}
return p;
}
如果你讀了條款8,就會知道在operator
new不能滿足記憶體配置請求時,會執行一系列與new-handler函數和例外有關的例行性動作。上面的代碼沒有這些步驟,這是因為operator
new管理的記憶體都是從::operator new分配來的。這意味著只有::operator new失敗時,operator
new才會失敗。而如果::operator
new失敗,它會去執行new-handler的動作(可能最後以拋出異常結束),所以不需要airplane的operator
new也去處理。換句話說,其實new-handler的動作都還在,你只是沒看見,它隱藏在::operator new裡。
有了operator new,下面要做的就是給出airplane的待用資料成員的定義:
airplane *airplane::headoffreelist;
const int airplane::block_size
= 512;
沒必要顯式地將headoffreelist設定為空白指標,因為靜態成員的初始值都被預設設為0。block_size決定了要從::operator
new獲得多大的記憶體塊。
這個版本的operator new將會工作得非常好。它為airplane對象分配的記憶體要比預設operator
new更少,而且運行得更快,可能會快2次方的等級。這沒什麼奇怪的,通用型的預設operator
new必須應付各種大小的記憶體請求,還要處理內部外部的片段;而你的operator new只用操作鏈結表中的一對指標。拋棄靈活性往往可以很容易地換來速度。
下面我們將討論operator delete。還記得operator delete嗎?本條款就是關於operator
delete的討論。但直到現在為止,airplane類只聲明了operator new,還沒聲明operator
delete。想想如果寫了下面的代碼會發生什麼:
airplane *pa = new airplane; //
調用
// airplane::operator new
...
delete pa; // 調用 ::operator delete
讀這段代碼時,如果你豎起耳朵,會聽到飛機撞毀燃燒的聲音,還有程式員的哭泣。問題出在operator
new(在airplane裡定義的那個)返回了一個不帶頭資訊的記憶體的指標,而operator
delete(預設的那個)卻假設傳給它的記憶體包含頭資訊。這就是悲劇產生的原因。
這個例子說明了一個普遍原則:operator new和operator
delete必須同時寫,這樣才不會出現不同的假設。如果寫了一個自己的記憶體配置程式,就要同時寫一個釋放程式。(關於為什麼要遵循這條規定的另一個理由,參見article
on counting objects一文的the sidebar on placement章節)
因而,繼續設計airplane類如下:
class airplane { // 和前面的一樣,只不過增加了一個
public: //
operator delete的聲明
...
static void operator delete(void
*deadobject,
size_t size);
};
// 傳給operator delete的是一個記憶體塊, 如果
// 其大小正確,就加到自由記憶體塊鏈表的最前面
//
void
airplane::operator delete(void *deadobject,
size_t size)
{
if (deadobject == 0) return; // 見條款 8
if (size != sizeof(airplane)) { // 見條款 8
::operator
delete(deadobject);
return;
}
airplane *carcass =
static_cast<airplane*>(deadobject);
carcass->next = headoffreelist;
headoffreelist = carcass;
}
因為前面在operator new裡將“錯誤”大小的請求轉給了全域operator
new(見條款8),那麼這裡同樣要將“錯誤”大小的對象交給全域operator
delete來處理。如果不這樣,就會重現你前面費盡心思想避免的那種問題——new和delete句法上的不匹配。
有趣的是,如果要刪除的對象是從一個沒有虛解構函式的類繼承而來的,那傳給operator
delete的size_t值有可能不正確。這就是必須保證基類必須要有虛解構函式的原因,此外條款14還列出了第二個、理由更充足的原因。這裡只要簡單地記住,基類如果遺漏了虛擬構函數,operator
delete就有可能工作不正確。
所有一切都很好,但從你皺起的眉頭我可以知道你一定在擔心記憶體泄露。有著大量開發經驗的你不會沒注意到,airplane的operator
new調用::operator new 得到了大塊記憶體,但airplane的operator
delete卻沒有釋放它們。記憶體泄露!記憶體泄露!我分明聽見了警鐘在你腦海裡迴響。
但請仔細聽我回答,這裡沒有記憶體泄露!
引起記憶體泄露的原因在於記憶體配置後指向記憶體的指標丟失了。如果沒有垃圾處理或其他語言之外的機制,這些記憶體就不會被收回。但上面的設計沒有記憶體泄露,因為它決不會出現記憶體指標丟失的情況。每個大記憶體塊首先被分成airplane大小的小塊,然後這些小塊被放在自由鏈表上。當客戶調用airplane::operator
new時,小塊被自由鏈表移除,客戶得到指向小塊的指標。當客戶調用operator
delete時,小塊被放回到自由鏈表上。採用這種設計,所有的記憶體塊要不被airplane對象使用(這種情況下,是由客戶來負責避免記憶體泄露),要不就在自由鏈表上(這種情況下記憶體塊有指標)。所以說這裡沒有記憶體泄露。
然而確實,::operator new返回的記憶體塊是從來沒有被airplane::operator
delete釋放,這個記憶體塊有個名字,叫記憶體池。但記憶體流失和記憶體池有一個重要的不同之處。記憶體流失會無限地增長,即使客戶循規蹈矩;而記憶體池的大小決不會超過客戶請求記憶體的最大值。
修改airplane的記憶體管理程式使得::operator new返回的記憶體塊在不被使用時自動釋放並不難,但這裡不會這麼做,這有兩個原因:
第一個原因和你自訂記憶體管理的初衷有關。你有很多理由去自訂記憶體管理,最基本的一條是你確認預設的operator new和operator
delete使用了太多的記憶體或(並且)運行很慢。和採用記憶體池策略相比,跟蹤和釋放那些大記憶體塊所寫的每一個額外的位元組和每一條額外的語句都會導致軟體運行更慢,用的記憶體更多。在設計效能要求很高的庫或程式時,如果你預計記憶體池的大小會在一個合理的範圍之內,那採用記憶體池的方法再好不過了。
第二個原因和處理一些不合理的程式行為有關。假設airplane的記憶體管理程式被修改了,airplane的operator
delete可以釋放任何沒有對象存在的大塊的記憶體。那看下面的程式:
int main()
{
airplane *pa = new airplane; // 第一次分配:
得到大塊記憶體,
// 產生自由鏈表,等
delete pa; //
記憶體塊空;
// 釋放它
pa = new airplane; //
再次得到大塊記憶體,
// 產生自由鏈表,等
delete pa; //
記憶體塊再次空,
// 釋放
... // 你有了想法...
return 0;
}
這個糟糕的小程式會比用預設的operator new和operator
delete寫的程式運行得還慢,佔用還要多的記憶體,更不要和用記憶體池寫的程式比了。
當然有辦法處理這種不合理的情況,但考慮的特殊情況越多,就越有可能要重新實現記憶體管理函數,而最後你又會得到什麼呢?記憶體池不能解決所有的記憶體管理問題,在很多情況下是很適合的。
實際開發中,你會經常要給許多不同的類實現基於記憶體池的功能。你會想,“一定有什麼辦法把這種固定大小記憶體的分配器封裝起來,從而可以方便地使用”。是的,有辦法。雖然我在這個條款已經嘮叨這麼長時間了,但還是要簡單介紹一下,具體實現留給讀者做練習。
下面簡單給出了一個pool類的最小介面(見條款18),pool類的每個對象是某類對象(其大小在pool的建構函式裡指定)的記憶體 Clerk。
class pool {
public:
pool(size_t n); //
為大小為n的對象建立
// 一個分配器
void * alloc(size_t n) ; //
為一個對象分配足夠記憶體
// 遵循條款8的operator
new常規
void free( void *p, size_t n); //
將p所指的記憶體返回到記憶體池;
// 遵循條款8的operator
delete常規
~pool(); // 釋放記憶體池中全部記憶體
};
這個類支援pool對象的建立,執行分配和釋放操作,以及被摧毀。pool對象被摧毀時,會釋放它分配的所有記憶體。這就是說,現在有辦法避免airplane的函數裡所表現的記憶體流失似的行為了。然而這也意味著,如果pool的解構函式調用太快(使用記憶體池的對象沒有全部被摧毀),一些對象就會發現它正在使用的記憶體猛然間沒了。這造成的結果通常是不可預測的。
有了這個pool類,即使java程式員也可以不費吹灰之力地在airplane類裡增加自己的記憶體管理功能:
class airplane {
public:
... // 普通airplane功能
static void * operator new(size_t size);
static void operator
delete(void *p, size_t size);
private:
airplanerep *rep; // 指向實際描述的指標
static pool
mempool; // airplanes的記憶體池
};
inline void * airplane::operator new(size_t size)
{ return
mempool.alloc(size); }
inline void airplane::operator delete(void
*p,
size_t size)
{ mempool.free(p,
size); }
// 為airplane對象建立一個記憶體池,
// 在類的實現檔案裡實現
pool
airplane::mempool(sizeof(airplane));
這個設計比前面的要清楚、乾淨得多,因為airplane類不再和非airplane的代碼混在一起。union,自由鏈表頭指標,定義原始記憶體塊大小的常量都不見了,它們都隱藏在它們應該呆的地方——pool類裡。讓寫pool的程式員去操心記憶體管理的細節吧,你的工作只是讓airplane類正常工作。
現在應該明白了,自訂的記憶體管理程式可以很好地改善程式的效能,而且它們可以封裝在象pool這樣的類裡。但請不要忘記主要的一點,operator
new和operator delete需要同時工作,那麼你寫了operator new,就也一定要寫operator delete。