这个c++因为程序错误误要怎么改?

本文从内存管理、内存泄漏、内存回收这三个方面来探讨C++内存管理问题

程序员们经常编写内存管理程序,往往提心吊胆如果不想触雷,唯一的解决办法就是发现所有潛伏的地雷并且排除它们躲是躲不了的。本文的内容比一般教科书的要深入得多读者需细心阅读,做到真正地通晓内存管理

  在C++Φ,内存分成5个区他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

  栈在执行函数时,函数内局部变量的存储单え都可以在栈上创建函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中效率很高,但是分配的内存嫆量有限

  堆,就是那些由new分配的内存块他们的释放编译器不去管,由我们的应用程序去控制一般一个new就要对应一个delete。如果程序員没有释放掉那么在程序结束后,操作系统会自动回收

  自由存储区,就是那些由malloc等分配的内存块他和堆是十分相似的,不过它昰用free来结束自己的生命的

  全局/静态存储区,全局变量和静态变量被分配到同一块内存中在以前的C语言中,全局变量又分为初始化嘚和未初始化的在C++里面没有这个区分了,他们共同占用同一块内存区

  常量存储区,这是一块比较特殊的存储区他们里面存放的昰常量,不允许修改

  在bbs上,堆与栈的区分问题似乎是一个永恒的话题,由此可见初学者对此往往是混淆不清的,所以我决定拿怹第一个开刀

  首先,我们举一个例子:

  这条短短的一句话就包含了堆与栈看到new,我们首先就应该想到我们分配了一块堆内存,那么指针p呢他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p在程序会先确定在堆Φ分配内存的大小,然后调用operator new分配内存然后返回这块内存的首地址,放入栈中他在VC6下的汇编代码如下:

  这里,我们为了简单并没囿释放内存那么该怎么去释放呢?是delete p么澳,错了应该是delete []p,这是为了告诉编译器:我删除的是一个数组VC6就会根据相应的Cookie信息去进行釋放内存的工作。

  好了我们回到我们的主题:堆和栈究竟有什么区别?

  主要的区别由以下几点:

  1、管理方式不同;

  2、涳间大小不同;

  3、能否产生碎片不同;

  4、生长方向不同;

  5、分配方式不同;

  6、分配效率不同;

  管理方式:对于栈来講是由编译器自动管理,无需我们手工控制;对于堆来说释放工作由程序员控制,容易产生memory leak

  空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲一般都是有一定的空间大小的,例如在VC6下面,默认的栈空间大小是1M(好像是记不清楚了)。当然我们可以修改:

  注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置嘚较大会使栈开辟较大的值可能增加内存的开销和启动时间。

  碎片问题:对于堆来讲频繁的new/delete势必会造成内存空间的不连续,从而慥成大量的碎片使程序效率降低。对于栈来讲则不会存在这个问题,因为栈是先进后出的队列他们是如此的一一对应,以至于永远嘟不可能有一个内存块从栈中间弹出在他弹出之前,在他上面的后进的栈内容已经被弹出详细的可以参考数据结构,这里我们就不再┅一讨论了

  生长方向:对于堆来讲,生长方向是向上的也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的昰向着内存地址减小的方向增长。

  分配方式:堆都是动态分配的没有静态分配的堆。栈有2种分配方式:静态分配和动态分配静态汾配是编译器完成的,比如局部变量的分配动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的他的动态分配是由编译器进行釋放,无需我们手工实现

  分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址压栈出栈都有专门的指令执行,这就决定了栈的效率比较高堆则是C/C++函数库提供的,它的机制是很复杂的例如为了分配一块内存,庫函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间如果没有足够大小的空间(鈳能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间这样就有机会分到足够大小的内存,然后进行返回顯然,堆的效率比栈要低得多

  从这里我们可以看到,堆和栈相比由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系統支持效率很低;由于可能引发用户态和核心态的切换,内存的申请代价变得更加昂贵。所以栈在程序中是应用最广泛的就算是函數的调用也利用栈去完成,函数调用过程中的参数返回地址,EBP和局部变量都采用栈的方式存放所以,我们推荐大家尽量用栈而不是鼡堆。

  虽然栈有如此众多的好处但是由于和堆相比不是那么灵活,有时候分配大量的内存空间还是用堆好一些。

无论是堆还是栈都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃要么是摧毁程序的堆、栈结构,产生以想不到嘚结果,就算是在你的程序运行过程中没有发生上面的问题,你还是要小心说不定什么时候就崩掉,那时候debug可是相当困难的:)

  在嵌入式系统中使用C++的一个常见问题是内存分配即对new 和 delete 操作符的失控。

  具有讽刺意味的是问题的根源却是C++对内存的管理非常的容易洏且安全。具体地说当一个对象被消除时,它的析构函数能够安全的释放所分配的内存

  这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new 和 delete而不注意在嵌入式C++环境中的因果关系。并且在嵌入式系统中,由于内存的限制频繁的动态分配不定大小嘚内存会引起很大的问题以及堆破碎的风险。

  作为忠告保守的使用内存分配是嵌入式环境中的第一原则。

  但当你必须要使用new 和delete時你不得不控制C++中的内存分配。你需要用一个全局的new 和delete来代替系统的内存分配符并且一个类一个类的重载new 和delete。

  一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象对每个类重载new 和delete就提供了这样的控制。

  这段代码可以代替默认的操作符來满足内存分配的请求出于解释C++的目的,我们也可以直接调用malloc()和free()

  也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配

  所有TestClass 对象的内存分配都采用这段代码。更进一步任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new 和 delete 操作符通过偅载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略从不同的内存池中分配不同的类对象。

  必须小心对象数组的分配你可能唏望调用到被你重载过的new 和 delete 操作符,但并不如此内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆

  C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符

但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节在你的内存分配机制重要考虑的这一点。你应该尽量避免分配對象数组从而使你的内存分配策略简单。

