內存泄漏

 

2.1 C++中動態內存分配引發問題的解決方案

假設我們要開發一個String類,它可以方便地處理字符串數據。我們可以在類中聲明一個數組,考慮到有時候字符串極長,我們可以把數組大小設爲200,但一般的情況下又不需要這麼多的空間,這樣是浪費了內存。對了,我們可以使用new操作符,這樣是十分靈活的,但在類中就會出現許多意想不到的問題,本文就是針對這一現象而寫的。現在,我們先來開發一個String類,但它是一個不完善的類。的確,我們要刻意地使它出現各種各樣的問題,這樣纔好對症下藥。好了,我們開始吧!

  1. /* String.h */
  2. #ifndef STRING_H_
  3. #define STRING_H_
  4. class String
  5. {
  6. private:
  7. char * str; //存儲數據
  8. int len; //字符串長度
  9. public:
  10. String(const char * s); //構造函數
  11. String(); // 默認構造函數
  12. ~String(); // 析構函數
  13. friend ostream & operator<<(ostream & os,const String& st);
  14. };

  15. #endif
複製代碼
  1. /*String.cpp*/
  2. #include <iostream>
  3. #include <cstring>
  4. #include "String.h"

  5. using namespace std;

  6. String::String(const char * s)
  7. {
  8. len = strlen(s);
  9. str = new char[len + 1];
  10. strcpy(str, s);
  11. }//拷貝數據

  12. String::String()
  13. {
  14. len =0;
  15. str = new char[len+1];
  16. str[0]='"0';
  17. }

  18. String::~String()
  19. {
  20. cout<<"這個字符串將被刪除:"<<str<<'"n';//爲了方便觀察結果,特留此行代碼。
  21. delete [] str;
  22. }

  23. ostream & operator<<(ostream & os, const String & st)
  24. {
  25. os << st.str;
  26. return os;
  27. }
複製代碼
  1. /*test_right.cpp*/

  2. #include <iostream>
  3. #include <stdlib.h>
  4. #include "String.h"

  5. using namespace std;

  6. int main()
  7. {
  8. String temp("大家網");
  9. cout<<temp<<'"n';
  10. system("PAUSE");
  11. return 0;
  12. }
複製代碼
運行結果:

  大家網

  請按任意鍵繼續. . .

  大家可以看到,以上程序十分正確,而且也是十分有用的。可是,我們不能被表面現象所迷惑!下面,請大家用test_String.cpp文件替換test_right.cpp文件進行編譯,看看結果。有的編譯器可能就是根本不能進行編譯!
  1. /*test_String.cpp:*/
  2. #include <iostream>
  3. #include <stdlib.h>
  4. #include "String.h"

  5. using namespace std;

  6. void show_right(const String&);
  7. void show_String(const String);//注意,參數非引用,而是按值傳遞。

  8. int main()
  9. {
  10. String test1("第一個範例。");
  11. String test2("第二個範例。");
  12. String test3("第三個範例。");
  13. String test4("第四個範例。");

  14. cout<<"下面分別輸入三個範例:"n";
  15. cout<<test1<<endl;
  16. cout<<test2<<endl;
  17. cout<<test3<<endl;

  18. String* String1=new String(test1);

  19. cout<<*String1<<endl;

  20. delete String1;

  21. cout<<test1<<endl; //在Dev-cpp上沒有任何反應。
  22. cout<<"使用正確的函數:"<<endl;

  23. show_right(test2);

  24. cout<<test2<<endl;
  25. cout<<"使用錯誤的函數:"<<endl;

  26. show_String(test2);

  27. cout<<test2<<endl; //這一段代碼出現嚴重的錯誤!

  28. String String2(test3);

  29. cout<<"String2: "<<String2<<endl;

  30. String String3;
  31. String3=test4;

  32. cout<<"String3: "<<String3<<endl;
  33. cout<<"下面,程序結束,析構函數將被調用。"<<endl;

  34. return 0;
  35. }

  36. void show_right(const String& a)
  37. {
  38. cout<<a<<endl;
  39. }

  40. void show_String(const String a)
  41. {
  42. cout<<a<<endl;
  43. }
複製代碼
運行結果:

  下面分別輸入三個範例:

  第一個範例。

  第二個範例。

  第三個範例。

  第一個範例。

  這個字符串將被刪除:第一個範例。

  使用正確的函數:

  

  第二個範例。

  第二個範例。

  使用錯誤的函數:

  第二個範例。

  這個字符串將被刪除:第二個範例。

  這個字符串將被刪除:?=

  ?=

  String2: 第三個範例。

  String3: 第四個範例。

  下面,程序結束,析構函數將被調用。

  這個字符串將被刪除:第四個範例。

  這個字符串將被刪除:第三個範例。

  這個字符串將被刪除:?=

  這個字符串將被刪除:x =

  這個字符串將被刪除:?=

  這個字符串將被刪除:


現在,請大家自己試試運行結果,或許會更加慘不忍睹呢!下面,我爲大家一一分析原因。

首先,大家要知道,C++類有以下這些極爲重要的函數:

一:複製構造函數。

二:賦值函數。

我們先來講複製構造函數。什麼是複製構造函數呢?比如,我們可以寫下這樣的代碼:String test1(test2);這是進行初始化。我們知道,初始化對象要用構造函數。可這兒呢?按理說,應該有聲明爲這樣的構造函數:String(const String &);可是,我們並沒有定義這個構造函數呀?答案是,C++提供了默認的複製構造函數,問題也就出在這兒。

(1):什麼時候會調用複製構造函數呢?(以String類爲例。)

  在我們提供這樣的代碼:String test1(test2)時,它會被調用;當函數的參數列表爲按值傳遞,也就是沒有用引用和指針作爲類型時,如:void show_String(const String),它會被調用。其實,還有一些情況,但在這兒就不列舉了。

(2):它是什麼樣的函數。

它的作用就是把兩個類進行復制。拿String類爲例,C++提供的默認複製構造函數是這樣的:
  1. String(const String& a)
  2. {
  3. str=a.str;
  4. len=a.len;
  5. }
複製代碼
在平時,這樣並不會有任何的問題出現,但我們用了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操作符,這樣的後果是十分嚴重的!

本例中,有這樣的代碼:
  1. String* String1=new String(test1);
  2. cout<<*String1<<endl;
  3. delete String1;
複製代碼
假設test1中str指向的地址爲2000,而String中str指針同樣指向地址2000,我們刪除了2000處的數據,而test1對象呢?已經被破壞了。大家從運行結果上可以看到,我們使用cout<<test1時,一點反應也沒有。而在test1的析構函數被調用時,顯示是這樣: “這個字符串將被刪除:”。

再看看這段代碼:
  1. cout<<"使用錯誤的函數:"<<endl;
  2. show_String(test2);
  3. cout<<test2<<endl;//這一段代碼出現嚴重的錯誤!
複製代碼
show_String函數的參數列表void show_String(const String a)是按值傳遞的,所以,我們相當於執行了這樣的代碼:String a=test2;函數執行完畢,由於生存週期的緣故,對象a被析構函數刪除,我們馬上就可以看到錯誤的顯示結果了:這個字符串將被刪除:?=。當然,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。這樣,就互不干擾了。

大家把這個函數加入程序中,問題就解決了大半,但還沒有完全解決,問題在賦值函數上。我們的程序中有這樣的段代碼:
  1. String String3;
  2. String3=test4;
複製代碼
經過我前面的講解,大家應該也會對這段代碼進行尋根摸底:憑什麼可以這樣做: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。我們先來寫寫看:
  1. String& String::operator=(const String& a)
  2. {
  3. delete [] str;//先刪除自身的數據
  4. len=a.len;
  5. str=new char[len+1];
  6. strcpy(str,a.str);//此三行爲進行拷貝
  7. return *this;//返回自身的引用
  8. }
