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

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

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

char DELETE_FILE[ FILENAME_LENGTH ] =
{0};
int DELETE_LINE = 0;
CCommonMutex globalLock;
而后,在我們的檢測子系統的頭文件中定義了如下形式的 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();\ (//見第六節解釋)
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

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