淺談記憶體洩漏(一)
對于一個c/c++程式員來說,記憶體洩漏是一個常見的也是令人頭疼的問題。已經有許多技術被研究出來以應對這個問題,比
如Smart Pointer,Garbage Collection等。Smart Pointer技術比較成熟,STL中已經包含支援Smart
Pointer的class,但是它的使用似乎并不廣泛,而且它也不能解決所有的問題;Garbage
Collection技術在Java中已經比較成熟,但是在c/c++領域的發展并不順暢,雖然很早就有人思考在C++中也加入GC的支援。現實世界就是
這樣的,作為一個c/c++程式員,記憶體洩漏是你心中永遠的痛。不過好在現在有許多工具能夠幫助我們驗證記憶體洩漏的存在,找出發生問題的代碼。
記憶體洩漏的定義
一
般我們常說的記憶體洩漏是指堆記憶體的洩漏。堆記憶體是指程式從堆中配置設定的,大小任意的(記憶體塊的大小可以在程式運作期決定),使用完後必須顯示釋放的記憶體。應
用程式一般使用malloc,realloc,new等函數從堆中配置設定到一塊記憶體,使用完後,程式必須負責相應的調用free或delete釋放該記憶體
塊,否則,這塊記憶體就不能被再次使用,我們就說這塊記憶體洩漏了。以下這段小程式示範了堆記憶體發生洩漏的情形:
void MyFunction(int nSize)
{
char* p= new char[nSize];
if( !GetStringFrom( p, nSize ) ){
MessageBox(“Error”);
return;
}
…//using the string pointed by p;
delete p;
}
例一
當函數GetStringFrom()傳回零的時候,指針p指向的記憶體就不會被釋放。這是一種常見的發生記憶體洩漏的情形。程式在入口處配置設定記憶體,在出口處釋放記憶體,但是c函數可以在任何地方退出,是以一旦有某個出口處沒有釋放應該釋放的記憶體,就會發生記憶體洩漏。
廣
義的說,記憶體洩漏不僅僅包含堆記憶體的洩漏,還包含系統資源的洩漏(resource leak),比如核心态HANDLE,GDI
Object,SOCKET,
Interface等,從根本上說這些由作業系統配置設定的對象也消耗記憶體,如果這些對象發生洩漏最終也會導緻記憶體的洩漏。而且,某些對象消耗的是核心态内
存,這些對象嚴重洩漏時會導緻整個作業系統不穩定。是以相比之下,系統資源的洩漏比堆記憶體的洩漏更為嚴重。
GDI Object的洩漏是一種常見的資源洩漏:
void CMyView::OnPaint( CDC* pDC )
CBitmap bmp;
CBitmap* pOldBmp;
bmp.LoadBitmap(IDB_MYBMP);
pOldBmp = pDC->SelectObject( &bmp );
…
if( Something() ){
pDC->SelectObject( pOldBmp );
return;
例二
當
函數Something()傳回非零的時候,程式在退出前沒有把pOldBmp選回pDC中,這會導緻pOldBmp指向的HBITMAP對象發生洩漏。
這個程式如果長時間的運作,可能會導緻整個系統花屏。這種問題在Win9x下比較容易暴露出來,因為Win9x的GDI堆比Win2k或NT的要小很多。
記憶體洩漏的發生方式:
以發生的方式來分類,記憶體洩漏可以分為4類:
1. 常發性記憶體洩漏。發生記憶體洩漏的代碼會被多次執行到,每次被執行的時候都會導緻一塊記憶體洩漏。比如例二,如果Something()函數一直傳回True,那麼pOldBmp指向的HBITMAP對象總是發生洩漏。
2.
偶發性記憶體洩漏。發生記憶體洩漏的代碼隻有在某些特定環境或操作過程下才會發生。比如例二,如果Something()函數隻有在特定環境下才傳回
True,那麼pOldBmp指向的HBITMAP對象并不總是發生洩漏。常發性和偶發性是相對的。對于特定的環境,偶發性的也許就變成了常發性的。是以
測試環境和測試方法對檢測記憶體洩漏至關重要。
3.
一次性記憶體洩漏。發生記憶體洩漏的代碼隻會被執行一次,或者由于算法上的缺陷,導緻總會有一塊僅且一塊記憶體發生洩漏。比如,在類的構造函數中配置設定記憶體,在析
構函數中卻沒有釋放該記憶體,但是因為這個類是一個Singleton,是以記憶體洩漏隻會發生一次。另一個例子:
char* g_lpszFileName = NULL;
void SetFileName( const char* lpcszFileName )
if( g_lpszFileName ){
free( g_lpszFileName );
g_lpszFileName = strdup( lpcszFileName );
例三
如果程式在結束的時候沒有釋放g_lpszFileName指向的字元串,那麼,即使多次調用SetFileName(),總會有一塊記憶體,而且僅有一塊記憶體發生洩漏。
4.
隐式記憶體洩漏。程式在運作過程中不停的配置設定記憶體,但是直到結束的時候才釋放記憶體。嚴格的說這裡并沒有發生記憶體洩漏,因為最終程式釋放了所有申請的記憶體。但
是對于一個伺服器程式,需要運作幾天,幾周甚至幾個月,不及時釋放記憶體也可能導緻最終耗盡系統的所有記憶體。是以,我們稱這類記憶體洩漏為隐式記憶體洩漏。舉一
個例子:
class Connection
public:
Connection( SOCKET s);
~Connection();
private:
SOCKET _socket;
};
class ConnectionManager
ConnectionManager(){
~ConnectionManager(){
list<Connection>::iterator it;
for( it = _connlist.begin(); it != _connlist.end(); ++it ){
delete (*it);
}
_connlist.clear();
void OnClientConnected( SOCKET s ){
Connection* p = new Connection(s);
_connlist.push_back(p);
void OnClientDisconnected( Connection* pconn ){
_connlist.remove( pconn );
delete pconn;
list<Connection*> _connlist;
例四
假
設在Client從Server端斷開後,Server并沒有呼叫OnClientDisconnected()函數,那麼代表那次連接配接的
Connection對象就不會被及時的删除(在Server程式退出的時候,所有Connection對象會在ConnectionManager的析
構函數裡被删除)。當不斷的有連接配接建立、斷開時隐式記憶體洩漏就發生了。
從使用者使用程式的角度來看,記憶體洩漏本身不會産生什麼危害,作為一般
的使用者,根本感覺不到記憶體洩漏的存在。真正有危害的是記憶體洩漏的堆積,這會最終消耗盡系統所有的記憶體。從這個角度來說,一次性記憶體洩漏并沒有什麼危害,因
為它不會堆積,而隐式記憶體洩漏危害性則非常大,因為較之于常發性和偶發性記憶體洩漏它更難被檢測到。
===================================================================
淺談記憶體洩漏(二)
檢測記憶體洩漏:
檢
測記憶體洩漏的關鍵是要能截獲住對配置設定記憶體和釋放記憶體的函數的調用。截獲住這兩個函數,我們就能跟蹤每一塊記憶體的生命周期,比如,每當成功的配置設定一塊記憶體
後,就把它的指針加入一個全局的list中;每當釋放一塊記憶體,再把它的指針從list中删除。這樣,當程式結束的時候,list中剩餘的指針就是指向那
些沒有被釋放的記憶體。這裡隻是簡單的描述了檢測記憶體洩漏的基本原理,詳細的算法可以參見Steve Maguire的<<Writing
Solid Code>>。
如果要檢測堆記憶體的洩漏,那麼需要截獲住malloc/realloc/free和new
/delete就可以了(其實new/delete最終也是用malloc/free的,是以隻要截獲前面一組即可)。對于其他的洩漏,可以采用類似的方
法,截獲住相應的配置設定和釋放函數。比如,要檢測BSTR的洩漏,就需要截獲SysAllocString/SysFreeString;要檢測HMENU
的洩漏,就需要截獲CreateMenu/
DestroyMenu。(有的資源的配置設定函數有多個,釋放函數隻有一個,比如,SysAllocStringLen也可以用來配置設定BSTR,這時就需要
截獲多個配置設定函數)
在Windows平台下,檢測記憶體洩漏的工具常用的一般有三種,MS C-Runtime
Library内建的檢測功能;外挂式的檢測工具,諸如,Purify,BoundsChecker等;利用Windows
NT自帶的Performance Monitor。這三種工具各有優缺點,MS C-Runtime
Library雖然功能上較之外挂式的工具要弱,但是它是免費的;Performance
Monitor雖然無法标示出發生問題的代碼,但是它能檢測出隐式的記憶體洩漏的存在,這是其他兩類工具無能為力的地方。
以下我們詳細讨論這三種檢測工具:
VC下記憶體洩漏的檢測方法
用MFC開發的應用程式,在DEBUG版模式下編譯後,都會自動加入記憶體洩漏的檢測代碼。在程式結束後,如果發生了記憶體洩漏,在Debug視窗中會顯示出所有發生洩漏的記憶體塊的資訊,以下兩行顯示了一塊被洩漏的記憶體塊的資訊:
E:/TestMemLeak/TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.
Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70
第
一行顯示該記憶體塊由TestDlg.cpp檔案,第70行代碼配置設定,位址在0x00881710,大小為200位元組,{59}是指調用記憶體配置設定函數的
Request
Order,關于它的詳細資訊可以參見MSDN中_CrtSetBreakAlloc()的幫助。第二行顯示該記憶體塊前16個位元組的内容,尖括号内是以
ASCII方式顯示,接着的是以16進制方式顯示。
一般大家都誤以為這些記憶體洩漏的檢測功能是由MFC提供的,其實不然。MFC隻是封裝和
利用了MS C-Runtime Library的Debug Function。非MFC程式也可以利用MS C-Runtime
Library的Debug Function加入記憶體洩漏的檢測功能。MS C-Runtime
Library在實作malloc/free,strdup等函數時已經内建了記憶體洩漏的檢測功能。
注意觀察一下由MFC Application Wizard生成的項目,在每一個cpp檔案的頭部都有這樣一段宏定義:
#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] = __FILE__;
#endif
有了這樣的定義,在編譯DEBUG版時,出現在這個cpp檔案中的所有new都被替換成DEBUG_NEW了。那麼DEBUG_NEW是什麼呢?DEBUG_NEW也是一個宏,以下摘自afx.h,1632行
#define DEBUG_NEW new(THIS_FILE, __LINE__)
是以如果有這樣一行代碼:
char* p = new char[200];
經過宏替換就變成了:
char* p = new( THIS_FILE, __LINE__)char[200];
根據C++的标準,對于以上的new的使用方法,編譯器會去找這樣定義的operator new:
void* operator new(size_t, LPCSTR, int)
我們在afxmem.cpp 63行找到了一個這樣的operator new 的實作
void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine)
return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine);
void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)
pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);
if (pResult != NULL)
return pResult;
二個operator
new函數比較長,為了簡單期間,我隻摘錄了部分。很顯然最後的記憶體配置設定還是通過_malloc_dbg函數實作的,這個函數屬于MS
C-Runtime Library 的Debug
Function。這個函數不但要求傳入記憶體的大小,另外還有檔案名和行号兩個參數。檔案名和行号就是用來記錄此次配置設定是由哪一段代碼造成的。如果這塊内
存在程式結束之前沒有被釋放,那麼這些資訊就會輸出到Debug視窗裡。
這裡順便提一下THIS_FILE,__FILE和
__LINE__。__FILE__和__LINE__都是編譯器定義的宏。當碰到__FILE__時,編譯器會把__FILE__替換成一個字元串,這
個字元串就是目前在編譯的檔案的路徑名。當碰到__LINE__時,編譯器會把__LINE__替換成一個數字,這個數字就是目前這行代碼的行号。在
DEBUG_NEW的定義中沒有直接使用__FILE__,而是用了THIS_FILE,其目的是為了減小目标檔案的大小。假設在某個cpp檔案中有
100處使用了new,如果直接使用__FILE__,那編譯器會産生100個常量字元串,這100個字元串都是這個cpp檔案的路徑名,顯然十分備援。
如果使用THIS_FILE,編譯器隻會産生一個常量字元串,那100處new的調用使用的都是指向常量字元串的指針。
再次觀察一下由
MFC Application
Wizard生成的項目,我們會發現在cpp檔案中隻對new做了映射,如果你在程式中直接使用malloc函數配置設定記憶體,調用malloc的檔案名和行
号是不會被記錄下來的。如果這塊記憶體發生了洩漏,MS C-Runtime
Library仍然能檢測到,但是當輸出這塊記憶體塊的資訊,不會包含配置設定它的的檔案名和行号。
要在非MFC程式中打開記憶體洩漏的檢測功能非常容易,你隻要在程式的入口處加入以下幾行代碼:
int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG );
tmpFlag |= _CRTDBG_LEAK_CHECK_DF;
_CrtSetDbgFlag( tmpFlag );
這樣,在程式結束的時候,也就是winmain,main或dllmain函數傳回之後,如果還有記憶體塊沒有釋放,它們的資訊會被列印到Debug視窗裡。
如果你試着建立了一個非MFC應用程式,而且在程式的入口處加入了以上代碼,并且故意在程式中不釋放某些記憶體塊,你會在Debug視窗裡看到以下的資訊:
{47} normal block at 0x00C91C90, 200 bytes long.
Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
記憶體洩漏的确檢測到了,但是和上面MFC程式的例子相比,缺少了檔案名和行号。對于一個比較大的程式,沒有這些資訊,解決問題将變得十分困難。
為了能夠知道洩漏的記憶體塊是在哪裡配置設定的,你需要實作類似MFC的映射功能,把new,maolloc等函數映射到_malloc_dbg函數上。這裡我不再贅述,你可以參考MFC的源代碼。
由
于Debug Function實作在MS
C-RuntimeLibrary中,是以它隻能檢測到堆記憶體的洩漏,而且隻限于malloc,realloc或strdup等配置設定的記憶體,而那些系統資
源,比如HANDLE,GDI Object,或是不通過C-Runtime
Library配置設定的記憶體,比如VARIANT,BSTR的洩漏,它是無法檢測到的,這是這種檢測法的一個重大的局限性。另外,為了能記錄記憶體塊是在哪裡
配置設定的,源代碼必須相應的配合,這在調試一些老的程式非常麻煩,畢竟修改源代碼不是一件省心的事,這是這種檢測法的另一個局限性。
對于開發
一個大型的程式,MS C-Runtime
Library提供的檢測功能是遠遠不夠的。接下來我們就看看外挂式的檢測工具。我用的比較多的是BoundsChecker,一則因為它的功能比較全
面,更重要的是它的穩定性。這類工具如果不穩定,反而會忙裡添亂。到底是出自鼎鼎大名的NuMega,我用下來基本上沒有什麼大問題。
使用BoundsChecker檢測記憶體洩漏:
BoundsChecker
采用一種被稱為 Code
Injection的技術,來截獲對配置設定記憶體和釋放記憶體的函數的調用。簡單地說,當你的程式開始運作時,BoundsChecker的DLL被自動載入進
程的位址空間(這可以通過system-level的Hook實作),然後它會修改程序中對記憶體配置設定和釋放的函數調用,讓這些調用首先轉入它的代碼,然後
再執行原來的代碼。BoundsChecker在做這些動作的時,無須修改被調試程式的源代碼或工程配置檔案,這使得使用它非常的簡便、直接。
這裡我們以malloc函數為例,截獲其他的函數方法與此類似。
需要被截獲的函數可能在DLL中,也可能在程式的代碼裡。比如,如果靜态連結C-Runtime Library,那麼malloc函數的代碼會被連結到程式裡。為了截獲住對這類函數的調用,BoundsChecker會動态修改這些函數的指令。
以下兩段彙編代碼,一段沒有BoundsChecker介入,另一段則有BoundsChecker的介入:
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 push ebp
00403C11 mov ebp,esp
130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);
00403C13 push 0
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }
以下這一段代碼有BoundsChecker介入:
00403C10 jmp 01F41EC8
BoundsChecker介入後,函數malloc的前三條彙編指令被替換成一條jmp指令,原來的三條指令被搬到位址01F41EC8處了。當程式進
入malloc後先jmp到01F41EC8,執行原來的三條指令,然後就是BoundsChecker的天下了。大緻上它會先記錄函數的傳回位址(函數
的傳回位址在stack上,是以很容易修改),然後把傳回位址指向屬于BoundsChecker的代碼,接着跳到malloc函數原來的指令,也就是在
00403c15的地方。當malloc函數結束的時候,由于傳回位址被修改,它會傳回到BoundsChecker的代碼中,此時
BoundsChecker會記錄由malloc配置設定的記憶體的指針,然後再跳轉到到原來的傳回位址去。
如果記憶體配置設定/釋放函數在DLL
中,BoundsChecker則采用另一種方法來截獲對這些函數的調用。BoundsChecker通過修改程式的DLL Import
Table讓table中的函數位址指向自己的位址,以達到截獲的目的。關于如何攔截Windows的系統函數,《程式員》雜志2002年8期,《API
鈎子揭密(下)》,對修改導入位址表做了概要的描述。我就不再贅述。
截獲住這些配置設定和釋放函數,BoundsChecker就能記錄被配置設定
的記憶體或資源的生命周期。接下來的問題是如何與源代碼相關,也就是說當BoundsChecker檢測到記憶體洩漏,它如何報告這塊記憶體塊是哪段代碼配置設定
的。答案是調試資訊(Debug
Information)。當我們編譯一個Debug版的程式時,編譯器會把源代碼和二進制代碼之間的對應關系記錄下來,放到一個單獨的檔案裡
(.pdb)或者直接連結進目标程式中。有了這些資訊,調試器才能完成斷點設定,單步執行,檢視變量等功能。BoundsChecker支援多種調試資訊
格式,它通過直接讀取調試資訊就能得到配置設定某塊記憶體的源代碼在哪個檔案,哪一行上。使用Code Injection和Debug
Information,使BoundsChecker不但能記錄呼叫配置設定函數的源代碼的位置,而且還能記錄配置設定時的Call Stack,以及Call
Stack上的函數的源代碼位置。這在使用像MFC這樣的類庫時非常有用,以下我用一個例子來說明:
void ShowXItemMenu()
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
void ShowYItemMenu( )
menu.Detach();//this will cause HMENU leak
BOOL CMenu::CreatePopupMenu()
hMenu = CreatePopupMenu();
調用ShowYItemMenu()時,我們故意造成HMENU的洩漏。但是,對于BoundsChecker來說被洩漏的HMENU是在class
CMenu::CreatePopupMenu()中配置設定的。假設的你的程式有許多地方使用了CMenu的CreatePopupMenu()函數,如果
隻是告訴你洩漏是由CMenu::CreatePopupMenu()造成的,你依然無法确認問題的根結到底在哪裡,在ShowXItemMenu()中
還是在ShowYItemMenu()中,或者還有其它的地方也使用了CreatePopupMenu()?有了Call
Stack的資訊,問題就容易了。BoundsChecker會如下報告洩漏的HMENU的資訊:
Function
File
Line
CMenu::CreatePopupMenu
E:/8168/vc98/mfc/mfc/include/afxwin1.inl
1009
ShowYItemMenu
E:/testmemleak/mytest.cpp
100
這裡省略了其他的函數調用
如此,我們很容易找到發生問題的函數是ShowYItemMenu()。當使用MFC之類的類庫程式設計時,大部分的API調用都被封裝在類庫的class裡,有了Call Stack資訊,我們就可以非常容易的追蹤到真正發生洩漏的代碼。
記錄Call Stack資訊會使程式的運作變得非常慢,是以預設情況下BoundsChecker不會記錄Call Stack資訊。可以按照以下的步驟打開記錄Call Stack資訊的選項開關:
1. 打開菜單:BoundsChecker|Setting…
2. 在Error Detection頁中,在Error Detection Scheme的List中選擇Custom
3. 在Category的Combox中選擇 Pointer and leak error check
4. 鈎上Report Call Stack複選框
5. 點選Ok
基于Code Injection,BoundsChecker還提供了API Parameter的校驗功能,memory over run等功能。這些功能對于程式的開發都非常有益。由于這些内容不屬于本文的主題,是以不在此詳述了。
盡管BoundsChecker的功能如此強大,但是面對隐式記憶體洩漏仍然顯得蒼白無力。是以接下來我們看看如何用Performance Monitor檢測記憶體洩漏。
使用Performance Monitor檢測記憶體洩漏
NT
的核心在設計過程中已經加入了系統監視功能,比如CPU的使用率,記憶體的使用情況,I/O操作的頻繁度等都作為一個個Counter,應用程式可以通過讀
取這些Counter了解整個系統的或者某個程序的運作狀況。Performance Monitor就是這樣一個應用程式。
為了檢測記憶體
洩漏,我們一般可以監視Process對象的Handle Count,Virutal Bytes 和Working
Set三個Counter。Handle
Count記錄了程序目前打開的HANDLE的個數,監視這個Counter有助于我們發現程式是否有Handle洩漏;Virtual
Bytes記錄了該程序目前在虛位址空間上使用的虛拟記憶體的大小,NT的記憶體配置設定采用了兩步走的方法,首先,在虛位址空間上保留一段空間,這時作業系統并
沒有配置設定實體記憶體,隻是保留了一段位址。然後,再送出這段空間,這時作業系統才會配置設定實體記憶體。是以,Virtual
Bytes一般總大于程式的Working Set。監視Virutal Bytes可以幫助我們發現一些系統底層的問題; Working
Set記錄了作業系統為程序已送出的記憶體的總量,這個值和程式申請的記憶體總量存在密切的關系,如果程式存在記憶體的洩漏這個值會持續增加,但是
Virtual Bytes卻是跳躍式增加的。
監視這些Counter可以讓我們了解程序使用記憶體的情況,如果發生了洩漏,即使是隐式記憶體
洩漏,這些Counter的值也會持續增加。但是,我們知道有問題卻不知道哪裡有問題,是以一般使用Performance
Monitor來驗證是否有記憶體洩漏,而使用BoundsChecker來找到和解決問題。
當Performance
Monitor顯示有記憶體洩漏,而BoundsChecker卻無法檢測到,這時有兩種可能:第一種,發生了偶發性記憶體洩漏。這時你要確定使用
Performance
Monitor和使用BoundsChecker時,程式的運作環境和操作方法是一緻的。第二種,發生了隐式的記憶體洩漏。這時你要重新審查程式的設計,然
後仔細研究Performance
Monitor記錄的Counter的值的變化圖,分析其中的變化和程式運作邏輯的關系,找到一些可能的原因。這是一個痛苦的過程,充滿了假設、猜想、驗
證、失敗,但這也是一個積累經驗的絕好機會。
總結
記憶體洩漏是個大而複雜的問題,即使是Java和.Net這樣有
Gabarge
Collection機制的環境,也存在着洩漏的可能,比如隐式記憶體洩漏。由于篇幅和能力的限制,本文隻能對這個主題做一個粗淺的研究。其他的問題,比如
多子產品下的洩漏檢測,如何在程式運作時對記憶體使用情況進行分析等等,都是可以深入研究的題目。如果您有什麼想法,建議或發現了某些錯誤,歡迎和我交流。