原文:C++内存分配
内存泄露相信对C++程序员来说都不陌生。解决内存泄露的方案多种多样,大部分方案以追踪检测为主,这种方法实现起来容易,使用方便,也比较安全。
首先我们要确定这个模块的主要功能:
- 能追踪内存的分配和释放过程。
- 要能显示内存分配的相关信息,比如内存块大小,代码所在文件所在行等。
- 在发现内存泄露时及时给出相关信息。
- 能正确处理一些异常情况,比如内存不足,对象初始化失败等等。
- 是线程安全的。[*这个还没有实现]
有了一些基本功能需求,我们需要考虑每种功能怎么去实现。首先,我们可以通过重载的方式来追踪new,delete.malloc和free,C++给我提供了这样的特性。因为本文主要针对C++,所以主要讲重载new,delete的方法,malloc和free的重载实现于此类似,最终版本的程序中也实现了malloc和free的重载。
1.重载new和delete
首先我们要了解一下new和delete是怎么工作的。C++中的操作符最终都会被转换成函数形式,例如"new int"会变成"opetaor new(sizeof(int))",而"new double[10]"会变成"operator new(sizeof(double)*10)",同样“delete p”就变成了"operator delete(p)"。另外一个需要特别注意的地方是,new对于用户定义的数据类型(即你的自定义类)会自动调用该类型的构造函数,如果构造函数没有抛出异常,则正确分配,否则会中断分配操作,将异常传递给用户。默认情况下,new可以对象构造异常进行捕获。另外一个版本的new就是不带捕获异常功能的的了,所以C++系统提供的new和delete有:
1
2
3
4
5
6
7
8
9
|
void * operator new ( size_t size) throw (std::bad_alloc);
void * operator new []( size_t size) throw (std::bad_alloc);
void * operator new ( size_t ,std::nothrow_t&) throw ();
void * operator new []( size_t ,std::nothrow_t&) throw ();
void operator delete ( void * pointer);
void operator delete []( void * pointer);
void operator delete ( void * pointer,std::nothrow_t&);
void operator delete []( void * pointer,std::nothrow_t&);<br>
|
其中,nothrow_t是一个空结构体“struct nothrow_t{}",它的一个实例就是nothrow,C++用它来区分可以捕获异常的new和不可捕获异常的new。我们不能直接修改内部函数的行为,但是我们可以重载它们。为了实现提供内存分配信息的功能,我们给重载的函数加上几个参数。得到以下函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
void * operator new ( size_t size);
void * operator new []( size_t size);
void * operator new ( size_t ,std::nothrow_t&) throw ();
void * operator new []( size_t ,std::nothrow_t&) throw ();
void * operator new ( size_t size, const char * file, const char * func, const int line) throw (std::bad_alloc);
void * operator new []( size_t size, const char * file, const char * func, const int line) throw (std::bad_alloc);
void * operator delete ( void * pointer);
void * operator delete []( void * pointer);
/*******Placement Delete********/ void operator delete ( void * pointer, const char * file, const char * func, const int line);
void operator delete []( void * pointer, const char * file, const char * func, const int line);
void operator delete ( void * pointer,std::nothrow_t&);
void operator delete []( void * pointer,std::nothrow_t&);
/*******************************/ |
中间的几个函数,就是我们主要需要重载的函数,模块的大部分工作也都由着几个函数完成。这些函数参数中,file表示分配代码所在的文件名,func表示代码所在的函数名,line表示代码行号。这几个参数信息我们可以通过编译器预定义好的几个宏来获得:__FILE__,__FUNCTION__,__LINE__。也就是说可以将"new ..."替换成"new(__FILE__,__FUNCTION__,__LINE__) ...",最终成为"operator new(sizeof(...),__FILE__,__FUNCTION__,__LINE__)"的形式,也就达到了我们的目的。关于 placement delete将在下面详细解释。
2.空间分配
接下来我们要考虑内存分配信息的组织问题了。我们先来了解一下编译器是怎么组织的。在大部分编译器中,new所分配的空间都要大于实际申请的空间,大出来的部分就是编译器定义的内存块的信息,包括了内存块的大小还有一些其他信息。如下图所示:
我们把包含内存分配信息的部分叫做cookie数据。为了方便,我们把cookie数据放在分配的内存的起始位置,之后紧接有效数据区。我们还需要把返回给调用者的指针和new分配的数据区联系起来,原本想用性能比较好的STL的map数据结构来储存这些数据,但是map内部同样也使用new来分配内存,所以如果直接使用map来储存,就会陷入死循环中。所以这里我们必须自己现实一个数据结构。我们可以对返回给调用者的地址进行Hash,得到hash 表中的地址,具有相同Hash值的数据我们用一个单向链表连接起来。最终的数据结构如下图所示:
2.1.构造函数中的异常
另外一个必须要注意的一点是,new操作符会先分配空间然后调用用户自定义类型的构造函数,如果构造函数抛出异常,需要用户手动释放已分配的内存。问题在于释放这样的内存不能用一般的delete操作符,可以用一个例子来说明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
#include <stdio.h> #include <stdexcept> void * operator new ( size_t size, int line) {
printf ( "Allocate %u bytes on line %d\\n" , size, line);
return operator new (size);
} class UserClass {
public :
UserClass( int n) throw (){
if (n<=0){
throw std::runtime_error( "n must be positive" );
}
}
}; int main(){
try {
UserClass* myobj= new (__LINE__) UserClass(-10);
delete myobj; //doesn't work if placement was not defined
} catch ( const std::runtime_error& e) {
fprintf (stderr, "Exception: %s\n" ,e.what());
}
return 0;
}<br> |
这里,虽然在new过后试图使用delete释放已经分配的内存,但是实际上不会释放。也许你的编译器会给出这样一条消息:
“no matching operator delete found”
为了正确处理这种情况,并给用户提供相关的信息,我们需要定义placement delete操作符。placement delete是C++98标准中才有的一个特性,所以对于某些老的编译器(大致可以认为是指那些98年以前编写的编译器)不支持这个特性。这需要在模块中添加宏定义让用户可以关闭placement delete的定义,以便模块能在较老的编译器上编译。以下就是需要定义的placement delete操作符:
1
2
3
4
|
void operator delete ( void * pointer, const char * file, const char * func, const int line);
void operator delete []( void * pointer, const char * file, const char * func, const int line);
void operator delete ( void * pointer,std::nothrow_t&);
void operator delete []( void * pointer,std::nothrow_t&);<br>
|
3.检查内存泄露
有了上面的实现,我们可以方便的手动检测内存泄露。通过一个函数来实现,它会检索整个hash表,如果表不为空则存在内存泄露。
为了达到在最后程序退出时检查内存泄露的目的,我们需要在所有对象调用析构函数后进行内存泄露检测,这是因为某些用户类型在构造函数里调用new而在析构函数里调用delete。这样做能大大的减小误报的概率。而且因为对象的析构函数的调用往往在主函数main()执行结束之后进行,所以我们也不能直接在主函数里进行内存泄露检测。这里我们利用一个全局对象来实现这种检测。首先我们定义一个类:
1
2
3
4
5
6
7
8
9
10
11
|
class MemCheck{
public :
MemCheck(){
memset (pTable,0, sizeof (mc_block_node_t*) * MC_HASHTABLESIZE);
}
~MemCheck(){
if (mc_checkmem()){
abort ();
}
}
}; |
这里的构造函数初始化Hash表。析构函数检测内存泄露。然后定义一个MemCheck的全局静态对象,这样当程序运行之前会初始化hash表,程序退出时检测内存泄露。可是问题又来了,如果一个程序中有多个全局静态对象会怎样?不幸的是,对于全局静态对象的构造顺序和析构顺序是C++标准中的一个未定义问题,也就是说,这个顺序取决于编译器的具体实现。考虑,绝大多数平台使用VC和GCC编译器,我们可以针对这两种编译器来控制全局对象的构造和解析顺序。
1
2
3
4
5
6
7
8
9
|
#ifdef _MSC_VER #pragma init_seg(lib) #endif static MemCheck mc_autocheck_object
#ifdef __GNUC__ __attribute__((init_priority (101))) #endif ; |
这里的宏定义部分都是编译器的选项。