老鳥程式設計師也未必知道的C++垃圾回收功能

C++是一種使用範圍非常廣泛的程式語言,在軟體開發的各個領域都可以看到它的身影。然而對於C++開發人員來說,C++指標是一個讓他們悲喜交加的存在,它是一把雙刃劍。使用好了,會因為它強大的功能而所向披靡,開發出高效的應用程式;使用不好,程式崩潰、記憶體洩漏以及其它很多種莫名其妙的問題曾讓多少程式設計師抓狂。以至於很多C++程式設計師羨慕C#、Java等程式語言提供的垃圾回收機制,熟不知,隨著時代的前進,C++標準也從C++98、C++03進化到了C++11/C++14/C++17/C++20,從C++11開始便引入了極小垃圾回收機制,雖然幾乎沒有編譯器完全實現這些功能,但是作為標準,很可能在不遠的未來獲得編譯器或第三方庫的支援,下面就讓我們開始探索C++11中的垃圾回收機制。

一、 從智慧指標說起

早在C++98標準中,就引入了智慧指標auto_ptr,它是透過標準庫提供的一個模板型別來實現。auto_ptr 是一個輕量級的智慧指標,以物件的方式管理堆記憶體的分配,並在適當的時間釋放所獲得的記憶體,適合用來管理生命週期比較短或者不會被遠距離傳遞的動態物件, 比如某個函式或者類的內部。

程式設計師只需用new操作返回的指標來初始化auto_ptr即可,並且不再需要顯示的呼叫delete操作。當auto_ptr物件生命週期結束時,其解構函式會將auto_ptr物件擁有的動態記憶體自動釋放,即使發生異常,透過異常的棧展開過程也能將動態記憶體釋放。

下面看一個簡單的示例:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖1——auto_ptr

在這段程式中,雖然我們使用了兩次new操作,也沒有使用任何delete操作,但是並不會造成記憶體洩漏,這就是auto_ptr帶給我們的好處。然而auto_ptr也存在不少缺陷,如:(1)不能指向陣列;(2)不能共享所有權;(3)不能透過複製操作來初始化;(4)不能放入容器中使用;(5)不能作為容器的成員;(6)不能把一個原生指標給兩個智慧指標物件管理。所以在C++11標準中auto_ptr已經被放棄,取而代之的是unique_ptr、shared__ptr以及weak_ptr。

二、 垃圾回收機制

程式語言中的垃圾通常是指之前使用過,現在不再使用或者沒有指標指向的記憶體空間,而將這些垃圾收集起來以便再次使用的機制,就稱為垃圾回收。說到這裡,很多人的第一反應是Java,C#等動態語言,熟不知垃圾回收最早出現在Lisp語言當中,由John McCarthy 在1959年發明。

常見的垃圾回收演算法有以下幾種:

1、引用計數法

在每個物件內部維護一個整數值,叫做該物件的引用計數,用來記錄物件被引用的次數,當物件被引用時計數加一,不被引用時計數減一,當引用次數變為0時,該物件即可被當做垃圾回收。它的特點是實現簡單,清理垃圾時不會造成程式暫停,也不會對系統的快取或者交換空間造成影響;缺點是難以處理迴圈引用,所以在使用時也有一定的限制。

目前c++ 標準庫的 std::shared_ptr 、微軟的 COM 、Objective-C 以及 PHP等都使用了引用計數法。

2、標記清除法

標記清除法是一種基於跟蹤處理的垃圾回收演算法,它透過跟蹤產生物件的關係圖,然後進行垃圾回收,可以有效解決迴圈引用帶來的問題。

該方法分為兩個階段,標記階段從程式的根節點開始,遞迴的遍歷所有物件,給能遍歷到的物件打上標記,被標記的物件認為是可達物件,沒有標記的物件就被認為是垃圾;清除階段則將未標記的物件當做垃圾回收。

相比引用計數,該方法效率低下,此外還有一個致命缺點,就是人們常常說的 STW 問題(Stop The World)。因為演算法在標記時必須暫停整個程式,否則其他執行緒的程式碼可能會改變物件狀態,從而可能把不應該回收的物件當做垃圾收集掉。

3、分代收集法

分代收集法是標記清除法的一個改進,該演算法是基於這樣幾個假設:大量新建立的物件生命週期都比較短,而較老的物件生命週期會更長 ;對部分記憶體進行回收比基於全部記憶體的回收操作要快 ;新建立的物件之間關聯程度通常較強。雖然每種語言中代的名稱不同,但是一般都分為三代,比如Java,C#和Python等。

對於一種語言來說,實現垃圾回收功能往往會綜合應用上面幾種方法,比如Java和C#中都使用了標記清除法和分帶收集法,而Python還使用了引用計數法。

除了上面幾種主要方法外,其它的垃圾回收機制還有Go最新版本中使用的三色標記法,以及由標記-清除法改進的標記-整理法,標記-複製法等。

三、 C++11最小垃圾回收

C++11的最小垃圾回收和基於可達性的記憶體洩漏檢測是在N2670(見下圖)

老鳥程式設計師也未必知道的C++垃圾回收功能

圖2——N2670提案

中加入的。其實在最初由Hans-J Boehm(同時也是我們後面要介紹的一款開源工具的作者)和Mike Spertus共同向C++委員會提交的,該方法透過新增一些關鍵字來支援C++語言中的垃圾回收功能,不過遺憾的是因為過於複雜,且存在一些問題,而沒有透過,後來他們又對這一提案進行簡化修改,只保留了支援垃圾回收的最基本功能,即透過語言的約束來保證安全的垃圾回收,這就是我們現在看到的C++11標準。

C++11中的垃圾回收基於安全派生指標,先看一下它的定義:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖3——安全派生指標