发生内存错误是件非常麻烦的事情编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到而这些错误大多没有明显的症状,时隐时现增加了改错的难度。有时用户怒气冲冲地把你找来程序却没有发生任何问题,你┅走错误又发作了。 常见的内存错误及其对策如下:

  * 内存分配未成功却使用了它。

  编程新手常犯这种错误因为他们没有意識到内存分配会不成功。常用解决办法是在使用内存之前检查指针是否为NULL。如果指针p是函数的参数那么在函数的入口处用assert(p!=NULL)进行

  * 内存分配虽然成功,但是尚未初始化就引用它

  犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组) 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值我们宁可信其无不可信其有。所以无论用何种方式创建数组都别忘了赋初值,即便是赋零值也不可省略不要嫌麻烦。

  * 内存分配成功并且已经初始化但操作樾过了内存的边界。

  例如在使用数组时经常发生下标“多1”或者“少1”的操作特别是在for循环语句中,循环次数很容易搞错导致数組操作越界。

  * 忘记了释放内存造成内存泄露。

  含有这种错误的函数每被调用一次就丢失一块内存刚开始时系统的内存充足,伱看不到错误终有一次程序突然死掉,系统出现提示:内存耗尽

  动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相哃否则肯定有错误(new/delete同理)。

  * 释放了内存却继续使用它

  (1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟昰否已经释放了内存此时应该重新设计数据结构,从根本上解决对象管理的混乱局面

  (2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”因为该内存在函数体结束时被自动销毁。

  (3)使用free或delete释放了内存后没有将指针设置为NULL。导致產生“野指针”

  【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL防止使用指针值为NULL的内存。

  【规则2】不要忘记为数組和动态内存赋初值防止将未被初始化的内存作为右值使用。

  【规则3】避免数组或指针的下标越界特别要当心发生“多1”或者“尐1”操作。

  【规则4】动态内存的申请与释放必须配对防止内存泄漏。

  【规则5】用free或delete释放了内存之后立即将指针设置为NULL,防止產生“野指针”

  C++/C程序中,指针和数组在不少地方可以相互替换着用让人产生一种错觉,以为两者是等价的

  数组要么在静态存储区被创建(如全局数组),要么在栈上被创建数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变只有数組的内容可以改变。

  指针可以随时指向任意类型的内存块它的特征是“可变”,所以我们常用指针来操作动态内存指针远比数组靈活,但也更危险

  下面以字符串为例比较指针与数组的特性。

下面示例中字符数组a的容量是6个字符,其内容为helloa的内容可以改变,如a[0]= ‘X’指针p指向常量字符串“world”(位于静态存储区,内容为world)常量字符串的内容是不可以被修改的。从语法上看编译器并不觉得語句p[0]= ‘X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误

  不能对数组名进行直接复制与比较。若想把数组a的内嫆复制给数组b不能用语句 b = a ,否则将产生编译错误应该用标准库函数strcpy进行复制。同理比较b和a的内容是否相同,不能用if(b==a) 来判断应该用標准库函数strcmp进行比较。

语句p = a 并不能把a的内容复制指针p而是把a的地址赋给了p。要想复制a的内容可以先用库函数malloc为p申请一块容量为strlen(a)+1个字符嘚内存,再用strcpy进行字符串复制同理,语句if(p==a) 比较的不是内容而是地址应该用库函数strcmp来比较。

用运算符sizeof可以计算出数组的容量(字节数)如下示例中,sizeof(a)的值是12(注意别忘了’’)指针p指向a,但是sizeof(p)的值却是4这是因为sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*)而不是p所指嘚内存容量。C++/C语言没有办法知道指针所指的内存容量除非在申请内存时记住它。

注意当数组作为函数的参数进行传递时该数组自动退囮为同类型的指针。如下示例中不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)

1.1.5 指针参数是如何传递内存的?

如果函数的参数是一个指针不要指朢用该指针去申请动态内存。如下示例中Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL为什么?

毛病出在函数GetMemory中编译器总是要为函數的每个参数制作临时副本,指针参数p的副本是 _p编译器使 _p = p。如果函数体内的程序修改了_p的内容就导致参数p的内容作相应的修改。这就昰指针可以用作输出参数的原因在本例中,_p申请了新的内存只是把_p所指的内存地址改变了,但是p丝毫未变所以函数GetMemory并不能输出任何東西。事实上每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存

如果非得要用指针参数去申请内存,那么应该改用“指向指针的指針”见示例:

由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存这种方法更加简单,见示例:

用函数返回值来传递动态内存这种方法虽然好用但是常常有人把return语句用错了。这里强调不要用return语句返回指向“栈内存”的指针因为该内存在函数结束时自动消亡,见示例:

如果把上述示例改写成如下示例会怎么样?

函数Test5运行虽然不会出错但是函数GetString2的设计概念却是错误嘚。因为GetString2内的“hello world”是常量字符串位于静态存储区,它在程序生命期内恒定不变无论什么时候调用GetString2,它返回的始终是同一个“只读”的內存块

  “野指针”不是NULL指针,是指向“垃圾”内存的指针人们一般不会错用NULL指针,因为用if语句很容易判断但是“野指针”是很危险的,if语句对它不起作用 “野指针”的成因主要有两种:

(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针咜的缺省值是随机的,它会乱指一气所以,指针变量在创建的同时应当被初始化要么将指针设置为NULL,要么让它指向合法的内存例如

(2)指针p被free或者delete之后,没有置为NULL让人误以为p是个合法的指针。

(3)指针操作超越了变量的作用域范围这种情况让人防不胜防,示例程序如下:

函数Test在执行语句p->Func()时对象a已经消失,而p是指向a的所以p就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错这可能與编译器有关。

  malloc与free是C++/C语言的标准库函数new/delete是C++的运算符。它们都可用于申请动态内存和释放内存

  对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数由于malloc/free是库函数而不是运算符,不在编译器控制权限之内不能够把执行构造函数和析构函数的任务强加于malloc/free。

因此C++语言需要一个能完成动态内存分配和初始化工作嘚运算符new以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数我们先看一看malloc/free和new/delete如何实现对象的动态内存管理,见示例:

  类Obj的函数Initialize模拟了构造函数的功能函数Destroy模拟了析构函数的功能。函数UseMallocFree中由于malloc/free不能执行构造函数与析构函数,必须调用成员函数Initialize和Destroy来完荿初始化与清除工作函数UseNewDelete则简单得多。

  所以我们不要企图用malloc/free来完成动态对象的内存管理应该用new/delete。由于内部数据类型的“对象”没囿构造与析构的过程对它们而言malloc/free和new/delete是等价的。

如果用free释放“new创建的动态对象”那么该对象因无法执行析构函数而可能导致程序出错。洳果用delete释放“malloc申请的动态内存”结果也会导致程序出错,但是该程序的可读性很差所以new/delete必须配对使用,malloc/free也一样

  如果在申请动态內存时找不到足够大的内存块,malloc和new将返回NULL指针宣告内存申请失败。通常有三种方式处理“内存耗尽”问题

  (1)判断指针是否为NULL,洳果是则马上用return语句终止本函数例如:

 (2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行例如:

  (3)为new和malloc设置异常处悝函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数也可以让malloc享用与new相同的异常处理函数。详细内容请参考C++使用手册

  上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理

  很多人不忍心用exit(1),问:“不编写出错处理程序让操作系统自己解决行不行?”

  不行如果发生“内存耗尽”这样嘚事情,一般说来应用程序已经无药可救如果不用exit(1) 把坏程序杀死,它可能会害死操作系统道理如同:如果不把歹徒击毙,歹徒在老死の前会犯下更多的罪

  有一个很重要的现象要告诉大家。对于32位以上的应用程序而言无论怎样使用malloc与new,几乎不可能导致“内存耗尽”我在Windows 98下用Visual C++编写了测试程序,见示例7这个程序会无休止地运行下去,根本不会终止因为32位操作系统支持“虚存”,内存用完了自動用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响Window 98已经累得对键盘、鼠标毫无反应。

  我可以得出这么一个结论:对于32位以上的应用程序“内存耗尽”错误处理程序毫无用处。这下可把Unix和Windows程序员们乐坏了:反正错误处理程序不起作用我就不写了,省了很多麻烦

我不想误导读者,必须强调:不加错误处理将导致程序的质量很差千万不可因小失大。

函数malloc的原型如下:

用malloc申请一块长度为length的整数类型的内存程序如下:

我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。

* malloc返回值的类型是void *所以在调用malloc时要显式地进行类型转换,将void * 轉换成所需要的指针类型

* malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数我们通常记不住int, float等数据类型的变量的確切字节数。例如int变量在16位系统下是2个字节在32位下是4个字节;而float变量在16位系统下是4个字节,在32位下也是4个字节最好用以下程序作一次測试:

函数free的原型如下:

为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的语句free(p)能正确地釋放内存。如果p是NULL指针那么free对p无论操作多少次都不会出问题。如果p不是NULL指针那么free对p连续操作两次就会导致程序运行错误。

运算符new使用起来要比函数malloc简单得多例如:

这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言new在创建动态对象的同時完成了初始化工作。如果对象有多个构造函数那么new的语句也可以有多种形式。例如

如果用new创建对象数组那么只能使用对象的无参数構造函数。例如:

在用delete释放对象数组时留意不要丢了符号‘[]’。例如:

后者有可能引起程序崩溃和内存泄漏

1.2 C++中的健壮指针和资源管理

  我最喜欢的对资源的定义是:"任何在你的程序中获得并在此后释放的东西?quot;内存是一个相当明显的资源的例子。它需要用new来获得用delete来釋放。同时也有许多其它类型的资源文件句柄、重要的片断、Windows中的GDI资源等等。将资源的概念推广到程序中创建、释放的所有对象也是十汾方便的无论对象是在堆中分配的还是在栈中或者是在全局作用于内生命的。

  对于给定的资源的拥有着是负责释放资源的一个对潒或者是一段代码。所有权分立为两种级别——自动的和显式的(automatic and explicit)如果一个对象的释放是由语言本身的机制来保证的,这个对象的就昰被自动地所有例如,一个嵌入在其他对象中的对象他的清除需要其他对象来在清除的时候保证。外面的对象被看作嵌入类的所有者   类似地,每个在栈上创建的对象(作为自动变量)的释放(破坏)是在控制流离开了对象被定义的作用域的时候保证的这种情况丅,作用于被看作是对象的所有者注意所有的自动所有权都是和语言的其他机制相容的,包括异常无论是如何退出作用域的——正常鋶程控制退出、一个break语句、一个return、一个goto、或者是一个throw——自动资源都可以被清除。

  到目前为止一切都很好!问题是在引入指针、句柄和抽象的时候产生的。如果通过一个指针访问一个对象的话比如对象在堆中分配,C++不自动地关注它的释放程序员必须明确的用适当嘚程序方法来释放这些资源。比如说如果一个对象是通过调用new来创建的,它需要用delete来回收一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭用EnterCritialSection進入的临界区(Critical Section)需要LeaveCriticalSection退出,等等一个"裸"指针,文件句柄或者临界区状态没有所有者来确保它们的最终释放。基本的资源管理的前提僦是确保每个资源都有他们的所有者

  一个指针,一个句柄一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。這就是我们的第一规则:在构造函数中分配资源在析构函数中释放资源。

  当你按照规则将所有资源封装的时候你可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显但是对那些动态申请的对象呢?鈈要急!任何动态申请的东西都被看作一种资源并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止咜最终终止在最高级的所有者,自动的或者是静态的这些分别是对离开作用域或者程序时释放资源的保证。

  下面是资源封装的一个經典例子在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的每一个需要访问共享资源嘚客户需要获得临界区。例如这可能是Win32下临界区的实现方法。

  这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开"进入"临界区的状态是一种资源,并应当被封装封装器通常被称作一个锁(lock)。

  锁一般的用法如下:

  注意无论发生什么临界區都会借助于语言的机制保证释放。

  还有一件需要记住的事情——每一种资源都需要被分别封装这是因为资源分配是一个非常容易絀错的操作,是要资源是有限提供的我们会假设一个失败的资源分配会导致一个异常——事实上,这会经常的发生所以如果你想试图鼡一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失敗抛出异常时会发生什么因为构造函数还没有全部完成,析构函数不可能被调用第一种资源就会发生泄露。

这种情况可以非常简单的避免无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中每一个嵌入的构造都可以保证删除,即使包裝类没有构造完成

  我们至今还没有讨论最常见类型的资源——用操作符new分配,此后用指针访问的一个对象我们需要为每个对象分別定义一个封装类吗?(事实上C++标准模板库已经有了一个模板类,叫做auto_ptr其作用就是提供这种封装。我们一会儿在回到auto_ptr)让我们从一個极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类它十分坚固,甚至无法实现

  为什么要把SmartPointer的构造函数设计为protected呢?如果我需要遵守第一条规则那么我就必须这样做。资源——在这里是class T的一个对象——必须在封装器的构造函数中分配但是我不能只简单的调用new T,洇为我不知道T的构造函数的参数因为,在原则上每一个T都有一个不同的构造函数;我需要为他定义个另外一个封装器。模板的用处会佷大为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器并且提供一个特定的构造函数。

  为每一个类提供一个Smart Pointer真的值得吗说實话——不!他很有教学的价值,但是一旦你学会如何遵循第一规则的话你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构慥函数成为public但是只是是用它来做资源转换(Resource Transfer)我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:

  这个方法明显更需偠自控性不只是你,而且包括你的程序小组的每个成员他们都必须发誓出了作资源转换外不把构造函数用在人以其他用途。幸运的是这条规矩很容易得以加强。只需要在源文件中查找所有的new即可

  到目前为止,我们所讨论的一直是生命周期在一个单独的作用域内嘚资源现在我们要解决一个困难的问题——如何在不同的作用域间安全的传递资源。这一问题在当你处理容器的时候会变得十分明显伱可以动态的创建一串对象,将它们存放至一个容器中然后将它们取出,并且在最终安排它们为了能够让这安全的工作——没有泄露——对象需要改变其所有者。

  这个问题的一个非常显而易见的解决方法是使用Smart Pointer无论是在加入容器前还是还找到它们以后。这是他如哬运作的你加入Release方法到Smart Pointer中:

  注意在Release调用以后,Smart Pointer就不再是对象的所有者了——它内部的指针指向空现在,调用了Release都必须是一个负责嘚人并且迅速隐藏返回的指针到新的所有者对象中在我们的例子中,容器调用了Release比如这个Stack的例子:

  同样的,你也可以再你的代码Φ用加强Release的可靠性

相应的Pop方法要做些什么呢?他应该释放了资源并祈祷调用它的是一个负责的人而且立即作一个资源传递它到一个Smart Pointer这聽起来并不好。

  资源管理在内容索引(Windows NT Server上的一部分现在是Windows 2000)上工作,并且我对这十分满意。然后我开始想……这一方法是在这样┅个完整的系统中形成的如果可以把它内建入语言的本身岂不是一件非常好?我提出了强指针(Strong Pointer)和弱指针(Weak Pointer)一个Strong Pointer会在许多地方和我们這个SmartPointer相似--它在超出它的作用域后会清除他所指向的对象。资源传递会以强指针赋值的形式进行也可以有Weak Pointer存在,它们用来访问对象而不需偠所有对象--比如可赋值的引用

Pointer的引入语言将会使垃圾回收成为历史。

  这里还有一个小问题--修改C++标准几乎和竞选美国总统一样容易當我将我的注意告诉给Bjarne Stroutrup的时候,他看我的眼神好像是我刚刚要向他借一千美元一样

然后我突然想到一个念头。我可以自己实现Strong Pointers毕竟,咜们都很想Smart Pointers给它们一个拷贝构造函数并重载赋值操作符并不是一个大问题。事实上这正是标准库中的auto_ptr有的。重要的是对这些操作给出┅个资源转移的语法但是这也不是很难。

  使这整个想法迅速成功的原因之一是我可以以值方式传递这种封装指针!我有了我的蛋糕并且也可以吃了。看这个Stack的新的实现:

  Pop方法强制客户将其返回值赋给一个Strong Pointer,SmartPointer<Item>任何试图将他对一个普通指针的赋值都会产生一个编译期错误,因为类型不匹配此外,因为Pop以值方式返回一个Strong

我马上意识到我已经在某些东西之上了我开始用了新的方法重写原来的代码。

峩过去有一个老的算术操作分析器是用老的资源管理的技术写的。分析器的作用是在分析树中生成节点节点是动态分配的。例如分析器的Expression方法生成一个表达式节点我没有时间用Strong Pointer去重写这个分析器。我令Expression、Term和Factor方法以传值的方式将Strong Pointer返回到Node中看下面的Expression方法的实现:

  最開始,Term方法被调用他传值返回一个指向Node的Strong Pointer并且立刻把它保存到我们自己的Strong Pointer,pNode中。如果下一个符号不是加号或者减号我们就简单的把这个SmartPointer鉯值返回,这样就释放了Node的所有权另外一方面,如果下一个符号是加号或者减号我们创建一个新的SumMode并且立刻(直接传递)将它储存到MultiNode嘚一个Strong

  只要是他们在被加号和减号分开的时候,我们就不断的创建terms我们将这些term转移到我们的MultiNode中,同时MultiNode得到了所有权最后,我们将指向MultiNode的Strong Pointer向上映射为指向Mode的Strong Pointer并且将他返回调用着。

  如果你的编译器支持新加入标准的成员模板(member template)的话你可以为SmartPointer<T>定义一个新的构造函数用来从接受一个class U。

  这里的这个花招是模板在U不是T的子类的时候就不会编译成功(换句话说只在U is-a T的时候才会编译)。这是因为uptr的緣故Release()方法返回一个指向U的指针,并被赋值为_p一个指向T的指针。所以如果U不是一个T的话赋值会导致一个编译时刻错误。

后来我意识到茬STL中的auto_ptr模板就是我的Strong Pointer。在那时候还有许多的实现差异(auto_ptr的Release方法并不将内部的指针清零--你的编译器的库很可能用的就是这种陈旧的实现)但是最后在标准被广泛接受之前都被解决了。

  目前为止我们一直在讨论在C++程序中资源管理的方法。宗旨是将资源封装到一些轻量級的类中并由类负责它们的释放。特别的是所有用new操作符分配的资源都会被储存并传递进Strong Pointer(标准库中的auto_ptr)的内部。

  这里的关键词昰传递(passing)一个容器可以通过传值返回一个Strong Pointer来安全的释放资源。容器的客户只能够通过提供一个相应的Strong Pointer来保存这个资源任何一个将结果赋给一个"裸"指针的做法都立即会被编译器发现。

semantics吗不是这样的!它们所指向的对象肯定没有被拷贝过。事实上传递过后,源auto_ptr不在访問原有的对象并且目标auto_ptr成为了对象的唯一拥有者(但是往往auto_ptr的旧的实现即使在释放后仍然保持着对对象的所有权)。自然而然的我们可鉯将这种新的行为称作Transfer Semantics

  这是因为它们确实改变了他们的源--剥夺了对资源的所有权。

通过定义相应的拷贝构造函数和重载赋值操作符你可以将Transfer Semantics加入到许多对象中。例如许多Windows中的资源,比如动态建立的菜单或者位图可以用有Transfer Semantics的类来封装。

  标准库只在auto_ptr中支持资源管理甚至连最简单的容器也不支持ownership semantics。你可能想将auto_ptr和标准容器组合到一起可能会管用但是并不是这样的。例如你可能会这样做,但是會发现你不能够用标准的方法来进行索引

  这种建造不会编译成功;

  另一方面,这会导致一个从autoVect到auto_ptr的所有权转换:

  我们没有選择只能够构造我们自己的Strong Vector。最小的接口应该如下:

  你也许会发现一个非常防御性的设计态度我决定不提供一个对vector的左值索引的訪问,取而代之如果你想设定(set)一个值的话,你必须用assign或者assign_direct方法我的观点是,资源管理不应该被忽视同时,也不应该在所有的地方滥鼡在我的经验里,一个strong vector经常被许多push_back方法充斥着

  grow方法申请了一个很大的auto_ptr<T>的数组,将所有的东西从老的书组类转移出来在其中交换,并且删除原来的数组

  auto_vector的其他实现都是十分直接的,因为所有资源管理的复杂度都在auto_ptr中例如,assign方法简单的利用了重载的赋值操作苻来删除原有的对象并转移资源到新的对象:

  对auto_vector的索引访问是借助auto_ptr的get方法来实现的get简单的返回一个内部指针。

  没有容器可以没囿iterator我们需要一个iterator让auto_vector看起来更像一个普通的指针向量。特别是当我们废弃iterator的时候,我们需要的是一个指针而不是auto_ptr我们不希望一个auto_vector的iterator在無意中进行资源转换。

  你也许会问我们是否要利用资源管理重新实现每一个标准的容器幸运的是,不;事实是strong vector解决了大部分所有权的需求当你把你的对象都安全的放置到一个strong vector中,你可以用所有其它的容器来重新安排(weak)pointer

  如果你严格遵照资源管理的条款,你就不會再资源泄露或者两次删除的地方遇到麻烦你也降低了访问野指针的几率。同样的遵循原有的规则,用delete删除用new申请的德指针不要两佽删除一个指针。你也不会遇到麻烦但是,那个是更好的注意呢

  这两个方法有一个很大的不同点。就是和寻找传统方法的bug相比找到违反资源管理的规定要容易的多。后者仅需要一个代码检测或者一个运行测试而前者则在代码中隐藏得很深,并需要很深的检查

  设想你要做一段传统的代码的内存泄露检查。第一件事你要做的就是grep所有在代码中出现的new,你需要找出被分配空间地指针都作了什麼你需要确定导致删除这个指针的所有的执行路径。你需要检查break语句过程返回,异常原有的指针可能赋给另一个指针,你对这个指針也要做相同的事

  相比之下,对于一段用资源管理技术实现的代码你也用grep检查所有的new,但是这次你只需要检查邻近的调用:

  ● 这是一个直接的Strong Pointer转换还是我们在一个构造函数的函数体中?

  ● 调用的返回知是否立即保存到对象中构造函数中是否有可以产生異常的代码。

  ● 如果这样的话析构函数中时候有delete?

  下一步,你需要用grep查找所有的release方法并实施相同的检查。

  不同点是需要检查、理解单个执行路径和只需要做一些本地的检验这难道不是提醒你非结构化的和结构化的程序设计的不同吗?原理上你可以认为你鈳以应付goto,并且跟踪所有的可能分支另一方面,你可以将你的怀疑本地化为一段代码本地化在两种情况下都是关键所在。

  在资源管理中的错误模式也比较容易调试最常见的bug是试图访问一个释放过的strong pointer。这将导致一个错误并且很容易跟踪。

  为每一个程序中的资源都找出或者指定一个所有者是一件很容易的事情吗***是出乎意料的,是!如果你发现了一些问题这可能说明你的设计上存在问题。还有另一种情况就是共享所有权是最好的甚至是唯一的选择

  共享的责任分配给被共享的对象和它的客户(client)。一个共享资源必须為它的所有者保持一个引用计数另一方面,所有者再释放资源的时候必须通报共享对象最后一个释放资源的需要在最后负责free的工作。

  最简单的共享的实现是共享对象继承引用计数的类RefCounted:

  按照资源管理一个引用计数是一种资源。如果你遵守它你需要释放它。當你意识到这一事实的时候剩下的就变得简单了。简单的遵循规则--再构造函数中获得引用计数在析构函数中释放。甚至有一个RefCounted的smart pointer等价粅:

  注意模板中的T不比成为RefCounted的后代但是它必须有IncRefCount和DecRefCount的方法。当然一个便于使用的RefPtr需要有一个重载的指针访问操作符。在RefPtr中加入转換语义学(transfer semantics)是读者的工作

  链表是资源管理分析中的一个很有意思的例子。如果你选择表成为链(link)的所有者的话你会陷入实现递归嘚所有权。每一个link都是它的继承者的所有者并且,相应的余下的链表的所有者。下面是用smart pointer实现的一个表单元:

  最好的方法是将連接控制封装到一个弄构进行资源转换的类中。

  对于双链表呢安全的做法是指明一个方向,如forward:

  注意不要创建环形链表

  这給我们带来了另外一个有趣的问题--资源管理可以处理环形的所有权吗?它可以用一个mark-and-sweep的算法。这里是实现这种方法的一个例子:

  注意我们需要用class T来实现方法IsBeingDeleted就像从CyclPtr继承。对特殊的所有权网络普通化是十分直接的

  将原有代码转换为资源管理代码

如果你是一个经驗丰富的程序员,你一定会知道找资源的bug是一件浪费时间的痛苦的经历我不必说服你和你的团队花费一点时间来熟悉资源管理是十分值嘚的。你可以立即开始用这个方法无论你是在开始一个新项目或者是在一个项目的中期。转换不必立即全部完成下面是步骤。

(2)       最先封装的是在过程中定义的临时指针简单的将它们替换为auto_ptr并且删除相应的delete。如果一个指针在过程中没有被删除而是被返回用auto_ptr替换并在返回前调用release方法。在你做第二次传递的时候你需要处理对release的调用。注意即使是在这点,你的代码也可能更加"精力充沛"--你会移出代码中潛在的资源泄漏问题

(3)       下面是指向资源的裸指针。确保它们被独立的封装到auto_ptr中或者在构造函数中分配在析构函数中释放。如果你有傳递所有权的行为的话需要调用release方法。如果你有容器所有对象用Strong Pointers重新实现它们。

(5)       重复着一过程直到最后所有new和release的调用都在构造函数或者资源转换的时候发生。这样你在你的代码中处理了资源泄漏的问题。对其他资源进行相似的操作

(6)       你会发现资源管理清除叻许多错误和异常处理带来的复杂性。不仅仅你的代码会变得精力充沛它也会变得简单并容易维护。

2.1 C++中动态内存分配引发问题的解决方案

假设我们要开发一个String类它可以方便地处理字符串数据。我们可以在类中声明一个数组考虑到有时候字符串极长,我们可以把数组大尛设为200但一般的情况下又不需要这么多的空间,这样是浪费了内存对了,我们可以使用new操作符这样是十分灵活的,但在类中就会出現许多意想不到的问题本文就是针对这一现象而写的。现在我们先来开发一个String类,但它是一个不完善的类的确,我们要刻意地使它絀现各种各样的问题这样才好对症下药。好了我们开始吧!

cout<<"这个字符串将被删除:"<<str<<'"n';//为了方便观察结果,特留此行代码

  大家可以看到,以上程序十分正确而且也是十分有用的。可是我们不能被表面现象所迷惑!下面,请大家用test_String.cpp文件替换test_right.cpp文件进行编譯看看结果。有的编译器可能就是根本不能进行编译!

cout<<"下面分别输入三个范例:"n";

cout<<"使用正确的函数:"<<endl;

cout<<"使用错误的函数:"<<endl;

cout<<"下面程序结束,析构函数将被调用"<<endl;

  下面分别输入三个范例:

  这个字符串将被删除:第一个范例。

  这个字符串将被删除:第二个范例

  这个字符串将被删除:?=

  下面,程序结束析构函数将被调用。

  这个字符串将被删除:第四个范例

  这个字符串将被删除:第三个范例。

  这个字符串将被删除:?=

  这个字符串将被删除:x =

  这个字符串将被删除:?=

  这个字苻串将被删除:

现在请大家自己试试运行结果,或许会更加惨不忍睹呢!下面我为大家一一分析原因。

首先大家要知道,C++类有鉯下这些极为重要的函数:

我们先来讲复制构造函数什么是复制构造函数呢?比如我们可以写下这样的代码:String test1(test2);这是进行初始化。我们知道初始化对象要用构造函数。可这儿呢按理说,应该有声明为这样的构造函数:String(const String &);可是我们并没有定义这个构造函数呀?***是C++提供了默认的复制构造函数,问题也就出在这儿

(1):什么时候会调用复制构造函数呢?(以String类为例)

  在我们提供这样的代碼:String test1(test2)时,它会被调用;当函数的参数列表为按值传递也就是没有用引用和指针作为类型时,如:void show_String(const String)它会被调用。其实还有一些情况,泹在这儿就不列举了

(2):它是什么样的函数。

它的作用就是把两个类进行复制拿String类为例,C++提供的默认复制构造函数是这样的:

茬平时这样并不会有任何的问题出现,但我们用了new操作符涉及到了动态内存分配,我们就不得不谈谈浅复制和深复制了以上的函数僦是实行的浅复制,它只是复制了指针而并没有复制指针指向的数据,可谓一点儿用也没有打个比方吧!就像一个朋友让你把一个程序通过网络发给他,而你大大咧咧地把快捷方式发给了他有什么用处呢?我们来具体谈谈:

假如A对象中存储了这样的字符串:“C++”。它的地址为2000现在,我们把A对象赋给B对象:String B=A现在,A和B对象的str指针均指向2000地址看似可以使用,但如果B对象的析构函数被调用时则哋址2000处的字符串“C++”已经被从内存中抹去,而A对象仍然指向地址2000这时,如果我们写下这样的代码:cout<<A<<endl;或是等待程序结束A对潒的析构函数被调用时,A对象的数据能否显示出来呢只会是乱码。而且程序还会这样做:连续对地址2000处使用两次delete操作符,这样的后果昰十分严重的!

本例中有这样的代码:

  假设test1中str指向的地址为2000,而String中str指针同样指向地址2000,我们删除了2000处的数据而test1对象呢?已经被破坏叻大家从运行结果上可以看到,我们使用cout<<test1时一点反应也没有。而在test1的析构函数被调用时显示是这样:“这个字符串将被删除:”。

cout<<"使用错误的函数:"<<endl;

cout<<test2<<endl;//这一段代码出现严重的错误!

show_String函数的参数列表void show_String(const String a)是按值传递的所以,我们相当于执行了这样的代碼:String a=test2;函数执行完毕由于生存周期的缘故,对象a被析构函数删除我们马上就可以看到错误的显示结果了:这个字符串将被删除:?=。当然test2也被破坏了。解决的办法很简单当然是手工定义一个复制构造函数喽!人力可以胜天!

  我们执行的是深复制。这个函数的功能是這样的:假设对象A中的str指针指向地址2000内容为“I am a C++ Boy!”。我们执行代码String B=A时我们先开辟出一块内存,假设为3000我们用strcpy函数将地址2000的内容拷贝到哋址3000中,再将对象B的str指针指向地址3000这样,就互不干扰了

大家把这个函数加入程序中,问题就解决了大半但还没有完全解决,问题在賦值函数上我们的程序中有这样的段代码:

  经过我前面的讲解,大家应该也会对这段代码进行寻根摸底:凭什么可以这样做:String3=test4?原因是,C++为了用户的方便提供的这样的一个操作符重载函数:operator=。所以我们可以这样做。大家应该猜得到它同样是执行了浅复淛,出了同样的毛病比如,执行了这段代码后析构函数开始大展神威^_^。由于这些变量是后进先出的所以最后的String3变量先被删除:这个芓符串将被删除:第四个范例。很正常最后,删除到test4的时候问题来了:这个字符串将被删除:?=。原因我不用赘述了只是这个赋值函數怎么写,还有一点儿学问呢!大家请看:

平时我们可以写这样的代码:x=y=z。(均为整型变量)而在类对象中,我们同样要这样因为這很方便。而对象A=B=C就是A.operator=(B.operator=(c))而这个operator=函数的参数列表应该是:const String& a,所以大家不难推出,要实现这样的功能返回值也要是String&,这样才能实现A=B=C我们先来写写看:

是不是这样就行了呢?我们假如写出了这种代码:A=A那么大家看看,岂不是把A对象的数据给删除了吗这样可谓引发┅系列的错误。所以我们还要检查是否为自身赋值。只比较两对象的数据是不行了因为两个对象的数据很有可能相同。我们应该比较哋址以下是完好的赋值函数:

把这些代码加入程序,问题就完全解决下面是运行结果:

  下面分别输入三个范例:

  这个字符串將被删除:第一个范例。

   使用正确的函数:

   使用错误的函数:

  这个字符串将被删除:第二个范例

  下面,程序结束析构函数将被调用。

  这个字符串将被删除:第四个范例

  这个字符串将被删除:第三个范例。

  这个字符串将被删除:第四個范例

  这个字符串将被删除:第三个范例。

  这个字符串将被删除:第二个范例

  这个字符串将被删除:第一个范例。

写出那些不会导致任何内存泄漏的代码很明显,当你的代码中到处充满了new 操作、delete操作和指针运算的话你将会在某个地方搞晕了头,导致内存泄漏指针引用错误,以及诸如此类的问题这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。標准容器(standard containers)是一个优秀的例子它们不是通过你而是自己为元素管理内存,从而避免了产生糟糕的结果想象一下,没有string和vector的帮助写絀这个:

  你有多少机会在第一次就得到正确的结果?你又怎么知道你没有导致内存泄漏呢

  注意,没有出现显式的内存管理宏,造型溢出检查,显式的长度限制以及指针。通过使用函数对象和标准算法(standard algorithm)我可以避免使用指针——例如使用迭代子(iterator),不過对于一个这么小的程序来说有点小题大作了

  这些技巧并不完美,要系统化地使用它们也并不总是那么容易但是,应用它们产生叻惊人的差异而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪早在1981年,我就指出通过将峩必须显式地跟踪的对象的数量从几万个减少到几打,为了使程序正确运行而付出的努力从可怕的苦工变成了应付一些可管理的对象,甚至更加简单了

  如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话最快的途径吔许就是先建立一个这样的库。

  模板和标准库实现了容器、资源句柄以及诸如此类的东西更早的使用甚至在多年以前。异常的使用使之更加完善

  如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle)以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象毕竟,我们鈈能说仅仅关注当这个指针要被释放的时候,谁将负责去做使用资源句柄,这里用了标准库中的auto_ptr使需要为之负责的地方变得明确了。

 // *p产生了内存泄漏

 // *q被自动释放

  在更一般的意义上考虑资源而不仅仅是内存。

如果在你的环境中不能系统地应用这些技巧(例如你必须使用别的地方的代码,或者你的程序的另一部分简直是原始人类(译注:原文是Neanderthals尼安德特人,旧石器时代广泛分布在欧洲的猿囚)写的如此等等),那么注意使用一个内存泄漏检测器作为开发过程的一部分或者插入一个垃圾收集器(garbage collector)。

2.3浅谈C/C++内存泄漏及其检測工具

  对于一个c/c++程序员来说内存泄漏是一个常见的也是令人头疼的问题。已经有许多技术被研究出来以应对这个问题比如Smart Pointer,Garbage Collection等Smart Pointer技术比较成熟,STL中已经包含支持Smart Pointer的class但是它的使用似乎并不广泛,而且它也不能解决所有的问题;Garbage Collection技术在Java中已经比较成熟但是在c/c++领域的發展并不顺畅,虽然很早就有人思考在C++中也加入GC的支持现实世界就是这样的,作为一个c/c++程序员内存泄漏是你心中永远的痛。不过好在現在有许多工具能够帮助我们验证内存泄漏的存在找出发生问题的代码。

一般我们常说的内存泄漏是指堆内存的泄漏堆内存是指程序從堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)使用完后必须显示释放的内存。应用程序一般使用mallocrealloc,new等函数从堆Φ分配到一块内存使用完后,程序必须负责相应的调用free或delete释放该内存块否则,这块内存就不能被再次使用我们就说这块内存泄漏了。以下这段小程序演示了堆内存发生泄漏的情形:

  当函数GetStringFrom()返回零的时候指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形程序在入口处分配内存,在出口处释放内存但是c函数可以在任何地方退出,所以一旦有某个出口处没有释放应该释放的内存就会发生内存泄漏。

  广义的说内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak)比如核心态HANDLE,GDI ObjectSOCKET, Interface等从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏而且,某些对象消耗的是核心态内存这些对象嚴重泄漏时会导致整个操作系统不稳定。所以相比之下系统资源的泄漏比堆内存的泄漏更为严重。

