首页 > 代码库 > C++内存管理学习笔记(6)

C++内存管理学习笔记(6)

/****************************************************************/

/*            学习是合作和分享式的!

/* Author:Atlas                    Email:wdzxl198@163.com   

/*  转载请注明本文出处:

*   http://blog.csdn.net/wdzxl198/article/details/9120635

/****************************************************************/

上期内容回顾:

C++内存管理学习笔记(5)

     2.5 资源传递   2.6 共享所有权  2.7 share_ptr


3 内存泄漏-Memory leak

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

     假设我们要开发一个String类,它可以方便地处理字符串数据。我们可以在类中声明一个数组,考虑到有时候字符串极长,我们可以把数组大小设为200,但一般的情况下又不需要这么多的空间,这样是浪费了内存。很容易想到可以使用new操作符,但在类中就会出现许多意想不到的问题,本小节就以这么意外的小问题的解决来看内存泄漏这个问题。。现在,我们先来开发一个String类,但它是一个不完善的类。存在很多的问题!如果你能一下子把潜在的全找出来,ok,你是一个技术基础扎实的读者,直接看下一小节,或者也可以陪着笔者和那些找不到问题的读者一起再学习一下吧。

   下面上例子,

   1: /* String.h */
   2: #ifndef STRING_H_
   3: #define STRING_H_
   4:  
   5: class String
   6: {
   7: private:
   8:     char * str; //存储数据
   9:     int len; //字符串长度
  10: public:
  11:     String(const char * s); //构造函数
  12:     String(); // 默认构造函数
  13:     ~String(); // 析构函数
  14:     friend ostream & operator<<(ostream & os,const String& st);
  15: };
  16: #endif
  17:  
  18: /*String.cpp*/
  19: #include <iostream>
  20: #include <cstring>
  21: #include "String.h"
  22: using namespace std;
  23: String::String(const char * s)
  24: {
  25:     len = strlen(s);
  26:     str = new char[len + 1];
  27:     strcpy(str, s);
  28: }//拷贝数据
  29: String::String()
  30: {
  31:     len =0;
  32:     str = new char[len+1];
  33:     str[0]=‘"0‘;
  34: }
  35: String::~String()
  36: {
  37:     cout<<"这个字符串将被删除:"<<str<<‘"n‘;//为了方便观察结果,特留此行代码。
  38:     delete [] str;
  39: }
  40: ostream & operator<<(ostream & os, const String & st)
  41: {
  42:     os<<st.str;
  43:     return os;
  44: }
  45:  
  46: /*test_right.cpp*/
  47: #include <iostrea>
  48: #include <stdlib.h>
  49: #include "String.h"
  50: using namespace std;
  51: int main()
  52: {
  53:     String temp("String类的不完整实现,用于后续内容讲解");
  54:     cout<<temp<<‘"n‘;
  55:     system("PAUSE");
  56:     return 0;
  57: }

    运行结果(运行环境Dev-cpp)如下图所示,表面看上去程序运行很正确,达到了自己程序运行的目的,但是,不要被表面结果所迷惑!

      这时如果你满足于上面程序的结果,你也就失去了c++中比较意思的一部分知识,请看下面的这个main程序,注意和上面的main加以区别,

   1: #include <iostream>
   2: #include <stdlib.h>
   3: #include "String.h"
   4: using namespace std;
   5:  
   6: void show_right(const String& a)
   7: {
   8:     cout<<a<<endl;
   9: }
  10: void show_String(const String a) //注意,参数非引用,而是按值传递。
  11: {
  12:     cout<<a<<endl;
  13: }
  14:  
  15: int main()
  16: {
  17:     String test1("第一个范例。");
  18:     String test2("第二个范例。");
  19:     String test3("第三个范例。");
  20:     String test4("第四个范例。");
  21:     cout<<"下面分别输入三个范例"<<endl;
  22:     cout<<test1<<endl;
  23:     cout<<test2<<endl;
  24:     cout<<test3<<endl;
  25:     
  26:     String* String1=new String(test1);
  27:     cout<<*String1<<endl;
  28:     delete String1;
  29:     cout<<test1<<endl; 
  30:     
  31:     cout<<"使用正确的函数:"<<endl;
  32:     show_right(test2);
  33:     cout<<test2<<endl;
  34:     cout<<"使用错误的函数:"<<endl;
  35:     show_String(test2);
  36:     cout<<test2<<endl; //这一段代码出现严重的错误!
  37:     
  38:     String String2(test3);
  39:     cout<<"String2: "<<String2<<endl;
  40:     
  41:     String String3;
  42:     String3=test4;
  43:     cout<<"String3: "<<String3<<endl;
  44:     cout<<"下面,程序结束,析构函数将被调用。"<<endl;
  45:  
  46:     return 0;
  47: }

 

      运行结果(环境Dev-cpp):程序运行最后崩溃!!!到这里就看出来上面的String类存在问题了吧。(读者可以自己运行一下看看,可以换vc或者vs等等试试)

     为什么会崩溃呢,让我们看一下它的输出结果,其中有乱码、有本来被删除的但是却正常打印的“第二个范例”,以及最后析构删除的崩溃等等问题。