複製代碼
是不是這樣就行了呢?我們假如寫出了這種代碼:A=A,那麼大家看看,豈不是把A對象的數據給刪除了嗎?這樣可謂引發一系列的錯誤。所以,我們還要檢查是否爲自身賦值。只比較兩對象的數據是不行了,因爲兩個對象的數據很有可能相同。我們應該比較地址。以下是完好的賦值函數:
  1. String& String::operator=(const String& a)
  2. {
  3. if(this==&a)
  4. return *this;
  5. delete [] str;
  6. len=a.len;
  7. str=new char[len+1];
  8. strcpy(str,a.str);
  9. return *this;
  10. }
複製代碼
把這些代碼加入程序,問題就完全解決,下面是運行結果:

  下面分別輸入三個範例:

  第一個範例

  第二個範例

  第三個範例

  第一個範例

  這個字符串將被刪除:第一個範例。

  第一個範例

   使用正確的函數:

  第二個範例。

  第二個範例。

   使用錯誤的函數:

  第二個範例。

  這個字符串將被刪除:第二個範例。

  第二個範例。

  String2: 第三個範例。

  String3: 第四個範例。

  下面,程序結束,析構函數將被調用。

  這個字符串將被刪除:第四個範例。

  這個字符串將被刪除:第三個範例。

  這個字符串將被刪除:第四個範例。

  這個字符串將被刪除:第三個範例。

  這個字符串將被刪除:第二個範例。

  這個字符串將被刪除:第一個範例。


2.2 如何對付內存泄漏?

寫出那些不會導致任何內存泄漏的代碼。很明顯,當你的代碼中到處充滿了new 操作、delete操作和指針運算的話,你將會在某個地方搞暈了頭,導致內存泄漏,指針引用錯誤,以及諸如此類的問題。這和你如何小心地對待內存分配工作其實完全沒有關係:代碼的複雜性最終總是會超過你能夠付出的時間和努力。於是隨後產生了一些成功的技巧,它們依賴於將內存分配(allocations)與重新分配(deallocation)工作隱藏在易於管理的類型之後。標準容器(standard containers)是一個優秀的例子。它們不是通過你而是自己爲元素管理內存,從而避免了產生糟糕的結果。想象一下,沒有string和vector 的幫助,寫出這個:
  1. #include<vector>
  2. #include<string>
  3. #include<iostream>
  4. #include<algorithm>

  5. using namespace std;

  6. int main() // small program messing around with strings
  7. {
  8.  cout << "enter some whitespace-separated words:"n";
  9.  vector<string> v;
  10.  string s;
  11.  while (cin>>s) v.push_back(s);
  12.  sort(v.begin(),v.end());
  13.  string cat;
  14.  typedef vector<string>::const_iterator Iter;
  15.  for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";
  16.  cout << cat << ’"n’;
  17. }
