C++動態記憶體管理與原始碼剖析

HickeyZhang發表於2021-08-06

引言

在本篇文章中,我們主要剖析c++中的動態記憶體管理,包括malloc、new expression、operator new、array new和allocator記憶體分配方法以及對應的記憶體釋放方式和他們之間的呼叫關係,另外也包括一些會引發的陷阱如記憶體洩漏。


動態記憶體管理函式及其呼叫關係

c++中的動態記憶體分配和釋放方式有很多,主要包括:

  • malloc與free
  • new expression與delete expression
  • array new 與array delete
  • operator new和operator delete
  • allocator中的allocate與deallocate

除此之外還有placement new,但需要注意placement new不是用來記憶體分配和釋放的,而是在已分配的記憶體上構造物件。

他們之間的呼叫關係如下:

下面我們來具體看下每一種分配和釋放方式的使用和原理。

malloc與free

	void *p1 = malloc(32); //分配32位元組的記憶體
	free(p1);//釋放指標p1指向的記憶體

malloc函式以位元組數為引數,返回指向分配的記憶體的首地址的void指標;而free函式釋放給定指標指向的記憶體。

operator new與operator delete

	void *p6 = ::operator new(32); //分配32位元組
	::operator delete(p6);

PS:底層呼叫mallocfree。gnu的實現:

_GLIBCXX_WEAK_DEFINITION void *
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)
	_GLIBCXX_THROW_OR_ABORT(bad_alloc());
      handler ();
    }

  return p;
}

_GLIBCXX_WEAK_DEFINITION void
operator delete(void* ptr) noexcept
{
  std::free(ptr);
}

new expression與delete expression

首先來看下簡單的使用:

	int *p2 = new int;
	delete p2;
	
	string *p3 = new string("hello");
	delete p3;

new expression完成兩樣工作:

  1. 申請並分配記憶體。

  2. 呼叫建構函式。

string *p3 = new string("hello");被編譯器替換成下面的工作:

	string *p3;
	try{
            void * tmp_p = operator new(sizeof(string));
            p3 = static_cast<string *>(tmp_p);
            //string 通過巨集被替換為basic_string,string的實際實現是basic_string,這裡不是重點。
            p3 -> basic_string::basic_string("hello");	//編譯器可以這麼呼叫,但我們自己寫程式碼時不能。即我們不能以這種方式通過指標顯式呼叫建構函式。
        }catch (std::bad_alloc){
            //若分配失敗,建構函式不執行
        }

我們看到,原來new expression記憶體申請和分配是通過呼叫operator new()來完成的

delete expression也完成兩樣工作:

  1. 呼叫解構函式。
  2. 釋放記憶體。

delete p3;被編譯器替換成下面的工作:

	p3 -> ~string();//通過指標直接呼叫解構函式。我們自己寫程式碼時也可以這麼做。
	operator delete(p3);//釋放記憶體

array new 與array delete

	//Complex為自定義類,只需要知道Complex類中沒有指標成員。
	Complex *pca = new Complex[3];//3次建構函式
	delete[] pca;//3次解構函式

	string *psa = new string[3];//3次建構函式
	delete[] psa;//3次解構函式

array new呼叫一次記憶體分配函式(底層原始碼實現中,其實是呼叫operator new,只是呼叫的時候計算好了大小。因此,有上下兩個cookie。)和多次建構函式。正因為呼叫多次建構函式,因此只能呼叫無參建構函式。

Complex和string的很大不同之處在於,string有指標成員,佈局如下圖:

array delete呼叫多次解構函式,一次記憶體釋放函式(底層原始碼實現中其實是呼叫一次operator delete)。

我們來看下,如果本應該使用array delete的地方使用了delete expression會發生什麼:

	Complex *pca = new Complex[3];//3次建構函式
	delete pca;//1次解構函式

	string *psa = new string[3];//3次解構函式
	delete psa;//1次解構函式

對於Complex,我們使用了array new呼叫了3次建構函式,卻沒有使用array delete而使用了delete expression,因此只呼叫了一次解構函式。那麼,會發生記憶體洩漏嗎不會。因為Complex的解構函式是無關痛癢的(trivial),因為沒有要釋放的關聯的記憶體(Complex物件自身所佔記憶體之外沒有隱式佔用的記憶體)。

同樣,對於string,我們使用了array new呼叫了3次建構函式,卻沒有使用array delete而使用了delete expression,因此只呼叫了一次解構函式。那麼,會發生記憶體洩漏嗎。因為string的解構函式不是無關痛癢的(non-trivial),因為要釋放關聯的記憶體(我們知道string底層是通過char[]儲存的,析構時會釋放掉那些實際儲存字元的記憶體)。

PS: 具體的記憶體佈局例子(涉及到cookie、對齊填充padding等等)。

	int *p = new int[10];
	delete[]p;
	//delete p 亦可。int無關痛癢。

VC6中的記憶體佈局如下:

另:

	Demo *p = new Demo[3];//Demo為解構函式non-trivial的自定義class
	delete[] p;
	//delete p; //錯誤

VC6中的記憶體佈局(注意紅框內的3):

allocate與deallocate

#ifdef __GNUC__	//GNUC環境下
	void *p7 = allocator<int>().allocate(4);  //非static函式,通過例項化匿名物件呼叫allocate,分配4個int的記憶體。
	allocator<int>().deallocate((int *)p7, 4);
	
	void *p8 = __gnu_cxx::__pool_alloc<int>().allocate(4);
	__gnu_cxx::__pool_alloc<int>().deallocate((int *)p8, 4);
#endif

allocator為模板,例項化時需提供模板型別引數,上面的程式中模板型別引數為<int>,allocate的引數為4則allocate函式分配時就分配4int的記憶體。釋放記憶體時需要給出指向所要釋放的記憶體位置的指標,以及要釋放的記憶體大小,單位為模板型別引數型別的大小。

__pool_alloc也為模板,除底層呼叫malloc的時機不同外(__pool_alloc使用記憶體池降低cookie帶來的overhead),使用和上面的allocator相同。

placement new

用法:

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

Complex *pc = new(buf) Complex(1, 2);被編譯器替換成如下的工作:

	Complex *pc;
	try{
            void *tmp = operator new(sizeof(Complex), buf);//該過載版本並不分配記憶體。buf指標已經指向記憶體。
            pc = static_cast<Complex*>(tmp);
            pc->Complex::Complex(1, 2);//建構函式
        }catch(std::bad_alloc){
            //若分配失敗則不執行建構函式。實際上沒有分配,因為之前已經分配完。
        }

上面使用的GNU庫過載版本的operator new()函式如下:

// Default placement versions of operator new.
_GLIBCXX_NODISCARD inline void* operator new(std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT
{ return __p; }

可以看到確實沒有分配記憶體。

過載記憶體管理函式

new expressiondelete expression都不可過載。

operator newoperator delete可以過載:

  • 過載globaloperator newoperator delete,即::operator new(size_t)::operator delete(void *)。(一般不會過載全域性的該函式,因為影響太廣)
  • 過載某個class的operator newoperator delete

若某個類過載了operator newoperator delete,則用new expression例項化該類時,呼叫的是類的operator newoperator delete,否則,呼叫globaloperator newoperator delete

array newarray delete也可以過載。同樣分全域性的和類所屬的。

具體如何過載這些記憶體管理函式,以及如何使用過載的記憶體管理函式,將在下一篇文章中分析。

參考資料

[1] 《STL原始碼剖析》

[2] 《Effective C++》3/e

[3] 《C++ Primer》5/e

[4] 侯捷老師的課程

[5] gcc開源庫:https://github.com/gcc-mirror/gcc

相關文章