通过查看,原来主要是复制构造函数和赋值操作符的问题,读者可能会有疑问,这两个函数是什么,怎会影响程序呢。接下来笔者慢慢结识。

     首先,什么是复制构造函数和赋值操作符?------>限于篇幅,详细分析请看《c++中复制控制详解(copy control)》

Tip:复制构造函数和赋值操作符

(1)复制构造函数(copy constructor)

         复制构造函数(有时也称为:拷贝构造函数)是一种特殊的构造函数,具有单个形参,该形参(常用const修饰)是对该类类型的引用.当定义一个新对象并用一个同类型的对象对它进行初始化时,将显示使用复制构造函数.当将该类型的对象传递给函数或者从函数返回该类型的对象时,将隐式使用复制构造函数。

       复制构造函数用在:

  • 对象创建时使用其他相同类型的对象初始化;
       1: Person q("Mickey"); // constructor is used to build q.
       2: Person r(p);        // copy constructor is used to build r.
       3: Person p = q;       // copy constructor is used to initialize in declaration.
       4: p = q;              // Assignment operator, no constructor or copy constructor.
  • 复制对象作为函数的参数进行值传递时;

       1: f(p);               // copy constructor initializes formal value parameter.
  • 复制对象以值传递的方式从函数返回。

          一般情况下,编译器会给我们自动产生一个拷贝构造函数,这就是“默认拷贝构造函数”,这个构造函数很简单,仅仅使用“老对象”的数据成员的值对“新对象”的数据成员一一进行赋值。使用默认的复制构造函数是叫做浅拷贝。

         相对应与浅拷贝,则有必要有深拷贝(deep copy),对于对象中动态成员,就不能仅仅简单地赋值了,而应该有重新动态分配空间。

         如果对象中没有指针去动态申请内存,使用默认的复制构造函数就可以了,因为,默认的复制构造、默认的赋值操作和默认的析构函数能够完成相应的工作,不需要去重写自己的实现。否则,必须重载复制构造函数,相应的也需要重写赋值操作以及析构函数。

    2.赋值操作符(The Assignment Operator)

          一般而言,如果类需要复制构造函数,则也会需要重载赋值操作符。首先,了解一下重载操作符。重载操作符是一些函数,其名字为operator后跟所定义的操作符符号,因此,可以通过定义名为operator=的函数,进行重载赋值定义。操作符函数有一个返回值和一个形参表。形参表必须具有和该操作数数目相同的形参。赋值是二元运算,所以该操作符有两个形参:第一个形参对应的左操作数,第二个形参对应右操作数。

        赋值和赋值一般在一起使用,可将这两个看作一个单元,如果需要其中一个,几乎也肯定需要另一个。

         ok,现在分析上面的程序问题。

   a)程序中有这样的一段代码,

 

   1: String* String1=new String(test1);
   2: cout<<*String1<<endl;
   3: delete String1;

 

      假设test1中str指向的地址为2000,而String中str指针同样指向地址2000,我们删除了2000处的数据,而test1对象呢?已经被破坏了。大家从运行结果上可以看到,我们使用cout<<test1时,从结果图上看,显示的是乱码类似于“*”,而在test1的析构函数被调用时,显示是这样:“这个字符串将被删除:”,程序崩溃,这里从结果图上看,可能没有执行到这一步,程序已经奔溃了。

   b)另外一段代码,

 

   1: cout<<"使用错误的函数:"<<endl;
   2: show_String(test2);
   3: cout<<test2<<endl;//这一段代码出现严重的错误!

 

       show_String函数的参数列表void show_String(const String a)是按值传递的,所以,我们相当于执行了这样的代码:函数申请一个临时对象a,然后将a=test2;函数执行完毕后,由于生存周期的缘故,对象a被析构函数删除,这里要注意!从输出结果来看,显示的是“第二个范例。”,看上去是正确的,但是分析程序发现这里有漏洞,程序执行的是默认的复制构造函数,类中使用str指针申请内存的,默认的函数不能动态申请空间,只是将临时对象的str指针指向了test2,即a.str = test2.str,所以这块不能够正确执我们的复制目的。因为此时test2也被破坏了!

     这是就需要我们自己重载构造函数了,即定义自己的复制构造函数,

   1: String::String(const String& a)
   2: {
   3:     len=a.len;
   4:     str=new char(len+1);
   5:     strcpy(str,a.str);
   6: }

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

     c)还有一段代码

   1: String String3;
   2: String3=test4;

      问题和上面的相似,大家应该猜得到,它同样是执行了浅拷贝,出了同样的毛病。比如,执行了这段代码后,析构函数开始执行。由于这些变量是后进先出的,所以最后的String3变量先被删除:这个字符串将被删除:String:第四个范例。执行正常。最后,删除到test4的时候,问题来了:程序崩溃。原因我不用赘述了。

      那怎么修改这个赋值操作呢,当然是自己定义重载啦,