複製代碼
你有多少機會在第一次就得到正確的結果?你又怎麼知道你沒有導致內存泄漏呢?

  注意,沒有出現顯式的內存管理,宏,造型,溢出檢查,顯式的長度限制,以及指針。通過使用函數對象和標準算法(standard algorithm),我可以避免使用指針——例如使用迭代子(iterator),不過對於一個這麼小的程序來說有點小題大作了。

  這些技巧並不完美,要系統化地使用它們也並不總是那麼容易。但是,應用它們產生了驚人的差異,而且通過減少顯式的內存分配與重新分配的次數,你甚至可以使餘下的例子更加容易被跟蹤。早在1981年,我就指出,通過將我必須顯式地跟蹤的對象的數量從幾萬個減少到幾打,爲了使程序正確運行而付出的努力從可怕的苦工,變成了應付一些可管理的對象,甚至更加簡單了。

  如果你的程序還沒有包含將顯式內存管理減少到最小限度的庫,那麼要讓你程序完成和正確運行的話,最快的途徑也許就是先建立一個這樣的庫。

  模板和標準庫實現了容器、資源句柄以及諸如此類的東西,更早的使用甚至在多年以前。異常的使用使之更加完善。

  如果你實在不能將內存分配/重新分配的操作隱藏到你需要的對象中時,你可以使用資源句柄(resource handle),以將內存泄漏的可能性降至最低。這裏有個例子:我需要通過一個函數,在空閒內存中建立一個對象並返回它。這時候可能忘記釋放這個對象。畢竟,我們不能說,僅僅關注當這個指針要被釋放的時候,誰將負責去做。使用資源句柄,這裏用了標準庫中的auto_ptr,使需要爲之負責的地方變得明確了。
  1. #include<memory>
  2. #include<iostream>
  3. using namespace std;
  4. struct S {
  5.  S() { cout << "make an S"n"; }
  6.  ~S() { cout << "destroy an S"n"; }
  7.  S(const S&) { cout << "copy initialize an S"n"; }
  8.  S& operator=(const S&) { cout << "copy assign an S"n"; }
  9. };

  10. S* f()
  11. {
  12.  return new S; // 誰該負責釋放這個S?
  13. };

  14. auto_ptr<S> g()
  15. {
  16.  return auto_ptr<S>(new S); // 顯式傳遞負責釋放這個S
  17. }

  18. int main()
  19. {
  20.  cout << "start main"n";
  21.  S* p = f();
  22.  cout << "after f() before g()"n";
  23.  // S* q = g(); // 將被編譯器捕捉
  24.  auto_ptr<S> q = g();
  25.  cout << "exit main"n";
  26.  // *p產生了內存泄漏
  27.  // *q被自動釋放
  28. }
複製代碼
在更一般的意義上考慮資源,而不僅僅是內存。

如果在你的環境中不能系統地應用這些技巧(例如,你必須使用別的地方的代碼,或者你的程序的另一部分簡直是原始人類(譯註:原文是 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++程序員,內存泄漏是你心中永遠的痛。不過好在現在有許多工具能夠幫助我們驗證內存泄漏的存在,找出發生問題的代碼。

2.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等,從根本上說這些由操作系統分配的對象也消耗內存,如果這些對象發生泄漏最終也會導致內存的泄漏。而且,某些對象消耗的是核心態內存,這些對象嚴重泄漏時會導致整個操作系統不穩定。所以相比之下,系統資源的泄漏比堆內存的泄漏更爲嚴重。

GDI Object的泄漏是一種常見的資源泄漏:
  1. void CMyView::OnPaint( CDC* pDC )
  2. {
  3.  CBitmap bmp;
  4.  CBitmap* pOldBmp;
  5.  bmp.LoadBitmap(IDB_MYBMP);
  6.  pOldBmp = pDC->SelectObject( &bmp );
  7.  …

  8.  if( Something() ){
  9.   return;
  10.  }

  11.  pDC->SelectObject( pOldBmp );
  12.  return;
  13. }
複製代碼
當函數Something()返回非零的時候,程序在退出前沒有把pOldBmp選回pDC中,這會導致pOldBmp指向的HBITMAP對象發生泄漏。這個程序如果長時間的運行,可能會導致整個系統花屏。這種問題在Win9x下比較容易暴露出來,因爲Win9x的GDI堆比Win2k或NT 的要小很多。

2.3.2 內存泄漏的發生方式

  以發生的方式來分類,內存泄漏可以分爲4類:

  1. 常發性內存泄漏。發生內存泄漏的代碼會被多次執行到,每次被執行的時候都會導致一塊內存泄漏。比如例二,如果Something()函數一直返回True,那麼pOldBmp指向的HBITMAP對象總是發生泄漏。

  2. 偶發性內存泄漏。發生內存泄漏的代碼只有在某些特定環境或操作過程下才會發生。比如例二,如果Something()函數只有在特定環境下才返回 True,那麼pOldBmp指向的HBITMAP對象並不總是發生泄漏。常發性和偶發性是相對的。對於特定的環境,偶發性的也許就變成了常發性的。所以測試環境和測試方法對檢測內存泄漏至關重要。

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. }
複製代碼
如果程序在結束的時候沒有釋放g_lpszFileName指向的字符串,那麼,即使多次調用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. };
複製代碼
假設在Client從Server端斷開後,Server並沒有呼叫OnClientDisconnected()函數,那麼代表那次連接的 Connection對象就不會被及時的刪除(在Server程序退出的時候,所有Connection對象會在ConnectionManager的析構函數裏被刪除)。當不斷的有連接建立、斷開時隱式內存泄漏就發生了。

從用戶使用程序的角度來看,內存泄漏本身不會產生什麼危害,作爲一般的用戶,根本感覺不到內存泄漏的存在。真正有危害的是內存泄漏的堆積,這會最終消耗盡系統所有的內存。從這個角度來說,一次性內存泄漏並沒有什麼危害,因爲它不會堆積,而隱式內存泄漏危害性則非常大,因爲較之於常發性和偶發性內存泄漏它更難被檢測到。

2.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,這時就需要截獲多個分配函數)

  在Windows平臺下,檢測內存泄漏的工具常用的一般有三種,MS C-Runtime Library內建的檢測功能;外掛式的檢測工具,諸如,Purify,BoundsChecker等;利用Windows NT自帶的Performance Monitor。這三種工具各有優缺點,MS C-Runtime Library雖然功能上較之外掛式的工具要弱,但是它是免費的;Performance Monitor雖然無法標示出發生問題的代碼,但是它能檢測出隱式的內存泄漏的存在,這是其他兩類工具無能爲力的地方。

  以下我們詳細討論這三種檢測工具:

2.3.3.1 VC下內存泄漏的檢測方法

  用MFC開發的應用程序,在DEBUG版模式下編譯後,都會自動加入內存泄漏的檢測代碼。在程序結束後,如果發生了內存泄漏,在Debug窗口中會顯示出所有發生泄漏的內存塊的信息,以下兩行顯示了一塊被泄漏的內存塊的信息:

E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.

Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70

  第一行顯示該內存塊由TestDlg.cpp文件,第70行代碼分配,地址在0x00881710,大小爲200字節,{59}是指調用內存分配函數的Request Order,關於它的詳細信息可以參見MSDN中_CrtSetBreakAlloc()的幫助。第二行顯示該內存塊前16個字節的內容,尖括號內是以 ASCII方式顯示,接着的是以16進制方式顯示。

  一般大家都誤以爲這些內存泄漏的檢測功能是由MFC提供的,其實不然。MFC只是封裝和利用了MS C-Runtime Library的Debug Function。非MFC程序也可以利用MS C-Runtime Library的Debug Function加入內存泄漏的檢測功能。MS C-Runtime Library在實現malloc/free,strdup等函數時已經內建了內存泄漏的檢測功能。

注意觀察一下由MFC Application Wizard生成的項目,在每一個cpp文件的頭部都有這樣一段宏定義:
  1. #ifdef _DEBUG
  2. #define new DEBUG_NEW
  3. #undef THIS_FILE
  4. static char THIS_FILE[] = __FILE__;
  5. #endif
複製代碼
有了這樣的定義,在編譯DEBUG版時,出現在這個cpp文件中的所有new都被替換成DEBUG_NEW了。那麼DEBUG_NEW是什麼呢?DEBUG_NEW也是一個宏,以下摘自afx.h,1632行
  1. #define DEBUG_NEW new(THIS_FILE, __LINE__)
複製代碼
所以如果有這樣一行代碼:
  1. char* p = new char[200];
複製代碼
經過宏替換就變成了:
  1. char* p = new( THIS_FILE, __LINE__)char[200];
複製代碼
根據C++的標準,對於以上的new的使用方法,編譯器會去找這樣定義的operator new:
  1. void* operator new(size_t, LPCSTR, int)
複製代碼
我們在afxmem.cpp 63行找到了一個這樣的operator new 的實現
  1. void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine)
  2. {
  3.  return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine);
  4. }

  5. void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)
  6. {
  7.  …
  8.  pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);
  9.  if (pResult != NULL)
  10.   return pResult;
  11.  …
  12. }