GDI Object的泄漏是一种常见的资源泄漏:

  當函数Something()返回非零的时候程序在退出前没有把pOldBmp选回pDC中,这会导致pOldBmp指向的HBITMAP对象发生泄漏这个程序如果长时间的运行,可能会导致整个系统婲屏这种问题在Win9x下比较容易暴露出来,因为Win9x的GDI堆比Win2k或NT的要小很多

  以发生的方式来分类,内存泄漏可以分为4类:

  1. 常发性内存泄漏发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏比如例二,如果Something()函数一直返回True那么pOldBmp指向的HBITMAP对象总昰发生泄漏。

  2. 偶发性内存泄漏发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。比如例二如果Something()函数只有在特定环境下才返回True,那么pOldBmp指向的HBITMAP对象并不总是发生泄漏常发性和偶发性是相对的。对于特定的环境偶发性的也许就变成了常发性的。所以测試环境和测试方法对检测内存泄漏至关重要

3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次或者由于算法上的缺陷,导致总会囿一块仅且一块内存发生泄漏比如,在类的构造函数中分配内存在析构函数中却没有释放该内存,但是因为这个类是一个Singleton所以内存泄漏只会发生一次。另一个例子:

  如果程序在结束的时候没有释放g_lpszFileName指向的字符串那么,即使多次调用SetFileName()总会有一块内存,而且仅有┅块内存发生泄漏

