4.錯誤方式刪除帶來的問題
以上我們已經(jīng)構(gòu)建了一個具備基本內(nèi)存泄漏檢測功能的子系統(tǒng),下面讓我們來看一下關(guān)于內(nèi)存泄漏方面的一些稍微高級一點的話題。
首先,在我們編制 c++ 應用時,有時需要在堆上創(chuàng)建單個對象,有時則需要創(chuàng)建對象的數(shù)組。關(guān)于 new 和 delete 原理的敘述我們可以知道,對于單個對象和對象數(shù)組來說,內(nèi)存分配和刪除的動作是大不相同的,我們應該總是正確的使用彼此搭配的 new 和 delete 形式。但是在某些情況下,我們很容易犯錯誤,比如如下代碼:

class Test
{};
……
Test* pAry = new Test[10];//創(chuàng)建了一個擁有 10 個 Test 對象的數(shù)組
Test* pObj = new Test;//創(chuàng)建了一個單對象
……
delete []pObj;//本應使用單對象形式 delete pObj 進行內(nèi)存釋放,卻錯誤的使用了數(shù)
//組形式
delete pAry;//本應使用數(shù)組形式 delete []pAry 進行內(nèi)存釋放,卻錯誤的使用了單對
//象的形式

不匹配的 new 和 delete 會導致什么問題呢?C++ 標準對此的解答是"未定義",就是說沒有人向你保證會發(fā)生什么,但是有一點可以肯定:大多不是好事情--在某些編譯器形成的代碼中,程序可能會崩潰,而另外一些編譯器形成的代碼中,程序運行可能毫無問題,但是可能導致內(nèi)存泄漏。
既然知道形式不匹配的 new 和 delete 會帶來的問題,我們就需要對這種現(xiàn)象進行毫不留情的揭露,畢竟我們重載了所有形式的內(nèi)存操作 operator new,operator new[],operator delete,operator delete[]。
我們首先想到的是,當用戶調(diào)用特定方式(單對象或者數(shù)組方式)的 operator new 來分配內(nèi)存時,我們可以在指向該內(nèi)存的指針相關(guān)的數(shù)據(jù)結(jié)構(gòu)中,增加一項用于描述其分配方式。當用戶調(diào)用不同形式的 operator delete 的時候,我們在 map 中找到與該指針相對應的數(shù)據(jù)結(jié)構(gòu),然后比較分配方式和釋放方式是否匹配,匹配則在 map 中正常刪除該數(shù)據(jù)結(jié)構(gòu),不匹配則將該數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)移到一個所謂 "ErrorDelete" 的 list 中,在程序最終退出的時候和內(nèi)存泄漏信息一起打印。
上面這種方法是最順理成章的,但是在實際應用中效果卻不好。原因有兩個,第一個原因我們上面已經(jīng)提到了:當 new 和 delete 形式不匹配時,其結(jié)果"未定義"。如果我們運氣實在太差--程序在執(zhí)行不匹配的 delete 時崩潰了,我們的全局對象(appMemory)中存儲的數(shù)據(jù)也將不復存在,不會打印出任何信息。第二個原因與編譯器相關(guān),前面提到過,當編譯器處理自定義數(shù)據(jù)類型或者自定義數(shù)據(jù)類型數(shù)組的 new 和 delete 操作符的時候,通常使用編譯器相關(guān)的 cookie 技術(shù)。這種 cookie 技術(shù)在編譯器中可能的實現(xiàn)方式是:new operator 先計算容納所有對象所需的內(nèi)存大小,而后再加上它為記錄 cookie 所需要的內(nèi)存量,再將總?cè)萘總鹘ooperator new 進行內(nèi)存分配。當 operator new 返回所需的內(nèi)存塊后,new operator 將在調(diào)用相應次數(shù)的構(gòu)造函數(shù)初始化有效數(shù)據(jù)的同時,記錄 cookie 信息。而后將指向有效數(shù)據(jù)的指針返回給用戶。也就是說我們重載的 operator new 所申請到并記錄下來的指針與 new operator 返回給調(diào)用者的指針不一定一致(圖3)。當調(diào)用者將 new operator 返回的指針傳給 delete operator 進行內(nèi)存釋放時,如果其調(diào)用形式相匹配,則相應形式的 delete operator 會作出相反的處理,即調(diào)用相應次數(shù)的析構(gòu)函數(shù),再通過指向有效數(shù)據(jù)的指針位置找出包含 cookie 的整塊內(nèi)存地址,并將其傳給 operator delete 釋放內(nèi)存。如果調(diào)用形式不匹配,delete operator 就不會做上述運算,而直接將指向有效數(shù)據(jù)的指針(而不是真正指向整塊內(nèi)存的指針)傳入 operator delete。因為我們在 operator new 中記錄的是我們所分配的整塊內(nèi)存的指針,而現(xiàn)在傳入 operator delete 的卻不是,所以就無法在全局對象(appMemory)所記錄的數(shù)據(jù)中找到相應的內(nèi)存分配信息。
圖3
綜上所述,當 new 和 delete 的調(diào)用形式不匹配時,由于程序有可能崩潰或者內(nèi)存子系統(tǒng)找不到相應的內(nèi)存分配信息,在程序最終打印出 "ErrorDelete" 的方式只能檢測到某些"幸運"的不匹配現(xiàn)象。但我們總得做點兒什么,不能讓這種危害極大的錯誤從我們眼前溜走,既然不能秋后算帳,我們就實時輸出一個 warning 信息來提醒用戶。什么時候拋出一個 warning 呢?很簡單,當我們發(fā)現(xiàn)在 operator delete 或 operator delete[] 被調(diào)用的時候,我們無法在全局對象(appMemory)的 map 中找到與傳入的指針值相對應的內(nèi)存分配信息,我們就認為應該提醒用戶。
既然決定要輸出warning信息,那么現(xiàn)在的問題就是:我們?nèi)绾蚊枋鑫覀兊膚arning信息才能更便于用戶定位到不匹配刪除錯誤呢?答案:在 warning 信息中打印本次 delete 調(diào)用的文件名和行號信息。這可有點困難了,因為對于 operator delete 我們不能向?qū)ο?operator new 一樣做出一個帶附加信息的重載版本,我們只能在保持其接口原貌的情況下,重新定義其實現(xiàn),所以我們的 operator delete 中能夠得到的輸入只有指針值。在 new/delete 調(diào)用形式不匹配的情況下,我們很有可能無法在全局對象(appMemory)的 map 中找到原來的 new 調(diào)用的分配信息。怎么辦呢?萬不得已,只好使用全局變量了。我們在檢測子系統(tǒng)的實現(xiàn)文件中定義了兩個全局變量(DELETE_FILE,DELETE_LINE)記錄 operator delete 被調(diào)用時的文件名和行號,同時為了保證并發(fā)的 delete 操作對這兩個變量訪問同步,還使用了一個 mutex(至于為什么是 CCommonMutex 而不是一個 pthread_mutex_t,在"實現(xiàn)上的問題"一節(jié)會詳細論述,在這里它的作用就是一個 mutex)。