複製代碼
第二個operator new函數比較長,爲了簡單期間,我只摘錄了部分。很顯然最後的內存分配還是通過_malloc_dbg函數實現的,這個函數屬於MS C-Runtime Library 的Debug 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程序中打開內存泄漏的檢測功能非常容易,你只要在程序的入口處加入以下幾行代碼:
  1. int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG );
  2. tmpFlag |= _CRTDBG_LEAK_CHECK_DF;
  3. _CrtSetDbgFlag( tmpFlag );
複製代碼
這樣,在程序結束的時候,也就是winmain,main或dllmain函數返回之後,如果還有內存塊沒有釋放,它們的信息會被打印到Debug窗口裏。

如果你試着創建了一個非MFC應用程序,而且在程序的入口處加入了以上代碼,並且故意在程序中不釋放某些內存塊,你會在Debug窗口裏看到以下的信息:

{47} normal block at 0x00C91C90, 200 bytes long.

Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F


  內存泄漏的確檢測到了,但是和上面MFC程序的例子相比,缺少了文件名和行號。對於一個比較大的程序,沒有這些信息,解決問題將變得十分困難。

  爲了能夠知道泄漏的內存塊是在哪裏分配的,你需要實現類似MFC的映射功能,把new,maolloc等函數映射到_malloc_dbg函數上。這裏我不再贅述,你可以參考MFC的源代碼。

  由於Debug Function實現在MS C-RuntimeLibrary中,所以它只能檢測到堆內存的泄漏,而且只限於malloc,realloc或strdup等分配的內存,而那些系統資源,比如HANDLE,GDI Object,或是不通過C-Runtime Library分配的內存,比如VARIANT,BSTR的泄漏,它是無法檢測到的,這是這種檢測法的一個重大的侷限性。另外,爲了能記錄內存塊是在哪裏分配的,源代碼必須相應的配合,這在調試一些老的程序非常麻煩,畢竟修改源代碼不是一件省心的事,這是這種檢測法的另一個侷限性。