版本一,

   1: String& String::operator =(const String &a)
   2: {
   3:     if(this == &a)
   4:         return *this;
   5:     delete []str;
   6:     str = NULL;
   7:     len=a.len;
   8:     str = new char[len+1];
   9:     strcpy(str,a.str);
  10:     
  11:     return *this;
  12: } //重载operator= 

版本二,

   1: String& String::operator =(const String& a)
   2: {
   3:     if(this != &a)
   4:     {
   5:         String strTemp(a);
   6:         
   7:         len = a.len;
   8:         char* pTemp = strTemp.str;
   9:         strTemp.str = str;
  10:         str = pTemp;
  11:     }
  12:     return *this;    
  13: }

 

 

 

 

 

 

 

 

    这个重载函数实现时要考虑填补很多的陷阱!限于篇幅,大概说下,返回值须是String类型的引用,形参为const 修饰的Sting引用类型,程序中要首先判断是否为a=a的情形,最后要返回对*this的引用,至于为什么需要利用一个临时strTemp,是考虑到内存不足是会出现new异常的,将改变Srting对象的有效状态,违背C++异常安全性原则,当然这里可以先new,然后在删除原来对象的指针方式来替换使用临时对象赋值。

    我们根据上面的要求重新修改程序后,执行程序,结果显示为,从图的右侧可以到,这次执行正确了。

