C++:Memory Management

  • 2019 年 10 月 3 日
  • 筆記

淺談C++記憶體管理

new和delete

在C++中,我們習慣用new申請堆中的記憶體,配套地,使用delete釋放記憶體。

class LiF;  LiF* lif = new LiF(); // 分配記憶體給一個LiF對象  delete lif; // 釋放資源  lif = nullptr; // 指針置空,保證安全

與C的malloc相比,我們發現,new操作在申請記憶體的同時還完成了對象的構造,這也是new運算符做的一層封裝。

記憶體是怎樣申請的

new這個例子可以看出,C++的記憶體管理大有門道,而記憶體管理也是C++中最為重要的一部分。在硬體層之上的第一層封裝就是作業系統,高級語言編寫的程式也將作為進程在這裡接受進程調度,其中就涉及到記憶體的分配。從這個意義上理解,可以說,記憶體是向作業系統申請的(不嚴格正確)。

在C++應用層(Application),我們最常用的是C++ primitive(原語)操作,newnew[]new()::operator new()等,申請記憶體。在primitive之上,C++的Library還為我們提供了各種各樣的allocator(容器,或者說分配器),如std::allocator,可以通過這些容器分配記憶體,但其實容器還是通過newdelete運算符去實現記憶體的申請與釋放。在new之下,則是Microsoft的CRT庫提供的mallocfreenew操作是對malloc的封裝。再往下就是作業系統的API。這些記憶體管理的API的關係大致如下:

memory-management-1

再談new和delete

new expression

通常,我們會使用new在堆中申請一塊記憶體,並把這塊記憶體的地址保存到一個指針,這個操作就是new操作,但嚴格來說,它其實應該稱為new expression(new表達式)

LiF* lif = new LiF(); // new expression

但其實,new是一個複合操作,通常會被編譯器轉換為類似如下的形式:

LiF* lif;  try {      void* mem = operator new(sizeof(LiF)); // apply for memory      lif = static_cast<LiF*>(mem); // static type conversion      lif->LiF::LiF(); // constructor  } catch(std::bad_alloc) {      // exception handling  }

new做了什麼

  1. 調用operator new申請足夠存放對象大小的記憶體;
  2. 把申請到的記憶體交給我們的指針;
  3. 最後調用構造函數構造對象。

operator new

try/catch塊的第一句,new expression調用了operator new,它的原型是:

// 位於<vcruntime_new.h>  _Ret_notnull_ _Post_writable_byte_size_(_Size)  _NODISCARD _VCRT_ALLOCATOR void* __CRTDECL operator new(      size_t _Size  );    _Ret_maybenull_ _Success_(return != NULL) _Post_writable_byte_size_(_Size)  _NODISCARD _VCRT_ALLOCATOR void* __CRTDECL operator new(      size_t _Size,      std::nothrow_t const&  ) noexcept;

而在operator new()會去調用::operator new(),最後,::operator new()的內部實際上是調用了mallocoperator new()的工作就是通過malloc不斷申請記憶體,直到申請成功。在operator new的第二個重載中可以看到,這是一個noexcept的函數,因為我們可以認為,記憶體的申請總是可以成功的,因為在operator new()內部,每當申請失敗時,他都會調用一次new handler,可以把new handler理解為一個記憶體管理策略,它會釋放掉一些不需要的記憶體,以便當前的malloc可以申請到記憶體。可以說,operator new的工作就是申請記憶體。

placement new

在new拆解得到的第三步,它調用了對象的構造函數,而且在表達上比較特殊:lif->LiF::LiF();。編譯器通過對象指針直接調用了對象的構造函數,但如果我們在程式中這樣寫,編譯一般是無法通過的,這不是源程式碼的語法。在上面的語句中,我們已經完成了記憶體的分配工作,顯然這一步是在進行對象的構造,這個操作也被稱為placement new,即定點構造,在指定的記憶體塊中構造對象。

new expression是operator new和placement new的複合。

delete expression

在我們不再需要某一個對象時,通常使用delete析構該對象。delete操作嚴格來說,與new對應,它應該稱為delete expression(delete表達式)

delete lif; // delete expression  lif = nullptr;

同樣,delete也是一個複合操作,通常會被編譯器轉換為類似如下的形式:

lif->~LiF(); // destructor  operator delete(lif); // free the memory

delete做了什麼

  1. 調用對象的析構函數;
  2. 釋放記憶體。

operator delete

在delete操作的第二步,實際上是執行了operator delete(),它的原型是:

void __CRTDECL operator delete(      void*  _Block,      size_t _Size  ) noexcept;

operator delete其實是調用了全局的::operator delete()::operator delete()又調用了free進行記憶體的釋放。

也就是說,newdelete是對mallocfree的一層封裝,這也對應了上面圖中的內容。

array new和array delete

array newnew[],顧名思義,它用於構造一個對象數組。

class LiF {  public:      LiF(int _lif = 0): lif(_lif) {}      int lif;  };    LiF* lifs = new LiF[3]; // right  LiF* lifs = new LiF[3](); // right  LiF* lifs = new LiF[3](1); // wrong, no param accepted  LiF* lifs = new LiF[3]{1}; // right, but only lifs[0].lif equals 1

array new的工作是申請一塊足以容納指定個數的對象的記憶體(在本例中是3個LiF對象)。在前兩種寫法中,array new調用的是默認構造函數,這種情況下只能默認構造對象,但如果又想要給對象賦予非默認的初值,那麼就需要使用到placement new了。

LiF* lifs = new LiF[3];  LiF* p = lifs;  for (int i = 0; i < 3; ++i) {      new(p++)LiF(i+1); // placement new      cout << lifs[i].lif << endl;  }

直觀地,placement new並不會分配記憶體,它只是在已分配的記憶體上構造對象。對應地,使用array new構造的對象需要使用array delete釋放記憶體。

delete[] lifs;

相較於array new,array delete不需要提供數組長度參數。這是因為,在使用array new構造對象的時候,還有一塊額外的空間用於存放cookie,也就是這塊記憶體的一些資訊,其中就包括這個記憶體塊的大小和對象的數量等等。

class LiF {  public:      //...      ~LiF() { cout << "des" << endl; }  };    delete[] lifs; // array delete

此時我們顯式地定義析構函數,並且在析構函數被調用時列印資訊。在運行到delete[]的時候,程式就會根據cookie中的資訊,準確地釋放對應的記憶體塊,本例中,「des」會被列印三次,即3個對象的析構函數都被調用了。此時如果錯誤地調用delete而非array delete,那麼就可能會發生記憶體泄漏。

delete lifs; // delete

這時只會調用一次析構函數,但本例中並不會發生泄漏,這個簡單的類中並沒有包含其他對象。再看下面這種情況:

class LiF2 {  public:      LiF2() : lif(new LiF()) {}      LiF2(const LiF& _lif) : lif(new LiF(_lif.lif)) {}      ~LiF2() { delete lif; lif = nullptr; }  private:      LiF* lif;  };    LiF2* lif2 = new LiF2[3];  delete lif2; // call "delete" by mistake

這時,由於錯誤地使用了delete,析構函數只會被調用一次,也就是說,還有另外兩個對象,雖然對象本身被銷毀了,但對象中的lif指針所指的對象卻沒有被銷毀,即:對象本身不會發生泄漏,泄漏的是對象中指針保存的記憶體

深入placement new

之前提到的new()操作以及new expression拆解的第三步,其實都是placement new。在主動使用placement new時,它的一般格式為:

new(pointer)Constructor(params);  // or  ::operator new(size_t, void*);

它的作用是:把對象(object)構造在已分配的記憶體(allocated memory)中。同樣也可以在vcruntime_new.h中找到相關定義:

#ifndef __PLACEMENT_NEW_INLINE      #define __PLACEMENT_NEW_INLINE      _Ret_notnull_ _Post_writable_byte_size_(_Size) _Post_satisfies_(return == _Where)      _NODISCARD inline void* __CRTDECL operator new(size_t _Size, _Writable_bytes_(_Size) void* _Where) noexcept      {          (void)_Size;          return _Where;      }        inline void __CRTDECL operator delete(void*, void*) noexcept      {          return;      }  #endif

可以看到,placement new並沒有做任何工作,它只是把我們傳遞的指針又return了回來。結合下面的例子就不難理解這個邏輯。

class LiF {  public:      LiF(int _lif = 0): lif(_lif) {}      int lif;  };    LiF* lifs = new LiF[3]; // array new  LiF* lif = new(lifs)LiF(); // placement new

我們在array new得到的LiF對象數組中的第一個對象上使用了placement new,同樣拆解這個new操作可以得到類似上面普通new的一個try/catch塊:

LiF* lif;  try {      void* mem = operator new(sizeof(LiF), lifs); // placement new      lif = static_cast<LiF*>(mem); // static type conversion      lif->LiF::LiF(); // constructor  } catch(std::bad_alloc) {      // exception handling  }

此外,在__PLACEMENT_NEW_INLINE宏還包含了一個placement delete的定義:

inline void __CRTDECL operator delete(void*, void*) noexcept  {      return;  }

可以看到,它也是不做任何工作的,所謂的placement delete只是為了形式上的統一。

總結

  • 記憶體的申請釋放可以在不同層面上進行,但只要是在作業系統之上,都是基於malloc/free。
  • 在C++ primitive層,通常使用new和delete系列,new是對malloc的封裝,delete是對free的封裝。
  • 通常new是指new expression。嚴格來說,new的含義有三種:new expression、operator new和placement new。new expression是operator new和placement new的複合,operator new負責記憶體的申請,placement new負責對象的構造;此外還有new[]。
  • 所有的記憶體申請/釋放操作都必須配套使用。