char DELETE_FILE[ FILENAME_LENGTH ] =
{0};
int DELETE_LINE = 0;
CCommonMutex globalLock;
而后,在我們的檢測子系統(tǒng)的頭文件中定義了如下形式的 DEBUG_DELETE
extern char DELETE_FILE[ FILENAME_LENGTH ];
extern int DELETE_LINE;
extern CCommonMutex globalLock;//在后面解釋
#define DEBUG_DELETE globalLock.Lock(); \
if (DELETE_LINE != 0) BuildStack();\ (//見第六節(jié)解釋)
strncpy( DELETE_FILE, __FILE__,FILENAME_LENGTH - 1 );\
DELETE_FILE[ FILENAME_LENGTH - 1 ]= '\0'; \
DELETE_LINE = __LINE__; \
delete
在用戶被檢測文件中原來的宏定義中添加一條:
#include "MemRecord.h"
#if defined( MEM_DEBUG )
#define new DEBUG_NEW
#define delete DEBUG_DELETE
#endif

這樣,在用戶被檢測文件調(diào)用 delete operator 之前,將先獲得互斥鎖,然后使用調(diào)用點文件名和行號對相應的全局變量(DELETE_FILE,DELETE_LINE)進行賦值,而后調(diào)用 delete operator。當 delete operator 最終調(diào)用我們定義的 operator delete 的時候,在獲得此次調(diào)用的文件名和行號信息后,對文件名和行號全局變量(DELETE_FILE,DELETE_LINE)重新初始化并打開互斥鎖,讓下一個掛在互斥鎖上的 delete operator 得以執(zhí)行。
在對 delete operator 作出如上修改以后,當我們發(fā)現(xiàn)無法經(jīng)由 delete operator 傳入的指針找到對應的內(nèi)存分配信息的時候,就打印包括該次調(diào)用的文件名和行號的 warning。
天下沒有十全十美的事情,既然我們提供了一種針對錯誤方式刪除的提醒方法,我們就需要考慮以下幾種異常情況:
1. 用戶使用的第三方庫函數(shù)中有內(nèi)存分配和釋放操作。或者用戶的被檢測進程中進行內(nèi)存分配和釋放的實現(xiàn)文件沒有使用我們的宏定義。 由于我們替換了全局的 operator delete,這種情況下的用戶調(diào)用的 delete 也會被我們截獲。用戶并沒有使用我們定義的DEBUG_NEW 宏,所以我們無法在我們的全局對象(appMemory)數(shù)據(jù)結(jié)構(gòu)中找到對應的內(nèi)存分配信息,但是由于它也沒有使用DEBUG_DELETE,我們?yōu)?delete 定義的兩個全局 DELETE_FILE 和 DELETE_LINE 都不會有值,因此可以不打印 warning。
2. 用戶的一個實現(xiàn)文件調(diào)用了 new 進行內(nèi)存分配工作,但是該文件并沒有使用我們定義的 DEBUG_NEW 宏。同時用戶的另一個實現(xiàn)文件中的代碼負責調(diào)用 delete 來刪除前者分配的內(nèi)存,但不巧的是,這個文件使用了 DEBUG_DELETE 宏。這種情況下內(nèi)存檢測子系統(tǒng)會報告 warning,并打印出 delete 調(diào)用的文件名和行號。
3. 與第二種情況相反,用戶的一個實現(xiàn)文件調(diào)用了 new 進行內(nèi)存分配工作,并使用我們定義的 DEBUG_NEW 宏。同時用戶的另一個實現(xiàn)文件中的代碼負責調(diào)用 delete 來刪除前者分配的內(nèi)存,但該文件沒有使用 DEBUG_DELETE 宏。這種情況下,因為我們能夠找到這個內(nèi)存分配的原始信息,所以不會打印 warning。
4. 當出現(xiàn)嵌套 delete(定義可見"實現(xiàn)上的問題")的情況下,以上第一和第三種情況都有可能打印出不正確的 warning 信息,詳細分析可見"實現(xiàn)上的問題"一節(jié)。
你可能覺得這樣的 warning 太隨意了,有誤導之嫌。怎么說呢?作為一個檢測子系統(tǒng),對待有可能的錯誤我們所采取的原則是:寧可誤報,不可漏報。請大家"有則改之,無則加勉"。