C++ 記憶體管理(一)

  • 2019 年 10 月 6 日
  • 筆記

C++ 記憶體管理(一)

導語

c++ 記憶體管理學習自侯捷。

下面是本次對C++記憶體管理一些筆記。

1.四種記憶體分配與釋放

在編程時可以通過上圖的幾種方法直接或間接地操作記憶體。下面將介紹四種C++記憶體操作方法:

對於GNU C:四種分配與釋放方式如下:

    // C函數      void *p1 = malloc(512);      *(int *) p1 = 100;      cout << *(int *) p1 << endl;      free(p1);        // C++表達式      int *p2 = new int(10);      cout << *p2 << endl;      delete p2;        // C++函數 實際上等價於上述malloc與free      void *p3 = ::operator new(512);      *(int *) p3 = 103;      cout << *(int *) p3 << endl;      ::operator delete(p3);        //C++標準庫      printf("hello gcc %dn", __GNUC__);  #ifdef __GNUC__  // 以下函數都是non-static,一定要通過object調用,以下分配7個單元,而不是7個位元組      int *p4 = allocator<int>().allocate(7);      *p4 = 9;      cout << *p4 << endl;      allocator<int>().deallocate((int *) p4, 7);        /**       * void *p = alloc::allocate(512); 分配512bytes       * alloc::deallocate(p,512);       */      // __pool_alloc等價於之前的alloc 9個單元      int *p5 = __gnu_cxx::__pool_alloc<int>().allocate(9);      *p5 = 10;      cout << *p5 << endl;      __gnu_cxx::__pool_alloc<int>().deallocate((int *) p5, 9);  #endif

2.new/delete表達式

2.1 new表達式

當使用operator new:

// 下面這個是new expression,而operator new 是函數  Complex* pc = new Complex(1,2);

上述會被編譯器轉為:

