導語: C++指針的內存管理相信是大部分C++入門程序員的夢魘,受到Boost的啟發(fā),C++11標準推出了智能指針,讓我們從指針的內存管理中釋放出來,幾乎消滅所有new和delete。既然智能指針如此強大,今天我們來一窺智能指針的原理以及在多線程操作中需要注意的細節(jié)。
智能指針的由來
在遠古時代,C++發(fā)明了指針這把雙刃劍,既可以讓程序員精確地控制堆上每一塊內存,也讓程序更容易發(fā)生crash,大大增加了使用指針的技術門檻。因此,從C++98開始便推出了auto_ptr,對裸指針進行封裝,讓程序員無需手動釋放指針指向的內存區(qū)域,在auto_ptr生命周期結束時自動釋放,然而,由于auto_ptr在轉移指針所有權后會產生野指針,導致程序運行時crash,如下面示例代碼所示:
auto_ptr<int> p1(new int(10));
auto_ptr<int> p2 = p1; //轉移控制權
*p1 += 10; //crash,p1為空指針,可以用p1->get判空做保護
因此在C++11又推出了unique_ptr、shared_ptr、weak_ptr三種智能指針,慢慢取代auto_ptr。
unique_ptr的使用
unique_ptr是auto_ptr的繼承者,對于同一塊內存只能有一個持有者,而unique_ptr和auto_ptr唯一區(qū)別就是unique_ptr不允許賦值操作,也就是不能放在等號的右邊(函數(shù)的參數(shù)和返回值例外),這一定程度避免了一些誤操作導致指針所有權轉移,然而,unique_str依然有提供所有權轉移的方法move,調用move后,原unique_ptr就會失效,再用其訪問裸指針也會發(fā)生和auto_ptr相似的crash,如下面示例代碼,所以,即使使用了unique_ptr,也要慎重使用move方法,防止指針所有權被轉移。
unique_ptr<int> up(new int(5));
//auto up2 = up; // 編譯錯誤
auto up2 = move(up);
cout << *up << endl; //crash,up已經失效,無法訪問其裸指針
除了上述用法,unique_ptr還支持創(chuàng)建動態(tài)數(shù)組。在C++中,創(chuàng)建數(shù)組有很多方法,如下所示:
// 靜態(tài)數(shù)組,在編譯時決定了數(shù)組大小
int arr[10];
// 通過指針創(chuàng)建在堆上的數(shù)組,可在運行時動態(tài)指定數(shù)組大小,但需要手動釋放內存
int *arr = new int[10];
// 通過std::vector容器創(chuàng)建動態(tài)數(shù)組,無需手動釋放數(shù)組內存
vector<int> arr(10);
// 通過unique_ptr創(chuàng)建動態(tài)數(shù)組,也無需手動釋放數(shù)組內存,比vector更輕量化
unique_ptr<int[]> arr(new int[10]);
這里需要注意的是,不管vector還是unique_ptr,雖然可以幫我們自動釋放數(shù)組內存,但如果數(shù)組的元素是復雜數(shù)據(jù)類型時,我們還需要在其析構函數(shù)中正確釋放內存。
真正的智能指針:shared_ptr
auto_ptr和unique_ptr都有或多或少的缺陷,因此C++11還推出了shared_ptr,這也是目前工程內使用最多最廣泛的智能指針,他使用引用計數(shù)(感覺有參考Objective-C的嫌疑),實現(xiàn)對同一塊內存可以有多個引用,在最后一個引用被釋放時,指向的內存才釋放,這也是和unique_ptr最大的區(qū)別。
另外,使用shared_ptr過程中有幾點需要注意:
構造shared_ptr的方法,如下示例代碼所示,我們盡量使用shared_ptr構造函數(shù)或者make_shared的方式創(chuàng)建shared_ptr,禁止使用裸指針賦值的方式,這樣會shared_ptr難于管理指針的生命周期。
// 使用裸指針賦值構造,不推薦,裸指針被釋放后,shared_ptr就野了,不能完全控制裸指針的生命周期,失去了智能指針價值
int *p = new int(10);
shared_ptr<int>sp = p;
delete p; // sp將成為野指針,使用sp將crash
// 將裸指針作為匿名指針傳入構造函數(shù),一般做法,讓shared_ptr接管裸指針的生命周期,更安全
shared_ptr<int>sp1(new int(10));
// 使用make_shared,推薦做法,更符合工廠模式,可以連代碼中的所有new,更高效;方法的參數(shù)是用來初始化模板類
shared_ptr<int>sp2 = make_shared<int>(10);
禁止使用指向shared_ptr的裸指針,也就是智能指針的指針,這聽起來就很奇怪,但開發(fā)中我們還需要注意,使用shared_ptr的指針指向一個shared_ptr時,引用計數(shù)并不會加一,操作shared_ptr的指針很容易就發(fā)生野指針異常。
shared_ptr<int>sp = make_shared<int>(10);
cout << sp.use_count() << endl; //輸出1
shared_ptr<int> *sp1 = &sp;
cout << (*sp1).use_count() << endl; //輸出依然是1
(*sp1).reset(); //sp成為野指針
cout << *sp << endl; //crash
使用shared_ptr創(chuàng)建動態(tài)數(shù)組,在介紹unique_ptr時我們就講過創(chuàng)建動態(tài)數(shù)組,而shared_ptr同樣可以做到,不過稍微復雜一點,如下代碼所示,除了要顯示指定析構方法外(因為默認是T的析構函數(shù),不是T[]),另外對外的數(shù)據(jù)類型依然是shared_ptr<T>,非常有迷惑性,看不出來是數(shù)組,最后不能直接使用下標讀寫數(shù)組,要先get()獲取裸指針才可以使用下標。所以,不推薦使用shared_ptr來創(chuàng)建動態(tài)數(shù)組,盡量使用unique_ptr,這可是unique_ptr為數(shù)不多的優(yōu)勢了。
template <typename T>
shared_ptr<T> make_shared_array(size_t size) {
return shared_ptr<T>(new T[size], default_delete<T[]>());
}
shared_ptr<int>sp = make_shared_array(10); //看上去是shared<int>類型,實際上是數(shù)組
sp.get()[0] = 100; //不能直接使用下標讀寫數(shù)組元素,需要通過get()方法獲取裸指針后再操作
用shared_ptr實現(xiàn)多態(tài),在我們使用裸指針時,實現(xiàn)多態(tài)就免不了定義虛函數(shù),那么用shared_ptr時也不例外,不過有一處是可以省下的,就是析構函數(shù)我們不需要定義為虛函數(shù)了,如下面代碼所示:
class A {
public:
~A() {
cout << "dealloc A" << endl;
}
};
class B : public A {
public:
~B() {
cout << "dealloc B" << endl;
}
};
int main(int argc, const char * argv[]) {
A *a = new B();
delete a; //只打印dealloc A
shared_ptr<A>spa = make_shared<B>(); //析構spa是會先打印dealloc B,再打印dealloc A
return 0;
}
循環(huán)引用,筆者最先接觸引用計數(shù)的語言就是Objective-C,而OC中最常出現(xiàn)的內存問題就是循環(huán)引用,如下面代碼所示,A中引用B,B中引用A,spa和spb的強引用計數(shù)永遠大于等于1,所以直到程序退出前都不會被退出,這種情況有時候在正常的業(yè)務邏輯中是不可避免的,而解決循環(huán)引用的方法最有效就是改用weak_ptr,具體可見下一章。
class A {
public:
shared_ptr<B> b;
};
class B {
public:
shared_ptr<A> a;
};
int main(int argc, const char * argv[]) {
shared_ptr<A> spa = make_shared<A>();
shared_ptr<B> spb = make_shared<B>();
spa->b = spb;
spb->a = spa;
return 0;
} //main函數(shù)退出后,spa和spb強引用計數(shù)依然為1,無法釋放
剛柔并濟:weak_ptr
正如上一章提到,使用shared_ptr過程中有可能會出現(xiàn)循環(huán)引用,關鍵原因是使用shared_ptr引用一個指針時會導致強引用計數(shù)+1,從此該指針的生命周期就會取決于該shared_ptr的生命周期,然而,有些情況我們一個類A里面只是想引用一下另外一個類B的對象,類B對象的創(chuàng)建不在類A,因此類A也無需管理類B對象的釋放,這個時候weak_ptr就應運而生了,使用shared_ptr賦值給一個weak_ptr不會增加強引用計數(shù)(strong_count),取而代之的是增加一個弱引用計數(shù)(weak_count),而弱引用計數(shù)不會影響到指針的生命周期,這就解開了循環(huán)引用,上一章最后的代碼使用weak_ptr可改造為如下代碼。
class A {
public:
shared_ptr<B> b;
};
class B {
public:
weak_ptr<A> a;
};
int main(int argc, const char * argv[]) {
shared_ptr<A> spa = make_shared<A>();
shared_ptr<B> spb = make_shared<B>();
spa->b = spb; //spb強引用計數(shù)為2,弱引用計數(shù)為1
spb->a = spa; //spa強引用計數(shù)為1,弱引用計數(shù)為2
return 0;
} //main函數(shù)退出后,spa先釋放,spb再釋放,循環(huán)解開了
使用weak_ptr也有需要注意的點,因為既然weak_ptr不負責裸指針的生命周期,那么weak_ptr也無法直接操作裸指針,我們需要先轉化為shared_ptr,這就和OC的Strong-Weak Dance有點像了,具體操作如下:
shared_ptr<int> spa = make_shared<int>(10);
weak_ptr<int> spb = spa; //weak_ptr無法直接使用裸指針創(chuàng)建
if (!spb.expired()) { //weak_ptr最好判斷是否過期,使用expired或use_count方法,前者更快
*spb.lock() += 10; //調用weak_ptr轉化為shared_ptr后再操作裸指針
}
cout << *spa << endl; //20
智能指針原理
看到這里,智能指針的用法基本介紹完了,后面筆者來粗淺地分析一下為什么智能指針可以有效幫我們管理裸指針的生命周期。
使用棧對象管理堆對象
在C++中,內存會分為三部分,堆、棧和靜態(tài)存儲區(qū),靜態(tài)存儲區(qū)會存放全局變量和靜態(tài)變量,在程序加載時就初始化,而堆是由程序員自行分配,自行釋放的,例如我們使用裸指針分配的內存;而最后棧是系統(tǒng)幫我們分配的,所以也會幫我們自動回收。因此,智能指針就是利用這一性質,通過一個棧上的對象(shared_ptr或unique_ptr)來管理一個堆上的對象(裸指針),在shared_ptr或unique_ptr的析構函數(shù)中判斷當前裸指針的引用計數(shù)情況來決定是否釋放裸指針。
shared_ptr引用計數(shù)的原理
一開始筆者以為引用計數(shù)是放在shared_ptr這個模板類中,但是細想了一下,如果這樣將shared_ptr賦值給另一個shared_ptr時,是怎么做到兩個shared_ptr的引用計數(shù)同時加1呢,讓等號兩邊的shared_ptr中的引用計數(shù)同時加1?不對,如果還有第二個shared_ptr再賦值給第三個shared_ptr那怎么辦呢?或許通過下面的類圖便清楚個中奧秘。
[ boost中shared_ptr與weak_ptr類圖 ]
我們重點關注shared_ptr<T>的類圖,它就是我們可以直接操作的類,這里面包含裸指針T*,還有一個shared_count的對象,而shared_count對象還不是最終的引用計數(shù),它只是包含了一個指向sp_counted_base的指針,這應該就是真正存放引用計數(shù)的地方,包括強應用計數(shù)和弱引用計數(shù),而且shared_count中包含的是sp_counted_base的指針,不是對象,這也就意味著假如shared_ptr<T> a = b,那么a和b底層pi_指針指向的是同一個sp_counted_base對象,這就很容易做到多個shared_ptr的引用計數(shù)永遠保持一致了。
多線程安全
本章所說的線程安全有兩種情況:
多個線程操作多個不同的shared_ptr對象
C++11中聲明了shared_ptr的計數(shù)操作具有原子性,不管是賦值導致計數(shù)增加還是釋放導致計數(shù)減少,都是原子性的,這個可以參考sp_counted_base的源碼,因此,基于這個特性,假如有多個shared_ptr共同管理一個裸指針,那么多個線程分別通過不同的shared_ptr進行操作是線程安全的。
多個線程操作同一個shared_ptr對象
同樣的道理,既然C++11只負責sp_counted_base的原子性,那么shared_ptr本身就沒有保證線程安全了,加入兩個線程同時訪問同一個shared_ptr對象,一個進行釋放(reset),另一個讀取裸指針的值,那么最后的結果就不確定了,很有可能發(fā)生野指針訪問crash。