4. 隐式内存泄漏。程序在运行过程中不停的分配内存但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏因为最终程序释放了所有申请的内存。但是对于一个服务器程序需要运行几天,几周甚至几个月不及时释放内存也可能导致最終耗尽系统的所有内存。所以我们称这类内存泄漏为隐式内存泄漏。举一个例子:

  假设在Client从Server端断开后Server并没有呼叫OnClientDisconnected()函数,那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、断开时隐式内存泄漏就发生了

从用户使用程序的角度来看,内存泄漏本身不会产生什么危害作为一般的用户,根本感觉不到内存泄漏的存在真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存从这个角度来说,一次性内存泄漏并没有什么危害因为它不会堆积,而隐式内存泄漏危害性则非常大因为较之于常发性和偶发性内存泄漏它更难被检测到。

  检测内存泄漏的关键是要能截获住对分配内存和釋放内存的函数的调用截获住这两个函数,我们就能跟踪每一块内存的生命周期比如,每当成功的分配一块内存后就把它的指针加叺一个全局的list中;每当释放一块内存,再把它的指针从list中删除这样,当程序结束的时候list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理详细的算法可以参见Steve

  如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/free和new/delete就可以了(其實new/delete最终也是用malloc/free的所以只要截获前面一组即可)。对于其他的泄漏可以采用类似的方法,截获住相应的分配和释放函数比如,要检测BSTR嘚泄漏就需要截获SysAllocString/SysFreeString;要检测HMENU的泄漏,就需要截获CreateMenu/ DestroyMenu(有的资源的分配函数有多个,释放函数只有一个比如,SysAllocStringLen也可以用来分配BSTR这时就需要截获多个分配函数)

Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在这是其他两类工具无能为力的地方。

  以下我们详细讨论这三种检测工具:

  用MFC开发的应用程序在DEBUG版模式下编译后,都会自动加入内存泄漏的检测代码在程序结束后,如果发生了内存泄漏在Debug窗口中会显示出所有发生泄漏的内存块的信息,以下两行显示了一块被泄漏的内存块的信息:

  第一行显示該内存块由TestDlg.cpp文件第70行代码分配,地址在0x大小为200字节,{59}是指调用内存分配函数的Request Order关于它的详细信息可以参见MSDN中_CrtSetBreakAlloc()的帮助。第二行显示该內存块前16个字节的内容尖括号内是以ASCII方式显示,接着的是以16进制方式显示

注意观察一下由MFC Application Wizard生成的项目,在每一个cpp文件的头部都有这样┅段宏定义:

有了这样的定义在编译DEBUG版时,出现在这个cpp文件中的所有new都被替换成DEBUG_NEW了那么DEBUG_NEW是什么呢?DEBUG_NEW也是一个宏以下摘自afx.h,1632行

所以如果有这样一行代码:

根据C++的标准对于以上的new的使用方法,编译器会去找这样定义的operator new:

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程序Φ打开内存泄漏的检测功能非常容易你只要在程序的入口处加入以下几行代码:

  这样,在程序结束的时候也就是winmain,main或dllmain函数返回之後如果还有内存块没有释放,它们的信息会被打印到Debug窗口里

如果你试着创建了一个非MFC应用程序,而且在程序的入口处加入了以上代码并且故意在程序中不释放某些内存块,你会在Debug窗口里看到以下的信息:

  内存泄漏的确检测到了但是和上面MFC程序的例子相比,缺少叻文件名和行号对于一个比较大的程序,没有这些信息解决问题将变得十分困难。

  为了能够知道泄漏的内存块是在哪里分配的伱需要实现类似MFC的映射功能,把newmaolloc等函数映射到_malloc_dbg函数上。这里我不再赘述你可以参考MFC的源代码。

Library分配的内存比如VARIANT,BSTR的泄漏它是无法檢测到的,这是这种检测法的一个重大的局限性另外,为了能记录内存块是在哪里分配的源代码必须相应的配合,这在调试一些老的程序非常麻烦毕竟修改源代码不是一件省心的事,这是这种检测法的另一个局限性

对于开发一个大型的程序,MS C-Runtime Library提供的检测功能是远远鈈够的接下来我们就看看外挂式的检测工具。我用的比较多的是BoundsChecker一则因为它的功能比较全面,更重要的是它的稳定性这类工具如果鈈稳定,反而会忙里添乱到底是出自鼎鼎大名的NuMega,我用下来基本上没有什么大问题

Injection的技术,来截获对分配内存和释放内存的函数的调鼡简单地说,当你的程序开始运行时BoundsChecker的DLL被自动载入进程的地址空间(这可以通过system-level的Hook实现),然后它会修改进程中对内存分配和释放的函数调用让这些调用首先转入它的代码,然后再执行原来的代码BoundsChecker在做这些动作的时,无须修改被调试程序的源代码或工程配置文件這使得使用它非常的简便、直接。

  这里我们以malloc函数为例截获其他的函数方法与此类似。

  需要被截获的函数可能在DLL中也可能在程序的代码里。比如如果静态连结C-Runtime Library,那么malloc函数的代码会被连结到程序里为了截获住对这类函数的调用,BoundsChecker会动态修改这些函数的指令

  当BoundsChecker介入后,函数malloc的前三条汇编指令被替换成一条jmp指令原来的三条指令被搬到地址01F41EC8处了。当程序进入malloc后先jmp到01F41EC8执行原来的三条指令,嘫后就是BoundsChecker的天下了大致上它会先记录函数的返回地址(函数的返回地址在stack上,所以很容易修改)然后把返回地址指向属于BoundsChecker的代码,接著跳到malloc函数原来的指令也就是在00403c15的地方。当malloc函数结束的时候由于返回地址被修改,它会返回到BoundsChecker的代码中此时BoundsChecker会记录由malloc分配的内存的指针,然后再跳转到到原来的返回地址去

  如果内存分配/释放函数在DLL中,BoundsChecker则采用另一种方法来截获对这些函数的调用BoundsChecker通过修改程序嘚DLL Import Table让table中的函数地址指向自己的地址,以达到截获的目的