Complex *pc;  try {   // operator new 實現自 new_op.cc      void* mem = operator new(sizeof(Complex)); //allocate 分配記憶體      pc = static_cast<Complex*>(mem);    // cast 轉型 以符合對應的類型,這裡對應為Complex*      pc->Complex::Complex(1,2); // construct      // 注意:只有編譯器才可以像上面那樣直接呼叫ctor 欲直接調用ctor可通用placement new: new(p) Complex(1,2);  }  catch(std::bad_alloc) {      // 若allocation失敗就不執行constructor  }

new操作背後編譯器做的事:

  • 第一步通過operator new()操作分配一個目標類型的記憶體大小,這裡是Complex的大小;
  • 第二步通過static_cast將得到的記憶體塊強制轉換為目標類型指針,這裡是Complex*
  • 第三版調用目標類型的構造方法,但是需要注意的是,直接通過pc->Complex::Complex(1, 2)這樣的方法調用構造函數只有編譯器可以做,用戶這樣做將產生錯誤。

注意:operator new()操作的內部是調用了malloc()函數。

operator new()具體實現源程式碼見:

https://github.com/gcc-mirror/gcc/blob/master/libstdc%2B%2B-v3/libsupc%2B%2B/new_op.cc

2.2 delete表達式

對於上述delete調用,

delete pc;  pc->~Complex();  //先析構  operator delete(pc);   //然後釋放記憶體

delete操作步驟:

  • 第一步調用了對象的析構函數
  • 第二步通過operator delete()函數釋放記憶體,本質上也是調用了free函數。

operator delete()具體實現源程式碼見:

https://github.com/gcc-mirror/gcc/blob/master/libstdc%2B%2B-v3/libsupc%2B%2B/del_op.cc

3.array new/array delete

3.1 array

上圖主要展示的是關於array new記憶體分配的大致情況。

當new一個數組對象時(例如 new Complex[3]),編譯器將分配一塊記憶體,這塊記憶體首部是關於對象記憶體分配的一些標記,然後下面會分配三個連續的對象記憶體,在使用delete釋放記憶體時需要使用delete[]。

什麼情況下發生記憶體泄露?

如果不使用delete[],只是使用delete只會將分配的三塊記憶體空間釋放,但不會調用對象的析構函數,如果對象內部還使用了new指向其他空間,如果指向的該空間里的對象的析構函數沒有意義,那麼不會造成問題,如果有意義,那麼由於該部分對象析構函數不會調用,那麼將會導致記憶體泄漏。

圖中new string[3]便是一個例子,雖然str[0]、str[1]、str[2]被析構了,但只是調用了str[0]的析構函數,其他對象的析構函數不被調用,這裡就會出問題。

其中的cookie保存的是delete[]裡面的數據,比如delete幾次。

3.2 演示數組對象創建與析構過程

構造函數調用順序是按照構建對象順序來執行的,但是析構函數執行卻相反。

構造函數:自上而下;析構函數:自下而上。

3.3 malloc基本構成

如果使用new分配十個記憶體的int,記憶體空間如上圖所示,首先記憶體塊會有一個頭和尾,黃色部分為debug資訊,灰色部分才是真正使用到的記憶體,藍色部分的12bytes是為了讓該記憶體塊以16位元組對齊。在這個例子中delete pi和delete[] pi效果是一樣的,因為int沒有析構函數。但是如果釋放的對象的析構函數有意義,array delet就必須採用delete[],否則發生記憶體泄露。

4.placement new

char *buf = new char[sizeof(Complex) * 3];  Complex *pc = new(buf)Complex(1, 2);  delete[]buf;

上述被編譯器編譯為:

Complex *pc;  try      void* mem = operator new(sizeof(Complex),buf); //allocate      pc= static_cast<Complex*>(mem);//cast      pc->Complex::Complex(1,2);//construct  } catch (std::bad_alloc) {      // 若allocation失敗就不執行construct  }

值得注意的是,這裡採用的operator new有兩個參數,我們在下面源碼中:

https://github.com/gcc-mirror/gcc/blob/master/libstdc%2B%2B-v3/libsupc%2B%2B/new

看到:

 _GLIBCXX_NODISCARD inline void* operator new(std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT  { return __p; }

因此得出,沒有做任何事,直接返回buf, 因此placement new 就等同於調用構造函數。也沒有所謂的operator delete ,因為placement new根本沒有分配memory。

5.重載

5.1 C++記憶體分配的途徑

如果是正常情況下,調用new之後走的是第二條路線,如果在類中重載了operator new(),那麼走的是第一條路線,但最後還是要調用到系統的::operator new()函數,這在後續的例子中會體現。

對於GNU C,背後使用的allocate()函數最後也是調用了系統的::operator new()函數。

5.2 重載new 和 delete

上面這張圖演示了如何重載系統的::operator new()函數,該方法最後也是模擬了系統的做法,效果和系統的方法一樣,但一般不推薦重載::operator new()函數,因為它對全局有影響,如果使用不當將造成很大的問題。

如果是在類中重載operator new()方法,那麼該方法有N多種形式,但必須保證函數參數列表第一個參數是size_t類型變數;對於operator delete(),第一個參數必須是void* 類型,第二個size_t是可選項,可以去掉。

對於operator new[]和operator delete[]函數的重載,和前面類似。

6.pre-class allocator1

前面把基本元素的重載元素學完了,例如:new、operator new、array new等等。萬事俱備,現在可以開始一個class進行記憶體管理。

對於malloc來說,大家都有一個誤解,以為它很慢,其實它不慢,後面會講到。無論如何,減少malloc的調用次數,總是很好的,所以設計class者,可以先挖一塊,只使用一次malloc,使用者使用,就只需要調用一次malloc,這樣就是一個小型的記憶體管理。

除了降低malloc次數之外,還需要降低cookie用量。前面提到一次malloc需要一組(兩個)cookie,總共8位元組。

所以,如果一次要1000個大小,這1000個切下來,都是不帶cookie,只有1000個一整包上下帶cookie。所以記憶體池的設計就是一整塊,一個池塘。這一大塊設計不但要提升速度,而且要降低浪費率。所以記憶體管理目標就是,一個是速度,一個是空間。

每次挖一大塊,需要指針把他們穿起來,如下圖右邊鏈表結構,基於這個考量,下面例子中設計了next指針。此時碰到了一個困惑:多設計了一個指針,去除了cookie,卻膨脹率100%(int i 佔4位元組,指針也是4位元組)。

使用者使用new的時候,就會被接管到operator new這個函數來,delete類似。

分配:operator new就是挖一大塊,裡面主要做的就是指針操作與轉型。其中freeStore指向頭,operator new返回的就是freeStore表頭。

回收:當使用者delete一個Scree,就會先調用析構函數,然後調用釋放記憶體函數,operator delete接管了這個任務,接收到一個指針。就把這個鏈表回收到單向鏈表之中。單向鏈表始終都有一個頭,所以回收動作最快放在鏈表開頭。

7.pre-class allocator2

這裡與上述不同之處在於使用union設計,這裡帶來了一個觀念:嵌入式指針,embedding pointer。

分配與釋放同前面6。

嵌入式指針:rep佔16位元組,next占前8位元組。

union {      AirplaneRep rep;  //此針對 used object      Airplane* next;   //此針對 free list  };

借用一個東西的前8位元組當指針用,這樣整體上可以節省空間,這是一個很好的想法,在記憶體管理中都是這麼來用。

最後,6與7中的operator delete並沒有free掉,只是回收到單向鏈表中。這樣子好?

這種當然不好,技術難點非常高,後面談!雖然沒有還給作業系統,但不能說它記憶體泄露,因為這些都在它的"手上"。

8.static allocator3

不要把記憶體分配與回收寫在各個class中,而要把它們集中在一個allocator中!

在前面設計中,每次都需要重載相應的函數,內部處理一些邏輯,重複程式碼量多,我們可以將這些包裝起來,使它容易被重複使用。以下展示一個作法:每個allocator object都是個分配器,在allocator設計了allocate與deallocate兩個函數。,它內部設計如下:

class allocator  {  private:      struct obj {          struct obj* next;  //embedded pointer      };  public:      void* allocate(size_t);      void  deallocate(void*, size_t);      void  check();    private:      obj* freeStore = nullptr;      const int CHUNK = 5; //小一點方便觀察 標準庫裡面是20  };

其他類,例如:Foo和Goo,當需要allocator這種記憶體管理池,只需要寫出下面兩個函數:

static void* operator new(size_t size)  {      return myAlloc.allocate(size);  }  static void  operator delete(void* pdead, size_t size)  {      return myAlloc.deallocate(pdead, size);  }

然後把內部做的動作交給myAlloc。myAlloc是專門為Foo或者Goo之類的服務的,可以設計為靜態 :

static allocator myAlloc;

想像成裡面有一根指針指向一條鏈表,專門為自己服務。

這裡實現同前面的實現。

void* allocator::allocate(size_t size)  {      obj* p;        if (!freeStore) {          //linked list 是空的,所以攫取一大塊 memory          size_t chunk = CHUNK * size;          freeStore = p = (obj*)malloc(chunk);            //cout << "empty. malloc: " << chunk << "  " << p << endl;            //將分配得來的一大塊當做 linked list 般小塊小塊串接起來          for (int i = 0; i < (CHUNK - 1); ++i)   {  //沒寫很漂亮, 不是重點無所謂.              p->next = (obj*)((char*)p + size);              p = p->next;          }          p->next = nullptr;  //last      }      p = freeStore;      freeStore = freeStore->next;        //cout << "p= " << p << "  freeStore= " << freeStore << endl;        return p;  }

同前面實現:

void allocator::deallocate(void* p, size_t)  {      //將 deleted object 收回插入 free list 前端      ((obj*)p)->next = freeStore;      freeStore = (obj*)p;  }

這樣設計好之後,任何一個class要使用它,這種寫法比較乾淨,application classes不再需記憶體分配糾纏不清,所有相關細節交給allocator去操心。

9.macro for static allocator4

之前的幾個版本都是在類的內部重載了operator new()和operator delete()函數,這些版本都將分配記憶體的工作放在這些函數中,但現在的這個版本將這些分配記憶體的操作放在了allocator類中,這就漸漸接近了標準庫的方法。

從上面的程式碼中可以看到,兩個類Foo和Goo中operator new()和operator delete()函數等很多部分程式碼類似,於是可以使用宏來將這些高度相似的程式碼提取出來,簡化類的內部結構,但最後達到的結果是一樣的。

//DECLARE_POOL_ALLOC -- used in class definition  #define DECLARE_POOL_ALLOC()   public:      void* operator new(size_t size) {           return myAlloc.allocate(size);       }       void operator delete(void* p) {           myAlloc.deallocate(p, 0);       }   protected:       static light::allocator myAlloc;    //IMPLEMENT_POOL_ALLOC -- used in class implementation  #define IMPLEMENT_POOL_ALLOC(class_name)   light::allocator class_name::myAlloc;

Foo、Goo:

class Foo {  DECLARE_POOL_ALLOC()  public:      long L;      string str;  public:      Foo(long l): L(l) {      }  };    IMPLEMENT_POOL_ALLOC(Foo)    class Goo {  DECLARE_POOL_ALLOC()  public:      complex<double> c;      string str;  public:      Goo(const complex<double> x): c(x) {      }  };    IMPLEMENT_POOL_ALLOC(Goo)

10.global allocator

前面設計了版本1、2、3、 4。

版本1:最簡單,版本2:加上了embedding pointer,版本3:把記憶體的動作抽取到class中,版本4:設計一個macro。

上面我們自己定義的分配器使用了一條鏈表來管理記憶體的,但標準庫卻用了多條鏈表來管理,這在後續會詳細介紹:

11.new handler

當operator new無法滿足某一記憶體分配需求時,它會拋出std::bad_alloc exception。某些編譯器則返回0,你可以另編譯器那麼做:new(nothrow) Foo;

在拋出異常之前,它會調用一個客戶指定的錯誤處理函數,也就是所謂的new-handler。

客戶通過調用set_new_handler來設置new-handler:

namespace std {    typedef void (*new_handler)();    new_handler set_new_handler(new_handler p) throw();  }

set_new_handler返回之前設置的new_handler。

當operator new無法滿足記憶體申請時,它會不斷調用new-handler函數,直到找到足夠記憶體。因此,一個設計良好的new-handler必須做以下事:

a:讓更多記憶體可被使用,以便使operator new下一次分配記憶體能夠成功。實現方法之一就是程式一開始就分配一大塊記憶體,而後當new-handler第一次被調用時,將它們還給程式使用;

b:安裝另一個new-handler:如果目前的new-handler無法獲得更多記憶體,並且它直到另外哪個new-handler有此能力,則當前的new-handler可以安裝那個new-handler以替換自己,下次當operator new調用new-handler時,就是調用最新的那個。

c:卸載new-handler,一旦沒有設置new-handler,則operator new就會在無法分配記憶體時拋異常;

d:拋出bad_alloc異常;

e:不返回,直接調用abort或exit。

c++ 設計是為了給我們一個機會,因為一旦記憶體不足,整個軟體也不能運作,所以它借這個機會通知你,也就是通過set_new_handler調用我們的函數,由我們來決定怎麼辦。

現在回過頭看operator new源碼:

如果malloc沒有成功,handler函數會循環調用,除非我們將handler設置為空,或者在handler中拋出異常。

operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)  {    void *p;      /* malloc (0) is unpredictable; avoid it.  */    if (__builtin_expect (sz == 0, false))      sz = 1;      while ((p = malloc (sz)) == 0)    {        new_handler handler = std::get_new_handler ();        if (! handler) //利用NULL,跑出錯誤異常        _GLIBCXX_THROW_OR_ABORT(bad_alloc());        handler ();  // 重新設定為原來的函數    }      return p;  }

例子:

#include <new>  #include <iostream>  #include <cassert>    using namespace std;    void noMoreMemory() {      cerr<<"out of memory";      abort();  }      int main() {      set_new_handler(noMoreMemory);      int *p=new int[900000000000000];      assert(p);  }

輸出:

out of memory

12.=default和=delete

(=default與=delete) it is not only for constructors and assignments, but also applies to operator new/new[], operator delete/delete[] and their overloads.

解釋一下,=default和=delete不僅適用於構造函數和賦值,還適用於operator new / new [],operator delete / delete []及其重載。

C++ 的類有四類特殊成員函數,它們分別是:默認構造函數、析構函數、拷貝構造函數以及拷貝賦值運算符。這些類的特殊成員函數負責創建、初始化、銷毀,或者拷貝類的對象。如果程式設計師沒有顯式地為一個類定義某個特殊成員函數,而又需要用到該特殊成員函數時,則編譯器會隱式的為這個類生成一個默認的特殊成員函數。

(1)C++11 標準引入了一個新特性:"=default"函數。

程式設計師只需在函數聲明後加上「=default;」,就可將該函數聲明為 "=default"函數,編譯器將為顯式聲明的 "=default"函數自動生成函數體。

class X {  public:      X() = default;  }
  • "=default"函數特性僅適用於類的特殊成員函數,且該特殊成員函數沒有默認參數。
class X1  {  public:      int f() = default;      // err , 函數 f() 非類 X 的特殊成員函數      X1(int, int) = default;  // err , 構造函數 X1(int, int) 非 X 的特殊成員函數      X1(int = 1) = default;   // err , 默認構造函數 X1(int=1) 含有默認參數  };
  • "=default"函數既可以在類體里(inline)定義,也可以在類體外(out-of-line)定義。
class X2  {  public:      X2() = default; //Inline defaulted 默認構造函數      X2(const X&);      X2& operator = (const X&);      ~X2() = default;  //Inline defaulted 析構函數  };    X2::X2(const X&) = default;  //Out-of-line defaulted 拷貝構造函數  X2& X2::operator= (const X2&) = default;   //Out-of-line defaulted  拷貝賦值操作符

(2)為了能夠讓程式設計師顯式的禁用某個函數,C++11 標準引入了一個新特性:"=delete"函數。程式設計師只需在函數聲明後上「=delete;」,就可將該函數禁用。

class X3  {  public:      X3();      X3(const X3&) = delete;  // 聲明拷貝構造函數為 deleted 函數      X3& operator = (const X3 &) = delete; // 聲明拷貝賦值操作符為 deleted 函數  };
  • "=delete"函數特性還可用于禁用類的某些轉換構造函數,從而避免不期望的類型轉換
class X4  {  public:      X4(double) {}      X4(int) = delete;  };
  • "=delete"函數特性還可以用來禁用某些用戶自定義的類的 new 操作符,從而避免在自由存儲區創建類的對象
class X5  {  public:      void *operator new(size_t) = delete;      void *operator new[](size_t) = delete;  };

回到侯老師課上,見下面兩個ppt:

首先使用了=default對operator new與operator delete,由於=defalult不能使用在這些函數上面,在侯老師程式碼中,將這兩行注釋掉了,保留了=delete的程式碼,所以在右側輸出,使用new沒問題,使用new[]被禁用,自然報錯,第二個是operator new與operator delete被禁用,因此new被禁用,報錯,new[]正常。

參考資料:https://www.cnblogs.com/lsgxeva/p/7787438.html

Tags: