在 windows 下使用 VC 编程时,我们通常需要 DEBUG 模式下运行程序,而后调试器将在退出程序时,打印出程序运行过程中在堆上分配而没有释放的内存信息,其中包括代码文件名、行号以及内存大小。该功能是 MFC Framework 提供的内置机制,封装在其类结构体系内部。
在 linux 或者 unix 下,我们的 C++ 程序缺乏相应的手段来检测内存信息,而只能使用 top 指令观察进程的动态内存总额。而且程序退出时,我们无法获知任何内存泄漏信息。为了更好的辅助在 linux 下程序开发,我们在我们的类库项目中设计并实现了一个内存检测子系统。下文将简述 C++ 中的 new 和 delete 的基本原理,并讲述了内存检测子系统的实现原理、实现中的技巧,并对内存泄漏检测的高级话题进行了讨论。
当我们在程序中写下 new 和 delete 时,我们实际上调用的是 C++ 语言内置的 new operator 和 delete operator。所谓语言内置就是说我们不能更改其含义,它的功能总是一致的。以 new operator 为例,它总是先分配足够的内存,而后再调用相应的类型的构造函数初始化该内存。而 delete operator 总是先调用该类型的析构函数,而后释放内存(图1)。我们能够施加影响力的事实上就是 new operator 和 delete operator 执行过程中分配和释放内存的方法。
我们为 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"
#define new DEBUG_NEW
就可以将客户源文件中的对于全局缺省的 new operator 的调用替换为 new (__FILE__,__LINE__) 调用,而该形式的new operator将调用我们的operator new (size_t nSize, char* pszFileName, int nLineNum),其中 nSize 是由 new operator 计算并传入的,而 new 调用点的文件名和行号是由我们自定义版本的 new operator 传入的。我们建议在所有用户自己的源代码文件中都加入上述宏,如果有的文件中使用内存检测子系统而有的没有,则子系统将可能因无法监控整个系统而输出一些泄漏警告。
首先,在我们编制 c++ 应用时,有时需要在堆上创建单个对象,有时则需要创建对象的数组。关于 new 和 delete 原理的叙述我们可以知道,对于单个对象和对象数组来说,内存分配和删除的动作是大不相同的,我们应该总是正确的使用彼此搭配的 new 和 delete 形式。但是在某些情况下,我们很容易犯错误,比如如下代码:
class Test {};
……
Test* pAry = new Test[10];//创建了一个拥有 10 个 Test 对象的数组
Test* pObj = new Test;//创建了一个单对象
……
delete []pObj;//本应使用单对象形式 delete pObj 进行内存释放,却错误的使用了数
//组形式
delete pAry;//本应使用数组形式 delete []pAry 进行内存释放,却错误的使用了单对
//象的形式
不匹配的 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)所记录的数据中找到相应的内存分配信息。
class B {…};
class A {
public:
A() {m_pB = NULL};
A(B* pb) {m_pB = pb;};
~A()
{
if (m_pB != NULL)
行号1 delete m_pB; //这句最要命
};
private:
class B* m_pB;
……
}
int main()
{
A* pA = new A(new B);
……
行号2 delete pA;
}
在上述代码中,main 函数中的一句 delete pA 我们称之为"嵌套删除",即我们 delete A 对象的时候,在A对象的析构执行了另一个 delete B 的动作。当用户使用我们的内存检测子系统时,delete pA 的动作应转化为以下动作:
上全局锁
全局变量(DELETE_FILE,DELETE_LINE)赋值为文件名和行号2
delete operator A
调用~A()
上全局锁
全局变量(DELETE_FILE,DELETE_LINE)赋值为文件名和行号1
delete operator B
调用~B()
返回~B()
调用operator delete B
记录全局变量(DELETE_FILE,DELETE_LINE)值1并清除全局变量(DELETE_FILE,DELETE_LINE)值
打开全局锁
返回operator delete B
返回delete operator B
返回~A()
调用 operator delete A
记录全局变量(DELETE_FILE,DELETE_LINE)值1并清除全局变量(DELETE_FILE,DELETE_LINE)值
打开全局锁
返回operator delete A
返回 delete operator A
嵌套删除时对全局变量(DELETE_FILE,DELETE_LINE)现场保护的问题是指,上述步骤中在 A 的析构函数中调用 delete m_pB 时,对全局变量(DELETE_FILE,DELETE_LINE)文件名和行号的赋值将覆盖主程序中调用 delete pA 时对全局变量(DELETE_FILE,DELETE_LINE)的赋值,造成了在执行 operator delete A 时,delete pA 的信息全部丢失。