浅谈内存泄漏

对于一个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机制的环境,也存在着泄漏的可能,好比隐式内存泄漏。因为篇幅和能力的限制,本文只能对这个主题作一个粗浅的研究。其余的问题,好比多模块下的泄漏检测,如何在程序运行时对内存使用状况进行分析等等,都是能够深刻研究的题目。若是您有什么想法,建议或发现了某些错误,欢迎和我交流。
相关文章
相关标签/搜索