如何在LINUX下检查内存的泄露.docx
- 文档编号:7658477
- 上传时间:2023-01-25
- 格式:DOCX
- 页数:14
- 大小:109.11KB
如何在LINUX下检查内存的泄露.docx
《如何在LINUX下检查内存的泄露.docx》由会员分享,可在线阅读,更多相关《如何在LINUX下检查内存的泄露.docx(14页珍藏版)》请在冰豆网上搜索。
如何在LINUX下检查内存的泄露
本文针对linux下的C++程序的内存泄漏的检测方法及其实现进行探讨。
其中包括C++中的new和delete的基本原理,内存检测子系统的实现原理和具体方法,以及内存泄漏检测的高级话题。
作为内存检测子系统实现的一部分,提供了一个具有更好的使用特性的互斥体(Mutex)类。
1.开发背景:
在windows下使用VC编程时,我们通常需要DEBUG模式下运行程序,而后调试器将在退出程序时,打印出程序运行过程中在堆上分配而没有释放的内存信息,其中包括代码文件名、行号以及内存大小。
该功能是MFCFramework提供的内置机制,封装在其类结构体系内部。
在linux或者unix下,我们的C++程序缺乏相应的手段来检测内存信息,而只能使用top指令观察进程的动态内存总额。
而且程序退出时,我们无法获知任何内存泄漏信息。
为了更好的辅助在linux下程序开发,我们在我们的类库项目中设计并实现了一个内存检测子系统。
下文将简述C++中的new和delete的基本原理,并讲述了内存检测子系统的实现原理、实现中的技巧,并对内存泄漏检测的高级话题进行了讨论。
2.New和delete的原理
当我们在程序中写下new和delete时,我们实际上调用的是C++语言内置的newoperator和deleteoperator。
所谓语言内置就是说我们不能更改其含义,它的功能总是一致的。
以newoperator为例,它总是先分配足够的内存,而后再调用相应的类型的构造函数初始化该内存。
而deleteoperator总是先调用该类型的析构函数,而后释放内存(图1)。
我们能够施加影响力的事实上就是newoperator和deleteoperator执行过程中分配和释放内存的方法。
newoperator为分配内存所调用的函数名字是operatornew,其通常的形式是void*operatornew(size_tsize);其返回值类型是void*,因为这个函数返回一个未经处理(raw)的指针,未初始化的内存。
参数size确定分配多少内存,你能增加额外的参数重载函数operatornew,但是第一个参数类型必须是size_t。
deleteoperator为释放内存所调用的函数名字是operatordelete,其通常的形式是voidoperatordelete(void*memoryToBeDeallocated);它释放传入的参数所指向的一片内存区。
这里有一个问题,就是当我们调用newoperator分配内存时,有一个size参数表明需要分配多大的内存。
但是当调用deleteoperator时,却没有类似的参数,那么deleteoperator如何能够知道需要释放该指针指向的内存块的大小呢?
答案是:
对于系统自有的数据类型,语言本身就能区分内存块的大小,而对于自定义数据类型(如我们自定义的类),则operatornew和operatordelete之间需要互相传递信息。
当我们使用operatornew为一个自定义类型对象分配内存时,实际上我们得到的内存要比实际对象的内存大一些,这些内存除了要存储对象数据外,还需要记录这片内存的大小,此方法称为cookie。
这一点上的实现依据不同的编译器不同。
(例如MFC选择在所分配内存的头部存储对象实际数据,而后面的部分存储边界标志和内存大小信息。
g++则采用在所分配内存的头4个自己存储相关信息,而后面的内存存储对象实际数据。
)当我们使用deleteoperator进行内存释放操作时,deleteoperator就可以根据这些信息正确的释放指针所指向的内存块。
以上论述的是对于单个对象的内存分配/释放,当我们为数组分配/释放内存时,虽然我们仍然使用newoperator和deleteoperator,但是其内部行为却有不同:
newoperator调用了operatornew的数组版的兄弟-operatornew[],而后针对每一个数组成员调用构造函数。
而deleteoperator先对每一个数组成员调用析构函数,而后调用operatordelete[]来释放内存。
需要注意的是,当我们创建或释放由自定义数据类型所构成的数组时,编译器为了能够标识出在operatordelete[]中所需释放的内存块的大小,也使用了编译器相关的cookie技术。
综上所述,如果我们想检测内存泄漏,就必须对程序中的内存分配和释放情况进行记录和分析,也就是说我们需要重载operatornew/operatornew[];operatordelete/operatordelete[]四个全局函数,以截获我们所需检验的内存操作信息。
3.内存检测的基本实现原理:
上文提到要想检测内存泄漏,就必须对程序中的内存分配和释放情况进行记录,所能够采取的办法就是重载所有形式的operatornew和operatordelete,截获newoperator和deleteoperator执行过程中的内存操作信息。
下面列出的就是重载形式
void*operatornew(size_tnSize,char*pszFileName,intnLineNum)
void*operatornew[](size_tnSize,char*pszFileName,intnLineNum)
voidoperatordelete(void*ptr)
voidoperatordelete[](void*ptr)
我们为operatornew定义了一个新的版本,除了必须的size_tnSize参数外,还增加了文件名和行号,这里的文件名和行号就是这次newoperator操作符被调用时所在的文件名和行号,这个信息将在发现内存泄漏时输出,以帮助用户定位泄漏具体位置。
对于operatordelete,因为无法为之定义新的版本,我们直接覆盖了全局的operatordelete的两个版本。
在重载的operatornew函数版本中,我们将调用全局的operatornew的相应的版本并将相应的size_t参数传入,而后,我们将全局operatornew返回的指针值以及该次分配所在的文件名和行号信息记录下来,这里所采用的数据结构是一个STL的map,以指针值为key值。
当operatordelete被调用时,如果调用方式正确的话(调用方式不正确的情况将在后面详细描述),我们就能以传入的指针值在map中找到相应的数据项并将之删除,而后调用free将指针所指向的内存块释放。
当程序退出的时候,map中的剩余的数据项就是我们企图检测的内存泄漏信息--已经在堆上分配但是尚未释放的分配信息。
以上就是内存检测实现的基本原理,现在还有两个基本问题没有解决:
1)如何取得内存分配代码所在的文件名和行号,并让newoperator将之传递给我们重载的operatornew。
2)我们何时创建用于存储内存数据的map数据结构,如何管理,何时打印内存泄漏信息。
先解决问题1。
首先我们可以利用C的预编译宏__FILE__和__LINE__,这两个宏将在编译时在指定位置展开为该文件的文件名和该行的行号。
而后我们需要将缺省的全局newoperator替换为我们自定义的能够传入文件名和行号的版本,我们在子系统头文件MemRecord.h中定义:
#defineDEBUG_NEWnew(__FILE__,__LINE__)
而后在所有需要使用内存检测的客户程序的所有的cpp文件的开头加入
#include"MemRecord.h"
#definenewDEBUG_NEW
就可以将客户源文件中的对于全局缺省的newoperator的调用替换为new(__FILE__,__LINE__)调用,而该形式的newoperator将调用我们的operatornew(size_tnSize,char*pszFileName,intnLineNum),其中nSize是由newoperator计算并传入的,而new调用点的文件名和行号是由我们自定义版本的newoperator传入的。
我们建议在所有用户自己的源代码文件中都加入上述宏,如果有的文件中使用内存检测子系统而有的没有,则子系统将可能因无法监控整个系统而输出一些泄漏警告。
再说第二个问题。
我们用于管理客户信息的这个map必须在客户程序第一次调用newoperator或者deleteoperator之前被创建,而且在最后一个newoperator和deleteoperator调用之后进行泄漏信息的打印,也就是说它需要先于客户程序而出生,而在客户程序退出之后进行分析。
能够包容客户程序生命周期的确有一人--全局对象(appMemory)。
我们可以设计一个类来封装这个map以及这对它的插入删除操作,然后构造这个类的一个全局对象(appMemory),在全局对象(appMemory)的构造函数中创建并初始化这个数据结构,而在其析构函数中对数据结构中剩余数据进行分析和输出。
Operatornew中将调用这个全局对象(appMemory)的insert接口将指针、文件名、行号、内存块大小等信息以指针值为key记录到map中,在operatordelete中调用erase接口将对应指针值的map中的数据项删除,注意不要忘了对map的访问需要进行互斥同步,因为同一时间可能会有多个线程进行堆上的内存操作。
好啦,内存检测的基本功能已经具备了。
但是不要忘了,我们为了检测内存泄漏,在全局的operatornew增加了一层间接性,同时为了保证对数据结构的安全访问增加了互斥,这些都会降低程序运行的效率。
因此我们需要让用户能够方便的enable和disable这个内存检测功能,毕竟内存泄漏的检测应该在程序的调试和测试阶段完成。
我们可以使用条件编译的特性,在用户被检测文件中使用如下宏定义:
#include"MemRecord.h"
#ifdefined(MEM_DEBUG)
#definenewDEBUG_NEW
#endif
当用户需要使用内存检测时,可以使用如下命令对被检测文件进行编译
g++-c-DMEM_DEBUGxxxxxx.cpp
就可以enable内存检测功能,而用户程序正式发布时,可以去掉-DMEM_DEBUG编译开关来disable内存检测功能,消除内存检测带来的效率影响。
图2所示为使用内存检测功能后,内存泄漏代码的执行以及检测结果
图2
4.错误方式删除带来的问题
以上我们已经构建了一个具备基本内存泄漏检测功能的子系统,下面让我们来看一下关于内存泄漏方面的一些稍微高级一点的话题。
首先,在我们编制c++应用时,有时需要在堆上创建单个对象,有时则需要创建对象的数组。
关于new和delete原理的叙述我们可以知道,对于单个对象和对象数组来说,内存分配和删除的动作是大不相同的,我们应该总是正确的使用彼此搭配的new和delete形式。
但是在某些情况下,我们很容易犯错误,比如如下代码:
classTest{};
……
Test*pAry=newTest[10];//创建了一个拥有10个Test对象的数组
Test*pObj=newTest;//创建了一个单对象
……
delete[]pObj;//本应使用单对象形式deletepObj进行内存释放,却错误的使用了数
//组形式
deletepAry;//本应使用数组形式delete[]pAry进行内存释放,却错误的使用了单对
//象的形式
不匹配的new和delete会导致什么问题呢?
C++标准对此的解答是"未定义",就是说没有人向你保证会发生什么,但是有一点可以肯定:
大多不是好事情--在某些编译器形成的代码中,程序可能会崩溃,而另外一些编译器形成的代码中,程序运行可能毫无问题,但是可能导致内存泄漏。
既然知道形式不匹配的new和delete会带来的问题,我们就需要对这种现象进行毫不留情的揭露,毕竟我们重载了所有形式的内存操作operatornew,operatornew[],operatordelete,operatordelete[]。
我们首先想到的是,当用户调用特定方式(单对象或者数组方式)的operatornew来分配内存时,我们可以在指向该内存的指针相关的数据结构中,增加一项用于描述其分配方式。
当用户调用不同形式的operatordelete的时候,我们在map中找到与该指针相对应的数据结构,然后比较分配方式和释放方式是否匹配,匹配则在map中正常删除该数据结构,不匹配则将该数据结构转移到一个所谓"ErrorDelete"的list中,在程序最终退出的时候和内存泄漏信息一起打印。
上面这种方法是最顺理成章的,但是在实际应用中效果却不好。
原因有两个,第一个原因我们上面已经提到了:
当new和delete形式不匹配时,其结果"未定义"。
如果我们运气实在太差--程序在执行不匹配的delete时崩溃了,我们的全局对象(appMemory)中存储的数据也将不复存在,不会打印出任何信息。
第二个原因与编译器相关,前面提到过,当编译器处理自定义数据类型或者自定义数据类型数组的new和delete操作符的时候,通常使用编译器相关的cookie技术。
这种cookie技术在编译器中可能的实现方式是:
newoperator先计算容纳所有对象所需的内存大小,而后再加上它为记录cookie所需要的内存量,再将总容量传给operatornew进行内存分配。
当operatornew返回所需的内存块后,newoperator将在调用相应次数的构造函数初始化有效数据的同时,记录cookie信息。
而后将指向有效数据的指针返回给用户。
也就是说我们重载的operatornew所申请到并记录下来的指针与newoperator返回给调用者的指针不一定一致(图3)。
当调用者将newoperator返回的指针传给deleteoperator进行内存释放时,如果其调用形式相匹配,则相应形式的deleteoperator会作出相反的处理,即调用相应次数的析构函数,再通过指向有效数据的指针位置找出包含cookie的整块内存地址,并将其传给operatordelete释放内存。
如果调用形式不匹配,deleteoperator就不会做上述运算,而直接将指向有效数据的指针(而不是真正指向整块内存的指针)传入operatordelete。
因为我们在operatornew中记录的是我们所分配的整块内存的指针,而现在传入operatordelete的却不是,所以就无法在全局对象(appMemory)所记录的数据中找到相应的内存分配信息。
图3
综上所述,当new和delete的调用形式不匹配时,由于程序有可能崩溃或者内存子系统找不到相应的内存分配信息,在程序最终打印出"ErrorDelete"的方式只能检测到某些"幸运"的不匹配现象。
但我们总得做点儿什么,不能让这种危害极大的错误从我们眼前溜走,既然不能秋后算帐,我们就实时输出一个warning信息来提醒用户。
什么时候抛出一个warning呢?
很简单,当我们发现在operatordelete或operatordelete[]被调用的时候,我们无法在全局对象(appMemory)的map中找到与传入的指针值相对应的内存分配信息,我们就认为应该提醒用户。
既然决定要输出warning信息,那么现在的问题就是:
我们如何描述我们的warning信息才能更便于用户定位到不匹配删除错误呢?
答案:
在warning信息中打印本次delete调用的文件名和行号信息。
这可有点困难了,因为对于operatordelete我们不能向对象operatornew一样做出一个带附加信息的重载版本,我们只能在保持其接口原貌的情况下,重新定义其实现,所以我们的operatordelete中能够得到的输入只有指针值。
在new/delete调用形式不匹配的情况下,我们很有可能无法在全局对象(appMemory)的map中找到原来的new调用的分配信息。
怎么办呢?
万不得已,只好使用全局变量了。
我们在检测子系统的实现文件中定义了两个全局变量(DELETE_FILE,DELETE_LINE)记录operatordelete被调用时的文件名和行号,同时为了保证并发的delete操作对这两个变量访问同步,还使用了一个mutex(至于为什么是CCommonMutex而不是一个pthread_mutex_t,在"实现上的问题"一节会详细论述,在这里它的作用就是一个mutex)。
charDELETE_FILE[FILENAME_LENGTH]={0};
intDELETE_LINE=0;
CCommonMutexglobalLock;
而后,在我们的检测子系统的头文件中定义了如下形式的DEBUG_DELETE
externcharDELETE_FILE[FILENAME_LENGTH];
externintDELETE_LINE;
externCCommonMutexglobalLock;//在后面解释
#defineDEBUG_DELETEglobalLock.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"
#ifdefined(MEM_DEBUG)
#definenewDEBUG_NEW
#definedeleteDEBUG_DELETE
#endif
这样,在用户被检测文件调用deleteoperator之前,将先获得互斥锁,然后使用调用点文件名和行号对相应的全局变量(DELETE_FILE,DELETE_LINE)进行赋值,而后调用deleteoperator。
当deleteoperator最终调用我们定义的operatordelete的时候,在获得此次调用的文件名和行号信息后,对文件名和行号全局变量(DELETE_FILE,DELETE_LINE)重新初始化并打开互斥锁,让下一个挂在互斥锁上的deleteoperator得以执行。
在对deleteoperator作出如上修改以后,当我们发现无法经由deleteoperator传入的指针找到对应的内存分配信息的时候,就打印包括该次调用的文件名和行号的warning。
天下没有十全十美的事情,既然我们提供了一种针对错误方式删除的提醒方法,我们就需要考虑以下几种异常情况:
1.用户使用的第三方库函数中有内存分配和释放操作。
或者用户的被检测进程中进行内存分配和释放的实现文件没有使用我们的宏定义。
由于我们替换了全局的operatordelete,这种情况下的用户调用的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太随意了,有误导之嫌。
怎么说呢?
作为一个检测子系统,对待有可能的错误我们所采取的原则是:
宁可误报,不可漏报。
请大家"有则改之,无则加勉"。
5.动态内存泄漏信息的检测
上面我们所讲述的内存泄漏的检测能够在程序整个生命周期结束时,打印出在程序运行过程中已经在堆上分配但是没有释放的内存分配信息,程序员可以由此找到程序中"显式"的内存泄漏点并加以改正。
但是如果程序在结束之前能够将自己所分配的所有内存都释放掉,是不是就可以说这个程序不存在内存泄漏呢?
答案:
否!
在编程实践中,我们发现了另外两种危害性更大的"隐式"内存泄漏,其表现就是在程序退出时,没有任何内存泄漏的现象,但是在程序运行过程中,内存占用量却不断增加,直到使整个系统崩溃。
1.程序的一个线程不断分配内存,并将指向内存的指针保存在一个数据存储中(如list),但是在程序运行过程中,一直没有任何线程进行内存释放。
当程序退出的时候,该数据存储中的指针值所指向的内存块被依次释放。
2.程序的N个线程进行内存分配,并将指针传递给一个数据存储,由M个线程从数据存储进行数据处理和内存释放。
由于N远大于M,或者M个线程数据处理的时间过长,导致内存分配的速度远大于内存被释放的速度。
但是在程序退出的时候,数据存储中的指针值所指向的内存块被依次释放。
之所以说他危害性更大,是因为很不容易这种问题找出来,程序可能连续运行几个十几个小时没有问题,从而通过了不严密的系统测试。
但是如果在实际环境中7×24小时运行,系统将不定时的崩溃,而且崩溃的原因从log和程序表象上都查不出原因。
为了将这种问题也挑落马下,我们增加了一个动态检测模块MemSnapShot,用于在程序运行过程中,每隔一定的时间间隔就对程序当前的内存总使用情况和内存分配情况进行统计,以使用户能够对程序的动态内存分配状况进行监视。
当客户使用MemSnapShot进程监视一个运行
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 如何 LINUX 检查 内存 泄露