3.2 如何对付内存泄漏

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

    如果不考虑vector和Sting使用来写下面的程序,你大脑很会费劲的…..

   1: #include <vector>
   2: #include <string>
   3: #include <iostream>
   4: #include <algorithm>
   5:  
   6: using namespace std;
   7:  
   8: int main() // small program messing around with strings
   9: {
  10:     cout<<"enter some whitespace-seperated words:"<<endl;
  11:     vector<string> v;
  12:     string s;
  13:     while (cin>>s)
  14:         v.push_back(s);
  15:     sort(v.begin(),v.end());
  16:     string cat;
  17:     typedef vector<string>::const_iterator Iter;
  18:     for (Iter p = v.begin(); p!=v.end(); ++p) 
  19:     { 
  20:         cat += *p+"+";
  21:         std::cout<<cat<<‘n‘;
  22:     }
  23:     return 0;
  24: }

 

    运行结果:这个程序利用标准库的string和vector来申请和管理内存,方便简单,若是设想使用new和delete来重新写程序,会头疼的。

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

  这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。  如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。模板和标准库实现了容器、资源句柄等等

  如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。

      这里有个例子:需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的auto_ptr,使需要为之负责的地方变得明确了。

   1: #include<memory>
   2: #include<iostream>
   3: using namespace std;
   4:  
   5: struct S {
   6:     S() { cout << "make an S"<<endl; }
   7:     ~S() { cout << "destroy an S"<<endl; }
   8:     S(const S&) { cout << "copy initialize an S"<<endl; }
   9:     S& operator=(const S&) { cout << "copy assign an S"<<endl; }
  10: };
  11:  
  12: S* f()
  13: {
  14:     return new S; // 谁该负责释放这个S?
  15: };
  16:  
  17: auto_ptr<S> g()
  18: {
  19:     return auto_ptr<S>(new S); // 显式传递负责释放这个S
  20: }
  21:  
  22: void test()
  23: {
  24:     cout << "start main"<<endl;
  25:     S* p = f();
  26:     cout << "after f() before g()"<<endl;
  27:     // S* q = g(); // 将被编译器捕捉
  28:     auto_ptr<S> q = g();
  29:     cout << "exit main"<<endl;
  30:     // *p产生了内存泄漏
  31:     // *q被自动释放    
  32: }
  33: int main()
  34: {
  35:     test();
  36:     system("PAUSE");
  37:     return 0;
  38: }

      运行这个程序(dev-cpp),可以看到p产生内存泄漏,而通过auto_ptr智能指针,则内存管理自动化了 ---->为什么?详见《C++内存管理学习笔记(4)》

     综合以上的内容,我们需要考虑更一般的意义上考虑资源,而不仅仅是内存。如果在你的环境中不能系统地应用这些技巧,那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。

3.3 浅谈C/C++内存泄漏

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

3.3.1 内存泄漏定义

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

      以下这段小程序演示了堆内存发生泄漏的情形:

   1: void MyFunction(int nSize)
   2: {
   3:      char* p= new char[nSize];
   4:      if( !GetStringFrom( p, nSize ) ){
   5:      MessageBox(“Error”);
   6:      return;
   7:      }
   8:      …//using the string pointed by p;
   9:      delete p;
  10: }

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

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

3.3.2 内存泄漏的发生方式

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

1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。

2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

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

 

   1: char* g_lpszFileName = NULL;
   2: void SetFileName( const char* lpcszFileName )
   3: {
   4:     if( g_lpszFileName ){
   5:         free( g_lpszFileName );
   6:     }
   7:     g_lpszFileName = strdup( lpcszFileName );
   8: }
   9: /*如果程序在结束的时候没有释放g_lpszFileName指向的字符串,
  10: 那么,即使多次调用SetFileName(),总会有一块内存,而且仅有一块内存发生泄漏。*/

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

   1: class Connection
   2: {
   3: public:
   4:     Connection( SOCKET s);
   5:     ~Connection();
   6:     …
   7: private:
   8:     SOCKET _socket;
   9:     …
  10: };
  11: class ConnectionManager
  12: {
  13: public:
  14:     ConnectionManager(){}
  15:     ~ConnectionManager(){
  16:         list::iterator it;
  17:         for( it = _connlist.begin(); it != _connlist.end(); ++it ){
  18:         delete (*it);
  19:         }
  20:     _connlist.clear();
  21:   }
  22:     void OnClientConnected( SOCKET s ){
  23:         Connection* p = new Connection(s);
  24:         _connlist.push_back(p);
  25:     }
  26:     void OnClientDisconnected( Connection* pconn ){
  27:         _connlist.remove( pconn );
  28:         delete pconn;
  29:     }
  30: private:
  31:     list _connlist;
  32: };
  33: /*假设在Client从Server端断开后,Server并没有呼叫OnClientDisconnected()函数,
  34: 那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候,
  35: 所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、
  36: 断开时隐式内存泄漏就发生了。*/

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

3.3.3 检测内存泄漏

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

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

 


参考资料详见《c++内存管理学习纲要》

 

Edit by Atlas

Time:2013/6/18 14:51

C++内存管理学习笔记(6)