實際上它是一個指向由new分配的物件或者子物件,透過過載一些基本操作來實現對垃圾回收的支援。在C++11標準中,可以透過下面的程式碼來檢視自己使用的編譯器對安全派生指標的支援情況,

老鳥程式設計師也未必知道的C++垃圾回收功能

圖4——指標安全模式

下面是在MSVC2017上的執行結果:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖5——MSVC2017測試

從執行結果來看,很遺憾,在MSVC2017中並不支援最小垃圾回收機制,但是從它提供了get_pointer_safety()介面來看,至少它已經向垃圾回收功能邁進了一步。

下面我們看一下現有標準下的一些特性:

1、 unique_ptr:是一種智慧指標,支援建立陣列物件,它獨享所指物件的所有權,無法進行復制構造和賦值操作,當一個unique_ptr指標指向其它物件時,原先的物件將會被銷燬,此外也無法使兩個unique_ptr指向同一個物件。看一個簡單的例子:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖6——unique_ptr

此外,unique_ptr還過載了一些運算子,並定義了一些函式,具體可參考,或評論留言。

2、 shared_ptr:它也是一種智慧指標,特點是指向的資源具有共享性,多個物件可以指向同一資源,它是透過引用計數的方式來管理的。下面看一個簡單構造shared_ptr的例子:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖7——shared_ptr

shared_ptr有兩種構造方式,即透過建構函式或者透過輔助函式,使用時需要#include ,此外它還過載了operator*、operator->等運算子,還定義了一些其它成員函式,更詳細的資訊可查閱,或者評論區留言。

shared_ptr也有一些缺陷,比如它無法進行隱式型別轉換,也不能用兩個shared_ptr儲存同一個指標,多執行緒情況下因為加鎖造成的額外開銷等問題,所以使用時一定多加思考。

3、 weak_ptr:它也是一種智慧指標,用來協助shared_ptr完成一些 更復雜的應用,它可以從另一個weak_ptr物件構造,也可以透過一個shared_ptr構造,它的構造與析構不會引起引用計數的變化,下面看一個簡單的例子:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖8——weak_ptr

需要說明一點的是weak_ptr沒有過載operator*和operator->,只能透過lock獲得一個可用的shared_ptr物件,然後利用shared_ptr的方法進行訪問,如前面的示例。更多詳細資訊可查閱,或者評論區留言。

4、 declare_reachable & undeclare_reachable:分別用來宣告指標所引用的物件可以抵達或者移除指標所指物件的可抵達狀態。一個可抵達物件即使所有指向它的指標都被銷燬,也不會被垃圾回收器刪除,或者被記憶體洩漏檢測器認為是記憶體洩漏。獲取更多詳細資訊可查閱,或評論區留言。

5、 declare_no_pointer & undeclare_no_pointer:前者告訴垃圾回收器或者記憶體洩漏檢測器,指定的記憶體區域不含可追蹤指標,而後者用來接觸前者指定的區域,呼叫時引數要保持一致。更多詳細資訊可查閱:,或者評論區留言共同討論。

四、 編譯器支援情況

目前大部分的C++編譯器已經開始支援C++11標準,不過每個廠商對C++11的支援程度還有很大差別,從下面圖表可以看出GCC、Clang、MSVC等編譯器已經提供了垃圾收集的庫支援,只是其中的功能還沒有具體實現。從筆者目前能獲取到的資訊來看,目前還沒有一種編譯器能完全支援垃圾回收與基於可達性的記憶體洩漏檢測,如果哪位讀者有了最新訊息,煩請不吝賜教。

老鳥程式設計師也未必知道的C++垃圾回收功能

圖9——庫支援情況

老鳥程式設計師也未必知道的C++垃圾回收功能

老鳥程式設計師也未必知道的C++垃圾回收功能

圖10——特性支援情況

五、 第三方C/C++垃圾收集器

雖然到目前為止大部分的編譯器並沒有完全支援垃圾回收功能,但是卻有一些第三方庫為提供另類的實現,下面介紹一款第三方C/C++垃圾收集器。感興趣的讀者可前往獲取更多資訊。因為如何使用該庫的方法介紹都足以寫很多單獨的文章,所以在此只做一個簡單的介紹。

老鳥程式設計師也未必知道的C++垃圾回收功能

圖11——一個C/C++垃圾回收器

該垃圾回收器可以用來代替C中malloc或者C++中的new,使用者依然可以使用以前的方式分配記憶體而不需要顯示的釋放操作。當它認為一塊記憶體不再使用時,就自動啟動回收記憶體。

此外該垃圾回收器還可以當做一個記憶體洩漏檢測器來使用,同樣可以應用於C或者C++環境。雖然這不是它的主要目標,但是考慮到一些專案的實際情況,使用記憶體洩漏檢測器也是一個不錯的選項。

下面看一個簡單的例子:

老鳥程式設計師也未必知道的C++垃圾回收功能

圖12——垃圾回收器示例

在該示例中首先透過GC_INIT對垃圾回收器進行初始化,然後在迴圈中不斷分配記憶體,並每隔十萬次顯示堆記憶體的使用情況。

後續文章計劃對該庫進行詳細介紹,感興趣的朋友可以留言共同探討。

六、 期待與遠景

雖然目前C++還沒有完全支援垃圾回收機制,但是垃圾回收也絕非其它一些程式語言,如Java、C#、Python等的專利。隨著技術的不斷髮展,特別是硬體效能的提升將使直接操作指標帶來的益處變得越來越微弱,C++必將會在垃圾回收方面做一些改變。雖然目前C++11標準中引入的垃圾回收機制還非常有限,但是我們已經能看到一些發展趨勢,如果在開發工程中能夠考慮程式碼向前的相容性,一旦未來C++支援垃圾回收功能,程式碼將可以直接享受它帶來的好處。