转自:http://hi.baidu.com/jasonlyy/item/9ca0cecf2c8f113a99b4981c
本文针对 linux 下的 C++ 程序的内存泄漏的检測方法及事实上现进行探讨。当中包含 C++ 中的 new 和 delete 的基本原理,内 存检測子系统的实现原理和详细方法,以及内存泄漏检測的高级话题。作为内存检測子系统实现的一部分,提供了一个具有更好的使用特性的相互排斥体 (Mutex)类。
1.开发背景
在 windows 下使用 VC 编程时,我们通常须要 DEBUG 模式下执行程序,而后调试器将在退出程序时,打印出程序执行过程中在堆上分配而没有释放的内存信息,当中包含代码文件名称、行号以及内存大小。该功能是 MFC fr
在 linux 或者 unix 下,我们的 C++ 程序缺乏对应的手段来检測内存信息,而仅仅能使用 top 指令观察进程的动态内存总额。并且程序退出时,我们无法获知不论什么内存泄漏信息。为了更好的辅助在 linux 下程序开发,我们在我们的类库项目中设计并实现了一个内存检測子系统。下文将简述 C++ 中的 new 和 delete 的基本原理,并讲述了内存检測子系统的实现原理、实现中的技巧,并对内存泄漏检測的高级话题进行了讨论。
2.New和delete的原理
当 我们在程序中写下 new 和 delete 时,我们实际上调用的是 C++ 语言内置的 new operator 和 delete operator。所谓语言内置就是说我们不能更改其含义,它的功能总是一致的。以 new operator 为例,它总是先分配足够的内存,而后再调用对应的类型的构造函数初始化该内存。而 delete operator 总是先调用该类型的析构函数,而后释放内存(图1)。我们可以施加影响力的其实就是 new operator 和 delete operator 运行过程中分配和释放内存的方法。
new operator 为分配内存所调用的函数名字是 operator new,其通常的形式是 void * operator new(size_t size); 其返回值类型是 void*,由于这个函数返回一个未经处理(raw)的指针,未初始化的内存。參数 size 确定分配多少内存,你能添加额外的參数重载函数 operator new,可是第一个參数类型必须是 size_t。
delete operator 为释放内存所调用的函数名字是 operator delete,其通常的形式是 void operator delete(void *memoryToBeDeallocated);它释放传入的參数所指向的一片内存区。
这 里有一个问题,就是当我们调用 new operator 分配内存时,有一个 size 參数表明须要分配多大的内存。可是当调用 delete operator 时,却没有类似的參数,那么 delete operator 怎样可以知道须要释放该指针指向的内存块的大小呢?答案是:对于系统自有的数据类型,语言本身就能区分内存块的大小,而对于自己定义数据类型(如我们自己定义 的类),则 operator new 和 operator delete 之间须要互相传递信息。
当我们使用 operator new 为一个自己定义类型对象分配内存时,实际上我们得到的内存要比实际对象的内存大一些,这些内存除了要存储对象数据外,还须要记录这片内存的大小,此方法称为 cookie。这一点上的实现根据不同的编译器不同。(比如 MFC 选择在所分配内存的头部存储对象实际数据,而后面的部分存储边界标志和内存大小信息。g++ 则採用在所分配内存的头 4 个自己存储相关信息,而后面的内存存储对象实际数据。)当我们使用 delete operator 进行内存释放操作时,delete operator 就能够依据这些信息正确的释放指针所指向的内存块。
以上论述的是对于单个对象的内存分配/释放,当我们为 数组分配/释放内存时,尽管我们仍然使用 new operator 和 delete operator,可是其内部行为却有不同:new operator 调用了operator new 的数组版的兄弟- operator new[],而后针对每个数组成员调用构造函数。而 delete operator 先对每个数组成员调用析构函数,而后调用 operator delete[] 来释放内存。须要注意的是,当我们创建或释放由自己定义数据类型所构成的数组时,编译器为了可以标识出在 operator delete[] 中所需释放的内存块的大小,也使用了编译器相关的 cookie 技术。
综上所述,假设我们想检測内存泄漏,就必须对程序中 的内存分配和释放情况进行记录和分析,也就是说我们须要重载 operator new/operator new[];operator delete/operator delete[] 四个全局函数,以截获我们所需检验的内存操作信息。
3.内存检測的基本实现原理
上 文提到要想检測内存泄漏,就必须对程序中的内存分配和释放情况进行记录,所可以採取的办法就是重载全部形式的operator new 和 operator delete,截获 new operator 和 delete operator 运行过程中的内存操作信息。以下列出的就是重载形式
void* operator new( size_t nSize char* pszFileName int nLineNum )我 们为 operator new 定义了一个新的版本号,除了必须的 size_t nSize 參数外,还添加了文件名称和行号,这里的文件名称和行号就是这次 new operator 操作符被调用时所在的文件名称和行号,这个信息将在发现内存泄漏时输出,以帮助用户定位泄漏详细位置。对于 operator delete,由于无法为之定义新的版本号,我们直接覆盖了全局的 operator delete 的两个版本号。
在重载的 operator new 函数版本号中,我们将调用全局的 operator new 的对应的版本号并将对应的 size_t 參数传入,而后,我们将全局 operator new 返回的指针值以及该次分配所在的文件名称和行号信息记录下来,这里所採用的数据结构是一个 STL 的 map,以指针值为 key 值。当 operator delete 被调用时,假设调用方式正确的话(调用方式不对的情况将在后面具体描写叙述),我们就能以传入的指针值在 map 中找到对应的数据项并将之删除,而后调用 free 将指针所指向的内存块释放。当程序退出的时候,map 中的剩余的数据项就是我们企图检測的内存泄漏信息--已经在堆上分配可是尚未释放的分配信息。
以上就是内存检測实现的基本原 理,如今还有两个基本问题没有解决:
1) 怎样取得内存分配代码所在的文件名称和行号,并让 new operator 将之传递给我们重载的 operator new。
2) 我们何时创建用于存储内存数据的 map 数据结构,怎样管理,何时打印内存泄漏信息。
先 解决这个问题1。首先我们可以利用 C 的预编译宏 __FILE__ 和 __LINE__,这两个宏将在编译时在指定位置展开为该文件的文件名称和该行的行号。而后我们须要将缺省的全局 new operator 替换为我们自己定义的可以传入文件名称和行号的版本号,我们在子系统头文件 MemRecord.h 中定义:
#define DEBUG_NEW new(__FILE__ __LINE__ )而 后在全部须要使用内存检測的客户程序的全部的 cpp 文件的开头增加
#include "MemRecord.h"就 能够将客户源文件里的对于全局缺省的 new operator 的调用替换为 new (__FILE____LINE__) 调用,而该形式的new operator将调用我们的operator new (size_t nSize char* pszFileName int nLineNum),当中 nSize 是由 new operator 计算并传入的,而 new 调用点的文件名称和行号是由我们自己定义版本号的 new operator 传入的。我们建议在全部用户自己的源码文件里都增加上述宏,假设有的文件里使用内存检測子系统而有的没有,则子系统将 可能因无法监控整个系统而输出一些 泄漏警告。
再说第二个问题。我们用于管理客户信息的这个 map 必须在客户程序第一次调用 new operator 或者 delete operator 之前被创建,并且在最后一个 new operator 和 delete operator 调用之后进行泄漏信息的打印,也就是说它须要先于客户程序而出生,而在客户程序退出之后进行分析。可以包容客户程序生命周期的确有一人--全局对象 (appMemory)。我们可以设计一个类来封装这个 map 以及这对它的插入删除操作,然后构造这个类的一个全局对象(appMemory),在全局对象(appMemory)的构造函数中创建并初始化这个数据结 构,而在其析构函数中对数据结构中剩余数据进行分析和输出。Operator new 中将调用这个全局对象(appMemory)的 insert 接口将指针、文件名称、行号、内存块大小等信息以指针值为 key 记录到 map 中,在 operator delete 中调用 erase 接口将相应指针值的 map 中的数据项删除,注意不要忘了对 map 的訪问须要进行相互排斥同步,由于同一时间可能会有多个线程进行堆上的内存操作。
好 啦,内存检測的基本功能已经具备了。可是不要忘了,我们为了检測内存泄漏,在全局的 operator new 添加了一层间接性,同一时候为了保证对数据结构的安全訪问添加了相互排斥,这些都会减少程序执行的效率。因此我们须要让用户可以方便的 enable 和 disable 这个内存检測功能,毕竟内存泄漏的检測应该在程序的调试和測试阶段完毕。我们可以使用条件编译的特性,在用户被检測文件里使 用例如以下宏定义:
#include "MemRecord.h"当 用户须要使用内存检測时,能够使用例如以下命令对被检測文件进行编译
g++ -c -DMEM_DEBUG xxxxxx.cpp就 能够 enable 内存检測功能,而用户程序正式公布时,能够去掉 -DMEM_DEBUG 编译开关来 disable 内 存检測功能,消除内存检測带来的效率影响。
图2所看到的为使用内存检測功 能后,内存泄漏代码的运行以及检測结果
图2
4.错误方式删除带来的问题
以上我们已经构建了一个具备基本内存泄漏检測功能的 子系统,以下让我们来看一下关于内存泄漏方面的一些略微高级一点的话题。
首 先,在我们编制 c++ 应用时,有时须要在堆上创建单个对象,有时则须要创建对象的数组。关于 new 和 delete 原理的叙述我们能够知道,对于单个对象和对象数组来说,内存分配和删除的动作是大不同样的,我们应该总是正确的使用彼此搭配的 new 和 delete 形式。可是在某些情况下,我们非常easy犯错误,比方例如以下代码:
class Test {};不 匹配的 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 呢?非常easy,当我们发如今 operator delete 或 operator delete[] 被调用的时候,我们无法在全局对象(appMemory)的 map 中找到与传入的指针值相相应的内存分配信息,我们就觉得应该提醒用户。
既然决定要输出warning信息,那么如今的问题就 是:我们怎样描写叙述我们的warning信息才干更便于用户定位到不匹配删除错误呢?答案:在 warning 信息中打印本次 delete 调用的文件名称和行号信息。这可有点困难了,由于对于 operator delete 我们不能向对象 operator new 一样做出一个带附加信息的重载版本号,我们仅仅能在保持其接口原貌的情况下,又一次定义事实上现,所以我们的 operator delete 中可以得到的输入仅仅有指针值。在 new/delete 调用形式不匹配的情况下,我们非常有可能无法在全局对象(appMemory)的 map 中找到原来的 new 调用的分配信息。怎么办呢?万不得已,仅仅好使用全局变量了。我们在检測子系统的实现文件里定义了两个全局变量 (DELETE_FILEDELETE_LINE)记录 operator delete 被调用时的文件名称和行号,同一时候为了保证并发的 delete 操作对这两个变量訪问同步,还使用了一个 mutex(至于为什么是 CCommonMutex 而不是一个 pthread_mutex_t,在"实现上的问题"一节会具体论述,在这里它的作用就是一个 mutex)。
char DELETE_FILE[ FILENAME_LENGTH ] = {0};而 后,在我们的检測子系统的头文件里定义了例如以下形式的 DEBUG_DELETE
extern char DELETE_FILE[ FILENAME_LENGTH ];在用户被检測文件里原来的宏定义中加入一条:
#include "MemRecord.h"这 样,在用户被检測文件调用 delete operator 之前,将先获得相互排斥锁,然后使用调用点文件名称和行号对对应的全局变量(DELETE_FILEDELETE_LINE)进行赋值,而后调用 delete operator。当 delete operator 终于调用我们定义的 operator delete 的时候,在获得此次调用的文件名称和行号信息后,对文件名称和行号全局变量(DELETE_FILEDELETE_LINE)又一次初始化并打开相互排斥锁,让下 一个挂在相互排斥锁上的 delete operator 得以运行。
在对 delete operator 作出如上改动以后,当我们发现无法经由 delete operator 传入的指针找到相应的内存分配信息的时候,就打印包含该次调用的文件名称和行号的 warning。
天下没有十全十美的事情,既然我们提供了一种针对错误方式删除的提醒方法,我们就须要考虑下面几种异常情况:
1. 用户使用的第三方库函数中有内存分配和释放操作。或者用户的被检測进程中进行内存分配和释放的实现文件没有使用我们的宏定义。 因为我们替换了全局的 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 太任意了,有误导之嫌。怎么说呢?作为一个检測子系统,对待有可能的错误我们所採取的原则是:宁可误报,不可漏报。请大家"有则改之,无则加勉"。
5.动态内存泄漏信息的检測
上 面我们所讲述的内存泄漏的检測可以在程序整个生命周期结束时,打印出在程序执行过程中已经在堆上分配可是没有释放的内存分配信息,程序猿可以由此找到程序 中"显式"的内存泄漏点并加以改正。可是假设程序在结束之前可以将自己所分配的全部内存都释放掉,是不是就行说这个程序不存在内存泄漏呢?答案:否!在 编程实践中,我们发现了另外两种危害性更大的"隐式"内存泄漏,其表现就是在程序退出时,没有不论什么内存泄漏的现象,可是在程序执行过程中,内存占用量却不 断添加,直到使整个系统崩溃。
1. 程序的一个线程不断分配内存,并将指向内存的指针保存在一个数据存储中(如 list),可是在程序执行过程中,一直没有不论什么线程进行内存释放。当程序退出的时候,该数据存储中的指针值所指向的内存块被依次释放。
2. 程序的N个线程进行内存分配,并将指针传递给一个数据存储,由M个线程从数据存储进行数据处理和内存释放。因为 N 远大于M,或者M个线程数据处理的时间过长,导致内存分配的速度远大于内存被释放的速度。可是在程序退出的时候,数据存储中的指针值所指向的内存块被依次 释放。
之所以说他危害性更大,是由于非常不easy这样的问题找出来,程序可能连续执行几个十几个小时没有问题,从而通过了不严密的系统測试。但 是假设在实际环境中 7×24 小时执行,系统将不定时的崩溃,并且崩溃的原因从 log 和程序表象上都查不出原因。
为了将这样的问题也挑 落马下,我们添加了一个动态检測模块 MemSnapShot,用于在程序执行过程中,每隔一定的时间间隔就对程序当前的内存总使用情况和内存分配情况进行统计,以使用户可以对程序的动态内存 分配状况进行监视。
当 客户使用 MemSnapShot 进程监视一个执行中的进程时,被监视进程的内存子系统将把内存分配和释放的信息实时传送给MemSnapShot。MemSnapShot 则每隔一定的时间间隔就对所接收到的信息进行统计,计算该进程总的内存使用量,同一时候以调用new进行内存分配的文件名称和行号为索引值,计算每一个内存分配动 作所分配而未释放的内存总量。这样一来,假设在连续多个时间间隔的统计结果中,假设某文件的某行所分配的内存总量不断增长而始终没有到达一个平衡点甚至回 落,那它一定是我们上面所说到的两种问题之中的一个。
在实现上,内存检測子系统的全局对象(appMemory)的构造函数中以自 己的当前 PID 为基础 key 值创建一个消息队列,并在operator new 和 operator delete 被调用的时候将对应的信息写入消息队列。MemSnapShot 进程启动时须要输入被检測进程的 PID,而后通过该 PID 组装 key 值并找到被检測进程创建的消息队列,并開始读入消息队列中的数据进行分析统计。当得到operator new 的信息时,记录内存分配信息,当收到 operator delete 消息时,删除对应的内存分配信息。同一时候启动一个分析线程,每隔一定的时间间隔就计算一下当前的以分配而尚未释放的内存信息,并以内存的分配位置为keyword进 行统计,查看在同一位置(同样文件名称和行号)所分配的内存总量和其占进程总内存量的百分比。
图4 是一个正在执行的 MemSnapShot 程序,它所监视的进程的动态内存分配情况如图所看到的:
图四
在 支持 MemSnapShot 过程中的实现上的唯一技巧是--对于被检測进程异常退出状况的处理。由于被检測进程中的内存检測子系统创建了用于进程间数据传输的消息队列, 它是一个核心 资源,其生命周期与内核同样,一旦创建,除非显式的进行删除或系统重新启动,否则将不被释放。
不错,我们能够在内 存检測子系统中 的全局对象(appMemory)的析构函数中完毕对消息队列的删除,可是假设被检測进程非正常退出(CTRL+C,段错误崩溃等),消息队列可就没人管 了。那么我们能够不能够在全局对象(appMemory)的构造函数中使用 signal 系统调用注冊 SIGINT,SIGSEGV 等系统信号处理函数,并在处理函数中删除消息队列呢?还是不行,由于被检測进程全然有可能注冊自己的相应的信号处理函数,这样就会替换我们的信号处理函 数。终于我们採取的方法是利用 fork 产生一个孤儿进程,并利用这个进程监视被检測进程的生存状况,假设被检測进程已经退出(不管正常退出还是异常退出),则试图删除被检測进程所创建的消息队 列。以下简述事实上现原理:
在全局对象(appMemory)构造函数中,创建消息队列成功以后,我们调用 fork 创建一个子进程,而后该子进程再次调用 fork 创建孙子进程,并退出,从而使孙子进程变为一个"孤儿"进程(之所以使用孤儿进程是由于我们须要切断被检測进程与我们创建的进程之间的信号联系)。孙子进 程利用父进程(被检測进程)的全局对象(appMemory)得到其 PID 和刚刚创建的消息队列的标识,并传递给调用 exec 函数产生的一个新的程序映象--MemCleaner。
MemCleaner 程序只调用 kill(pid 0);函数来查看被检測进程的生存状态,假设被检測进程不存在了(正常或者异常退出),则 kill 函数返回非 0 值,此时我们就动手清除可能存在的消息队列。
6.实现上的问题:嵌套delete
在" 错误方式删除带来的问题"一节中,我们对 delete operator 动了个小手术--添加了两个全局变量(DELETE_FILEDELETE_LINE)用于记录本次 delete 操作所在的文件名称和行号,而且为了同步对全局变量(DELETE_FILEDELETE_LINE)的訪问,添加了一个全局的相互排斥锁。在一開始,我们使 用的是 pthread_mutex_t,可是在測试中,我们发现 pthread_mutex_t 在本应用环境中的局限性。
比如例如以下代 码:
class B {…};在 上述代码中,main 函数中的一句 delete pA 我们称之为"嵌套删除",即我们 delete A 对象的时候,在A对象的析构运行了还有一个 delete B 的动作。当用户使用我们的内存检測子系统时,delete pA 的动作应转化为下面动作:
上全局锁在这一过程中,有两个技术问题,一个是 mutex 的可重入问题,一个是嵌套删除时 对全局变量 (DELETE_FILEDELETE_LINE)现场保护的问题。
所谓 mutex 的可重入问题,是指在同一 个线程上下文中,连续对同一个 mutex 调用了多次 lock,然后连续调用了多次 unlock。这就是说我们的应用方式要求相互排斥锁有例如以下特性:
1. 要求在同一个线程上下文中,可以多次持有同一个相互排斥体。而且仅仅有在同一线程上下文中调用同样次数的 unlock 才干放弃对相互排斥体的占有。
2. 对于不同线程上下文持有相互排斥体的企图,同一时间仅仅有一个线程可以持有相互排斥体,而且仅仅有在其释放相互排斥体之后,其它线程才干持有该相互排斥体。
Pthread_mutex_t 相互排斥体不具有以上特性,即使在同一上下文中,第二次调用 pthread_mutex_lock 将会挂起。因此,我们必须实现出自己的相互排斥体。在这里我们使用 semaphore 的特性实现了一个符合上述特性描写叙述的相互排斥体 CCommonMutex(源码见附件)。
为了支持特性 2,在这个 CCommo
原文地址:http://www.cnblogs.com/mfrbuaa/p/3820192.html