深入 C++ 的 new

發表於2016-10-17

“new”是C++的一個關鍵字,同時也是操作符。關於new的話題非常多,因為它確實比較複雜,也非常神祕,下面我將把我瞭解到的與new有關的內容做一個總結。

new的過程

當我們使用關鍵字new在堆上動態建立一個物件時,它實際上做了三件事:獲得一塊記憶體空間、呼叫建構函式、返回正確的指標。當然,如果我們建立的是簡單型別的變數,那麼第二步會被省略。假如我們定義瞭如下一個類A:

那麼上述動態建立一個物件的過程大致相當於以下三句話(只是大致上):

雖然從效果上看,這三句話也得到了一個有效的指向堆上的A物件的指標pa,但區別在於,當malloc失敗時,它不會呼叫分配記憶體失敗處理程式new_handler,而使用new的話會的。因此我們還是要儘可能的使用new,除非有一些特殊的需求。

new 的三種形態

到目前為止,本文所提到的new都是指的“new operator”或稱為“new expression”,但事實上在C++中一提到new,至少可能代表以下三種含義:new operator、operator new、placement new。

new operator就是我們平時所使用的new,其行為就是前面所說的三個步驟,我們不能更改它。但具體到某一步驟中的行為,如果它不滿足我們的具體要求時,我們是有可能更改它的。三個步驟中最後一步只是簡單的做一個指標的型別轉換,沒什麼可說的,並且在編譯出的程式碼中也並不需要這種轉換,只是人為的認識罷了。但前兩步就有些內容了。

new operator的第一步分配記憶體實際上是通過呼叫operator new來完成的,這裡的new實際上是像加減乘除一樣的操作符,因此也是可以過載的。operator new預設情況下首先呼叫分配記憶體的程式碼,嘗試得到一段堆上的空間,如果成功就返回,如果失敗,則轉而去呼叫一個new_hander,然後繼續重複前面過程。如果我們對這個過程不滿意,就可以過載operator new,來設定我們希望的行為。例如:

這裡通過::operator new呼叫了原有的全域性的new,實現了在分配記憶體之前輸出一句話。全域性的operator new也是可以過載的,但這樣一來就不能再遞迴的使用new來分配記憶體,而只能使用malloc了:

相應的,delete也有delete operator和operator delete之分,後者也是可以過載的。並且,如果過載了operator new,就應該也相應的過載operator delete,這是良好的程式設計習慣。

new的第三種形態——placement new是用來實現定位構造的,因此可以實現new operator三步操作中的第二步,也就是在取得了一塊可以容納指定型別物件的記憶體後,在這塊記憶體上構造一個物件,這有點類似於前面程式碼中的“p->A::A(3);”這句話,但這並不是一個標準的寫法,正確的寫法是使用placement new:

對標頭檔案<new>或<new.h>的引用是必須的,這樣才可以使用placement new。這裡“new(p) A(3)”這種奇怪的寫法便是placement new了,它實現了在指定記憶體地址上用指定型別的建構函式來構造一個物件的功能,後面A(3)就是對建構函式的顯式呼叫。這裡不難發現,這塊指定的地址既可以是棧,又可以是堆,placement對此不加區分。

但是,除非特別必要,不要直接使用placement new ,這畢竟不是用來構造物件的正式寫法,只不過是new operator的一個步驟而已。使用new operator地編譯器會自動生成對placement new的呼叫的程式碼,因此也會相應的生成使用delete時呼叫解構函式的程式碼。如果是像上面那樣在棧上使用了placement new,則必須手工呼叫解構函式,這也是顯式呼叫解構函式的唯一情況:

當我們覺得預設的new operator對記憶體的管理不能滿足我們的需要,而希望自己手工的管理記憶體時,placement new就有用了。STL中的allocator就使用了這種方式,藉助placement new來實現更靈活有效的記憶體管理。

處理記憶體分配異常

正如前面所說,operator new的預設行為是請求分配記憶體,如果成功則返回此記憶體地址,如果失敗則呼叫一個new_handler,然後再重複此過程。於是,想要從operator new的執行過程中返回,則必然需要滿足下列條件之一:

  • 分配記憶體成功
  • new_handler中丟擲bad_alloc異常
  • new_handler中呼叫exit()或類似的函式,使程式結束

於是,我們可以假設預設情況下operator new的行為是這樣的:

在預設情況下,new_handler的行為是丟擲一個bad_alloc異常,因此上述迴圈只會執行一次。但如果我們不希望使用預設行為,可以自定義一個new_handler,並使用std::set_new_handler函式使其生效。在自定義的new_handler中,我們可以丟擲異常,可以結束程式,也可以執行一些程式碼使得有可能有記憶體被空閒出來,從而下一次分配時也許會成功,也可以通過set_new_handler來安裝另一個可能更有效的new_handler。例如:

這裡new_handler程式在丟擲異常之前會輸出一句話。應該注意,在new_handler的程式碼裡應該注意避免再巢狀有對new的呼叫,因為如果這裡呼叫new再失敗的話,可能會再導致對new_handler的呼叫,從而導致無限遞迴呼叫。——這是我猜的,並沒有嘗試過。

在程式設計時我們應該注意到對new的呼叫是有可能有異常被丟擲的,因此在new的程式碼周圍應該注意保持其事務性,即不能因為呼叫new失敗丟擲異常來導致不正確的程式邏輯或資料結構的出現。例如:

靜態變數count用於記錄此型別生成的例項的個數,在上述程式碼中,如果因new分配記憶體失敗而丟擲異常,那麼其例項個數並沒有增加,但count變數的值卻已經多了一個,從而資料結構被破壞。正確的寫法是:

這樣一來,如果new失敗則直接丟擲異常,count的值不會增加。類似的,在處理執行緒同步時,也要注意類似的問題:

此時,如果new失敗,unlock將不會被執行,於是不僅造成了一個指向不正確地址的指標p的存在,還將導致someMutex永遠不會被解鎖。這種情況是要注意避免的。(參考:C++箴言:爭取異常安全的程式碼)

STL 的記憶體分配與 traits 技巧

在《STL原碼剖析》一書中詳細分析了SGI STL的記憶體分配器的行為。與直接使用new operator不同的是,SGI STL並不依賴C++預設的記憶體分配方式,而是使用一套自行實現的方案。首先SGI STL將可用記憶體整塊的分配,使之成為當前程式可用的記憶體,當程式中確實需要分配記憶體時,先從這些已請求好的大記憶體塊中嘗試取得記憶體,如果失敗的話再嘗試整塊的分配大記憶體。這種做法有效的避免了大量記憶體碎片的出現,提高了記憶體管理效率。

為了實現這種方式,STL使用了placement new,通過在自己管理的記憶體空間上使用placement new來構造物件,以達到原有new operator所具有的功能。

此函式接收一個已構造的物件,通過拷貝構造的方式在給定的記憶體地址p上構造一個新物件,程式碼中後半截T1(value)便是placement new語法中呼叫建構函式的寫法,如果傳入的物件value正是所要求的型別T1,那麼這裡就相當於呼叫拷貝建構函式。類似的,因使用了placement new,編譯器不會自動產生呼叫解構函式的程式碼,需要手工的實現:

與此同時,STL中還有一個接收兩個迭代器的destory版本,可將某容器上指定範圍內的物件全部銷燬。典型的實現方式就是通過一個迴圈來對此範圍內的物件逐一呼叫解構函式。如果所傳入的物件是非簡單型別,這樣做是必要的,但如果傳入的是簡單型別,或者根本沒有必要呼叫解構函式的自定義型別(例如只包含數個int成員的結構體),那麼再逐一呼叫解構函式是沒有必要的,也浪費了時間。為此,STL使用了一種稱為“type traits”的技巧,在編譯器就判斷出所傳入的型別是否需要呼叫解構函式:

其中value_type()用於取出迭代器所指向的物件的型別資訊,於是:

因上述函式全都是inline的,所以多層的函式呼叫並不會對效能造成影響,最終編譯的結果根據具體的型別就只是一個for迴圈或者什麼都沒有。這裡的關鍵在於__type_traits這個模板類上,它根據不同的T型別定義出不同的has_trivial_destructor的結果,如果T是簡單型別,就定義為__true_type型別,否則就定義為__false_type型別。其中__true_type、__false_type只不過是兩個沒有任何內容的類,對程式的執行結果沒有什麼意義,但在編譯器看來它對模板如何特化就具有非常重要的指導意義了,正如上面程式碼所示的那樣。__type_traits也是特化了的一系列模板類:

如果要把一個自定義的型別MyClass也定義為不呼叫解構函式,只需要相應的定義__type_traits的一個特化版本即可:

模板是比較高階的C++程式設計技巧,模板特化、模板偏特化就更是技巧性很強的東西,STL中的type_traits充分藉助模板特化的功能,實現了在程式編譯期通過編譯器來決定為每一處呼叫使用哪個特化版本,於是在不增加程式設計複雜性的前提下大大提高了程式的執行效率。更詳細的內容可參考《STL原始碼剖析》第二、三章中的相關內容。

帶有“[]”的new和delete

我們經常會通過new來動態建立一個陣列,例如:

嚴格的說,上述程式碼是不正確的,因為我們在分配記憶體時使用的是new[],而並不是簡單的new,但釋放記憶體時卻用的是delete。正確的寫法是使用delete[]:

但是,上述錯誤的程式碼似乎也能編譯執行,並不會帶來什麼錯誤。事實上,new與new[]、delete與delete[]是有區別的,特別是當用來操作複雜型別時。假如針對一個我們自定義的類MyClass使用new[]:

上述程式碼的結果是在堆上分配了10個連續的MyClass例項,並且已經對它們依次呼叫了建構函式,於是我們得到了10個可用的物件,這一點與Java、C#有區別的,Java、C#中這樣的結果只是得到了10個null。換句話說,使用這種寫法時MyClass必須擁有不帶引數的建構函式,否則會發現編譯期錯誤,因為編譯器無法呼叫有引數的建構函式。

當這樣構造成功後,我們可以再將其釋放,釋放時使用delete[]:

當我們對動態分配的陣列呼叫delete[]時,其行為根據所申請的變數型別會有所不同。如果p指向簡單型別,如int、char等,其結果只不過是這塊記憶體被回收,此時使用delete[]與delete沒有區別,但如果p指向的是複雜型別,delete[]會針對動態分配得到的每個物件呼叫解構函式,然後再釋放記憶體。因此,如果我們對上述分配得到的p指標直接使用delete來回收,雖然編譯期不報什麼錯誤(因為編譯器根本看不出來這個指標p是如何分配的),但在執行時(DEBUG情況下)會給出一個Debug assertion failed提示。

到這裡,我們很容易提出一個問題——delete[]是如何知道要為多少個物件呼叫解構函式的?要回答這個問題,我們可以首先看一看new[]的過載。

執行此段程式碼,得到的結果為:(VC2005)

雖然對建構函式和解構函式的呼叫結果都在預料之中,但所申請的記憶體空間大小以及地址的數值卻出現了問題。我們的類MyClass的大小顯然是4個位元組,並且申請的陣列中有3個元素,那麼應該一共申請12個位元組才對,但事實上系統卻為我們申請了16位元組,並且在operator new[]返後我們得到的記憶體地址是實際申請得到的記憶體地址值加4的結果。也就是說,當為複雜型別動態分配陣列時,系統自動在最終得到的記憶體地址前空出了4個位元組,我們有理由相信這4個位元組的內容與動態分配陣列的長度有關。通過單步跟蹤,很容易發現這4個位元組對應的int值為0x00000003,也就是說記錄的是我們分配的物件的個數。改變一下分配的個數然後再次觀察的結果證實了我的想法。

於是,我們也有理由認為new[] operator的行為相當於下面的虛擬碼:

上述示意性的程式碼省略了異常處理的部分,只是展示當我們對一個複雜型別使用new[]來動態分配陣列時其真正的行為是什麼,從中可以看到它分配了比預期多4個位元組的記憶體並用它來儲存物件的個數,然後對於後面每一塊空間使用placement new來呼叫無參建構函式,這也就解釋了為什麼這種情況下類必須有無參建構函式,最後再將首地址返回。類似的,我們很容易寫出相應的delete[]的實現程式碼:

由此可見,在預設情況下operator new[]與operator new的行為是相同的,operator delete[]與operator delete也是,不同的是new operator與new[] operator、delete operator與delete[] operator。當然,我們可以根據不同的需要來選擇過載帶有和不帶有“[]”的operator new和delete,以滿足不同的具體需求。

把前面類MyClass的程式碼稍做修改——註釋掉解構函式,然後再來看看程式的輸出:

這一次,new[]老老實實的申請了12個位元組的記憶體,並且申請的結果與new[] operator返回的結果也是相同的,看來,是否在前面新增4個位元組,只取決於這個類有沒有解構函式,當然,這麼說並不確切,正確的說法是這個類是否需要呼叫建構函式,因為如下兩種情況下雖然這個類沒宣告解構函式,但還是多申請了4個位元組:一是這個類中擁有需要呼叫解構函式的成員,二是這個類繼承自需要呼叫解構函式的類。於是,我們可以遞迴的定義“需要呼叫解構函式的類”為以下三種情況之一:

  1. 顯式的宣告瞭解構函式的
  2. 擁有需要呼叫解構函式的類的成員的
  3. 繼承自需要呼叫解構函式的類的

類似的,動態申請簡單型別的陣列時,也不會多申請4個位元組。於是在這兩種情況下,釋放記憶體時使用delete或delete[]都可以,但為養成良好的習慣,我們還是應該注意只要是動態分配的陣列,釋放時就使用delete[]。

釋放記憶體時如何知道長度

但這同時又帶來了新問題,既然申請無需呼叫解構函式的類或簡單型別的陣列時並沒有記錄個數資訊,那麼operator delete,或更直接的說free()是如何來回收這塊記憶體的呢?這就要研究malloc()返回的記憶體的結構了。與new[]類似的是,實際上在malloc()申請記憶體時也多申請了數個位元組的內容,只不過這與所申請的變數的型別沒有任何關係,我們從呼叫malloc時所傳入的引數也可以理解這一點——它只接收了要申請的記憶體的長度,並不關係這塊記憶體用來儲存什麼型別。下面執行這樣一段程式碼做個實驗:

我們直接來看VC2005下Release版本的執行結果,DEBUG版因包含了較多的除錯資訊,這裡就不分析了:

每一次分配的位元組數都比上一次多4,distance值記錄著與上一次分配的差值,第一個差值沒有實際意義,中間有一個較大的差值,可能是這塊記憶體已經被分配了,於是也忽略它。結果中最小的差值為16位元組,直到我們申請16位元組時,這個差值變成了24,後面也有類似的規律,那麼我們可以認為申請所得的記憶體結構是如下這樣的:

從圖中不難看出,當我們要分配一段記憶體時,所得的記憶體地址和上一次的尾地址至少要相距8個位元組(在DEBUG版中還要更多),那麼我們可以猜想,這8個位元組中應該記錄著與這段所分配的記憶體有關的資訊。觀察這8個節內的內容,得到結果如下:

圖中右邊為每次分配所得的地址之前8個位元組的內容的16進製表示,從圖中紅線所表示可以看到,這8個位元組中的第一個位元組乘以8即得到相臨兩次分配時的距離,經過試驗一次性分配更大的長度可知,第二個位元組也是這個意義,並且代表高8位,也就說前面空的這8個位元組中的前兩個位元組記錄了一次分配記憶體的長度資訊,後面的六個位元組可能與空閒記憶體連結串列的資訊有關,在翻譯記憶體時用來提供必要的資訊。這就解答了前面提出的問題,原來C/C++在分配記憶體時已經記錄了足夠充分的資訊用於回收記憶體,只不過我們平常不關心它罷了。

相關文章