閱讀912 返回首頁    go 阿裏雲 go 技術社區[雲棲]


C++內存管理學習筆記(6)

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

/*            學習是合作和分享式的!

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

/*  轉載請注明本文出處:

*   https://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

最後更新:2017-04-03 18:52:14

  上一篇:go 第二章 IoC 概念與簡單的使用
  下一篇:go 做個精致的程序員