淺談內存泄漏

對於一個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() ){ return; } 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 { public: ConnectionManager(){ } ~ConnectionManager(){ list::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; } private: list _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個字符串都是飧?/SPAN>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介入: 126: _CRTIMP void * __cdecl malloc ( 127: size_t nSize 128: ) 129: { 00403C10 jmp 01F41EC8 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介入後,函數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)或者直接連結進目標程蛑小S辛蘇廡┬畔ⅲ?魘雲韃拍芡瓿啥系閔柚茫?ゲ街蔥校?榭幢淞康裙δ堋?/SPAN>BoundsChecker支持多種調試信息格式,它經過直接讀取調試信息就能獲得分配某塊內存的源代碼在哪一個文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能記錄呼叫分配函數的源代碼的位置,並且還能記錄分配時的Call Stack,以及Call Stack上的函數的源代碼位置。這在使用像MFC這樣的類庫時很是有用,如下我用一個例子來講明: void ShowXItemMenu() { … CMenu menu; menu.CreatePopupMenu(); //add menu items. menu.TrackPropupMenu(); … } void ShowYItemMenu( ) { … CMenu menu; menu.CreatePopupMenu(); //add menu items. menu.TrackPropupMenu(); menu.Detach();//this will cause HMENU leak … } BOOL CMenu::CreatePopupMenu() { … hMenu = CreatePopupMenu(); … } 當調用ShowYItemMenu()時,咱們故意形成HMENU的泄漏。可是,對於BoundsChecker來講被泄漏的HMENU是在class CMenu::CreatePopupMenu()中分配的。假設的你的程序有許多地方使用了CMenu的CreatePopupMenu()函數,如?皇歉嫠唚閾孤┦怯?/SPAN>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來找到和解決侍狻?/SPAN> 當Performance Monitor顯示有內存泄漏,而BoundsChecker卻沒法檢測到,這時有兩種可能:第一種,發生了偶發性內存泄漏。這時你要確保使用Performance Monitor和使用BoundsChecker時,程序的運行環境和操做方法是一致的。第二種,發生了隱式的內存泄漏。這時你要從新審查程序的設計,而後仔細研究Performance Monitor記錄的Counter的值的變化圖,分析其中的變化和程序運行邏輯的關係,找到一些可能的緣由。這是一個痛苦的過程,充滿了假設、猜測、驗證、失敗,但這也是一個積累經驗的絕好機會。 總結 內存泄漏是個大而複雜的問題,即便是Java和.Net這樣有Gabarge Collection機制的環境,也存在着泄漏的可能,好比隱式內存泄漏。因爲篇幅和能力的限制,本文只能對這個主題作一個粗淺的研究。其餘的問題,好比多模塊下的泄漏檢測,如何在程序運行時對內存使用狀況進行分析等等,都是能夠深刻研究的題目。若是您有什麼想法,建議或發現了某些錯誤,歡迎和我交流。
相關文章
相關標籤/搜索