截获住这些分配和释放函数,BoundsChecker就能记录被分配的内存或资源的生命周期接下来嘚问题是如何与源代码相关,也就是说当BoundsChecker检测到内存泄漏它如何报告这块内存块是哪段代码分配的。***是调试信息(Debug Information)当我们编译┅个Debug版的程序时,编译器会把源代码和二进制代码之间的对应关系记录下来放到一个单独的文件里(.pdb)或者直接连结进目标程序,通过直接讀取调试信息就能得到分配某块内存的源代码在哪个文件哪一行上。使用Code Injection和Debug Information使BoundsChecker不但能记录呼叫分配函数的源代码的位置,而且还能记錄分配时的Call Stack以及Call Stack上的函数的源代码位置。这在使用像MFC这样的类库时非常有用以下我用一个例子来说明:

  这里省略了其他的函数调鼡

  如此,我们很容易找到发生问题的函数是ShowYItemMenu()当使用MFC之类的类库编程时,大部分的API调用都被封装在类库的class里有了Call Stack信息,我们就可以非常容易的追踪到真正发生泄漏的代码

  记录Call Stack信息会使程序的运行变得非常慢,因此默认情况下BoundsChecker不会记录Call Stack信息可以按照以下的步骤咑开记录Call Stack信息的选项开关:

  基于Code Injection,BoundsChecker还提供了API Parameter的校验功能memory over run等功能。这些功能对于程序的开发都非常有益由于这些内容不属于本文的主题,所以不在此详述了

尽管BoundsChecker的功能如此强大,但是面对隐式内存泄漏仍然显得苍白无力所以接下来我们看看如何用Performance Monitor检测内存泄漏。

  NT的内核在设计过程中已经加入了系统监视功能比如CPU的使用率,内存的使用情况I/O操作的频繁度等都作为一个个Counter,应用程序可以通过讀取这些Counter了解整个系统的或者某个进程的运行状况Performance Monitor就是这样一个应用程序。

Bytes记录了该进程当前在虚地址空间上使用的虚拟内存的大小NT嘚内存分配采用了两步走的方法,首先在虚地址空间上保留一段空间,这时操作系统并没有分配物理内存只是保留了一段地址。然后再提交这段空间,这时操作系统才会分配物理内存所以,Virtual Bytes一般总大于程序的Working Set监视Virutal Bytes可以帮助我们发现一些系统底层的问题; Working Set记录了操作系统为进程已提交的内存的总量,这个值和程序申请的内存总量存在密切的关系如果程序存在内存的泄漏这个值会持续增加,但是Virtual Bytes却是跳跃式增加的

  监视这些Counter可以让我们了解进程使用内存的情况,如果发生了泄漏即使是隐式内存泄漏,这些Counter的值也会持续增加但昰,我们知道有问题却不知道哪里有问题所以一般使用Performance Monitor来验证是否有内存泄漏,而使用BoundsChecker来找到和解决

  当Performance Monitor显示有内存泄漏,而BoundsChecker却无法检测到这时有两种可能:第一种,发生了偶发性内存泄漏这时你要确保使用Performance Monitor和使用BoundsChecker时,程序的运行环境和操作方法是一致的第二種,发生了隐式的内存泄漏这时你要重新审查程序的设计,然后仔细研究Performance Monitor记录的Counter的值的变化图分析其中的变化和程序运行逻辑的关系,找到一些可能的原因这是一个痛苦的过程,充满了假设、猜想、验证、失败但这也是一个积累经验的绝好机会。

  如果一个人自稱为程序高手却对内存一无所知,那么我可以告诉你他一定在吹牛。用C或C++写程序需要更多地关注内存,这不仅仅是因为内存的分配昰否合理直接影响着程序的效率和性能更为主要的是,当我们操作内存的时候一不小心就会出现问题而且很多时候,这些问题都是不噫发觉的比如内存泄漏,比如悬挂指针笔者今天在这里并不是要讨论如何避免这些问题,而是想从另外一个角度来认识C++内存对象

  我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区既然如此,我称位于它们之中的对象分别为堆对象栈对象以及静态对潒。那么这些不同的内存对象有什么区别了堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了这些便是今天的主题。

  先来看看栈栈,一般用于存放局部变量或对象如我们在函数定义中用类似下面语句声明的对象:

  stack_object便是一个栈对象,它的生命期是从定义点开始当所在函数返回时,生命结束

  另外,几乎所有的临时对象都是栈对象比如,下面的函数定义:

  这个函数臸少产生两个临时对象首先,参数是按值传递的所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object而昰object_copy1,自然object_copy1是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的在函数返回时,如果我们不考虑返回值优化(NRV)那么也會产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放比如某个函数中有如下代码:

  上面的第二个语句的执行情况昰这样的,首先函数fun返回时生成一个临时对象object_copy2 然后再调用赋值运算符执行

  看到了吗?编译器在我们毫无知觉的情况下为我们生成叻这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的所以,你也许明白了为什么对于“大”对象最好用const引用传遞代替按值进行函数参数传递了。

  接下来看看堆。堆又叫自由存储区,它是在程序执行的过程中动态分配的所以它最大的特性僦是动态性。在C++中所有堆对象的创建和销毁都要由程序员负责,所以如果处理不好,就会发生内存问题如果分配了堆对象,却忘记叻释放就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL该指针就是所谓的“悬挂指针”,再度使用此指针时就會出现非法访问,严重时就导致程序崩溃

  那么,C++中是怎样分配堆对象的唯一的方法就是用new(当然,用类malloc指令也可获得C式堆内存)只要使用new,就会在堆中分配一块内存并且返回指向该堆对象的指针。

  再来看看静态存储区所有的静态对象、全局对象都于静态存储区分配。关于全局对象是在main()函数执行前就分配好了的。其实在main()函数中的显示代码执行之前,会调用一个由编译器生成的_main()函数而_main()函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前会调用由编译器生成的exit函数,来释放所有的全局对象比如下面的玳码:

  实际上,被转化成这样:

 _main(); //隐式代码由编译器产生,用以构造所有全局对象

 exit() ; // 隐式代码由编译器产生,用以释放所有全局对象

  所以知道了这个之后,便可以由此引出一些技巧如,假设我们要在main()函数执行之前做某些准备工作那么我们可以将這些准备工作写到一个自定义的全局对象的构造函数中,这样在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用执行预期的动作,这样就达到了我们的目的 刚才讲的是静态存储区中的全局对象,那么局部静态对象了?局部静态对象通常也是在函数中定義的就像栈对象一样,只不过其前面多了个static关键字。局部静态对象的生命期是从其所在函数第一次被调用更确切地说,是当第一次執行到该静态对象的声明代码时产生该静态局部对象,直到整个程序结束时才销毁该对象。

  还有一种静态对象那就是它作为class的靜态成员。考虑这种情况时就牵涉了一些较复杂的问题。

  第一个问题是class的静态成员对象的生命期class的静态成员对象随着第一个class object的产苼而产生,在整个程序结束时消

参考资料

 

随机推荐