對於開發一個大型的程序,MS C-Runtime Library提供的檢測功能是遠遠不夠的。接下來我們就看看外掛式的檢測工具。我用的比較多的是BoundsChecker,一則因爲它的功能比較全面,更重要的是它的穩定性。這類工具如果不穩定,反而會忙裏添亂。到底是出自鼎鼎大名的NuMega,我用下來基本上沒有什麼大問題。

2.3.3.2 使用BoundsChecker檢測內存泄漏

  BoundsChecker採用一種被稱爲 Code Injection的技術,來截獲對分配內存和釋放內存的函數的調用。簡單地說,當你的程序開始運行時,BoundsChecker的DLL被自動載入進程的地址空間(這可以通過system-level的Hook實現),然後它會修改進程中對內存分配和釋放的函數調用,讓這些調用首先轉入它的代碼,然後再執行原來的代碼。BoundsChecker在做這些動作的時,無須修改被調試程序的源代碼或工程配置文件,這使得使用它非常的簡便、直接。

  這裏我們以malloc函數爲例,截獲其他的函數方法與此類似。

  需要被截獲的函數可能在DLL中,也可能在程序的代碼裏。比如,如果靜態連結C-Runtime Library,那麼malloc函數的代碼會被連結到程序裏。爲了截獲住對這類函數的調用,BoundsChecker會動態修改這些函數的指令。

以下兩段彙編代碼,一段沒有BoundsChecker介入,另一段則有BoundsChecker的介入:

126: _CRTIMP void * __cdecl malloc (

127: size_t nSize

128: )

129: {

00403C10 push ebp

00403C11 mov ebp,esp

130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);

00403C13 push 0

00403C15 push 0

00403C17 push 1

00403C19 mov eax,[__newmode (0042376c)]

00403C1E push eax

00403C1F mov ecx,dword ptr [nSize]

00403C22 push ecx

00403C23 call _nh_malloc_dbg (00403c80)

00403C28 add esp,14h

131: }


以下這一段代碼有BoundsChecker介入:

126: _CRTIMP void * __cdecl malloc (

127: size_t nSize

128: )

129: {

00403C10 jmp 01F41EC8

00403C15 push 0

00403C17 push 1

00403C19 mov eax,[__newmode (0042376c)]

00403C1E push eax

00403C1F mov ecx,dword ptr [nSize]

00403C22 push ecx

00403C23 call _nh_malloc_dbg (00403c80)

00403C28 add esp,14h

131: }


  當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這樣的類庫時非常有用,以下我用一個例子來說明:

void ShowXItemMenu()
{
 …
 CMenu menu;
 menu.CreatePopupMenu();
 //add menu items.
 menu.TrackPropupMenu();
 …
}

void ShowYItemMenu( )
{
 …
 CMenu menu;
 menu.CreatePopupMenu();
 //add menu items.
 menu.TrackPropupMenu();
 menu.Detach();//this will cause HMENU leak
 …
}

BOOL CMenu::CreatePopupMenu()
{
 …
 hMenu = CreatePopupMenu();
 …
}

當調用ShowYItemMenu()時,我們故意造成HMENU的泄漏。但是,對於BoundsChecker來說被泄漏的HMENU是在 class CMenu::CreatePopupMenu()中分配的。假設的你的程序有許多地方使用了CMenu的CreatePopupMenu()函數,如 CMenu::CreatePopupMenu()造成的,你依然無法確認問題的根結到底在哪裏,在ShowXItemMenu()中還是在 ShowYItemMenu()中,或者還有其它的地方也使用了CreatePopupMenu()?有了Call Stack的信息,問題就容易了。BoundsChecker會如下報告泄漏的HMENU的信息:

Function

File

Line

CMenu::CreatePopupMenu

E:"8168"vc98"mfc"mfc"include"afxwin1.inl

1009

ShowYItemMenu

E:"testmemleak"mytest.cpp

100


  這裏省略了其他的函數調用

  如此,我們很容易找到發生問題的函數是ShowYItemMenu()。當使用MFC之類的類庫編程時,大部分的API調用都被封裝在類庫的class裏,有了Call Stack信息,我們就可以非常容易的追蹤到真正發生泄漏的代碼。

  記錄Call Stack信息會使程序的運行變得非常慢,因此默認情況下BoundsChecker不會記錄Call Stack信息。可以按照以下的步驟打開記錄Call Stack信息的選項開關:

  1. 打開菜單:BoundsChecker|Setting…

  2. 在Error Detection頁中,在Error Detection Scheme的List中選擇Custom

  3. 在Category的Combox中選擇 Pointer and leak error check

  4. 鉤上Report Call Stack複選框

  5. 點擊Ok

  基於Code Injection,BoundsChecker還提供了API Parameter的校驗功能,memory over run等功能。這些功能對於程序的開發都非常有益。由於這些內容不屬於本文的主題,所以不在此詳述了。

儘管BoundsChecker的功能如此強大,但是面對隱式內存泄漏仍然顯得蒼白無力。所以接下來我們看看如何用Performance Monitor檢測內存泄漏。

2.3.3.3 使用Performance Monitor檢測內存泄漏
  NT的內核在設計過程中已經加入了系統監視功能,比如CPU 的使用率,內存的使用情況,I/O操作的頻繁度等都作爲一個個Counter,應用程序可以通過讀取這些Counter瞭解整個系統的或者某個進程的運行狀況。Performance Monitor就是這樣一個應用程序。

  爲了檢測內存泄漏,我們一般可以監視Process對象的Handle Count,Virutal Bytes 和Working Set三個Counter。Handle Count記錄了進程當前打開的HANDLE的個數,監視這個Counter有助於我們發現程序是否有Handle泄漏;Virtual 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的值的變化圖,分析其中的變化和程序運行邏輯的關係,找到一些可能的原因。這是一個痛苦的過程,充滿了假設、猜想、驗證、失敗,但這也是一個積累經驗的絕好機會。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章