標準C++類std::string的
內存共享和Copy-On-Write技術
陳皓
一、 概念
Scott Meyers在《More Effective C++》中舉了個例子,不知你是否還記得?在你還在上學的時候,你的父母要你不要看電視,而去複習功課,因而你把本身關在房間裏,作出一副正在複習功課的樣子,其實你在幹着別的諸如給班上的某位女生寫情書之類的事,而一旦你的父母出來在你房間要檢查你是否在複習時,你才真正撿起課本看書。這就是「拖延戰術」,直到你非要作的時候纔去作。
固然,這種事情在現實生活中時每每會出事,但其在編程世界中搖身一變,就成爲了最有用的技術,正如C++中的能夠隨處聲明變量的特色同樣,Scott Meyers推薦咱們,在真正須要一個存儲空間時纔去聲明變量(分配內存),這樣會獲得程序在運行時最小的內存花銷。執行到那纔會去作分配內存這種比較耗時的工做,這會給咱們的程序在運行時有比較好的性能。必竟,20%的程序運行了80%的時間。
固然,拖延戰術還並不僅是這樣一種類型,這種技術被咱們普遍地應用着,特別是在操做系統當中,當一個程序運行結束時,操做系統並不會急着把其清除出內存,緣由是有可能程序還會立刻再運行一次(從磁盤把程序裝入到內存是個很慢的過程),而只有當內存不夠用了,纔會把這些還駐留內存的程序清出。
寫時才拷貝(Copy-On-Write)技術,就是編程界「懶惰行爲」——拖延戰術的產物。舉個例子,好比咱們有個程序要寫文件,不斷地根據網絡傳來的數據寫,若是每一次fwrite或是fprintf都要進行一個磁盤的I/O操做的話,都簡直就是性能上巨大的損失,所以一般的作法是,每次寫文件操做都寫在特定大小的一塊內存中(磁盤緩存),只有當咱們關閉文件時,才寫到磁盤上(這就是爲何若是文件不關閉,所寫的東西會丟失的緣由)。更有甚者是文件關閉時都不寫磁盤,而一直等到關機或是內存不夠時才寫磁盤,Unix就是這樣一個系統,若是非正常退出,那麼數據就會丟失,文件就會損壞。
呵呵,爲了性能咱們須要冒這樣大的風險,還好咱們的程序是不會忙得忘了還有一塊數據須要寫到磁盤上的,因此這種作法,仍是頗有必要的。
二、 標準C++類std::string的Copy-On-Write
在咱們常用的STL標準模板庫中的string類,也是一個具備寫時才拷貝技術的類。C++曾在性能問題上被普遍地質疑和指責過,爲了提升性能,STL中的許多類都採用了Copy-On-Write技術。這種偷懶的行爲的確使使用STL的程序有着比較高要性能。
這裏,我想從C++類或是設計模式的角度爲各位揭開Copy-On-Write技術在string中實現的面紗,以供各位在用C++進行類庫設計時作一點參考。
在講述這項技術以前,我想簡單地說明一下string類內存分配的概念。經過常,string類中必有一個私有成員,其是一個char*,用戶記錄從堆上分配內存的地址,其在構造時分配內存,在析構時釋放內存。由於是從堆上分配內存,因此string類在維護這塊內存上是格外當心的,string類在返回這塊內存地址時,只返回const char*,也就是隻讀的,若是你要寫,你只能經過string提供的方法進行數據的改寫。
2.一、 特性
由表及裏,由感性到理性,咱們先來看一看string類的Copy-On-Write的表面特徵。讓咱們寫下下面的一段程序:
#include
#include
using namespace std;
main()
{
string str1 = "hello world";
string str2 = str1;
printf ("Sharing the memory:\n");
printf ("\tstr1's address: %x\n", str1.c_str() );
printf ("\tstr2's address: %x\n", str2.c_str() );
str1[1]='q';
str2[1]='w';
printf ("After Copy-On-Write:\n");
printf ("\tstr1's address: %x\n", str1.c_str() );
printf ("\tstr2's address: %x\n", str2.c_str() );
return 0;
}
這個程序的意圖就是讓第二個string經過第一個string構造,而後打印出其存放數據的內存地址,而後分別修改str1和str2的內容,再查一下其存放內存的地址。程序的輸出是這樣的(我在VC6.0和g++ 2.95都獲得了一樣的結果):
> g++ -o stringTest stringTest.cpp
> ./stringTest
Sharing the memory:
str1's address: 343be9
str2's address: 343be9
After Copy-On-Write:
str1's address: 3407a9
str2's address: 343be9
2.二、 深刻
在深刻這前,經過上述的演示,咱們應該知道在string類中,要實現寫時才拷貝,須要解決兩個問題,一個是內存共享,一個是Copy-On-Wirte,這兩個主題會讓咱們產生許多疑問,仍是讓咱們帶着這樣幾個問題來學習吧:
一、 Copy-On-Write的原理是什麼?
二、 string類在什麼狀況下才共享內存的?
三、 string類在什麼狀況下觸發寫時才拷貝(Copy-On-Write)?
四、 Copy-On-Write時,發生了什麼?
五、 Copy-On-Write的具體實現是怎麼樣的?
喔,你說只要看一看STL中stirng的源碼你就能夠找到答案了。固然,固然,我也是參考了string的父模板類basic_string的源碼。可是,若是你感到看STL的源碼就好像看機器碼,並嚴重打擊你對C++自信心,乃至產生了本身是否懂C++的疑問,若是你有這樣的感受,那麼仍是繼續往下看個人這篇文章吧。
OK,讓咱們一個問題一個問題地探討吧,慢慢地全部的技術細節都會浮出水面的。
2.三、 Copy-On-Write的原理是什麼?
有必定經驗的程序員必定知道,Copy-On-Write必定使用了「引用計數」,是的,必然有一個變量相似於RefCnt。當第一個類構造時,string的構造函數會根據傳入的參數從堆上分配內存,當有其它類須要這塊內存時,這個計數爲自動累加,當有類析構時,這個計數會減一,直到最後一個類析構時,此時的RefCnt爲1或是0,此時,程序纔會真正的Free這塊從堆上分配的內存。
是的,引用計數就是string類中寫時才拷貝的原理!
不過,問題又來了,這個RefCnt該存在在哪裏呢?若是存放在string類中,那麼每一個string的實例都有各自的一套,根本不能共有一個RefCnt,若是是聲明成全局變量,或是靜態成員,那就是全部的string類共享一個了,這也不行,咱們須要的是一個「民主和集中」的一個解決方法。這是如何作到的呢?呵呵,人生就是一個糊塗後去探知,知道後和又糊塗的循環過程。別急別急,在後面我會給你一一道來的。
2.3.一、 string類在什麼狀況下才共享內存的?
這個問題的答案應該是明顯的,根據常理和邏輯,若是一個類要用另外一個類的數據,那就能夠共享被使用類的內存了。這是很合理的,若是你不用個人,那就不用共享,只有你使用個人,才發生共享。
使用別的類的數據時,無非有兩種狀況,1)以別的類構造本身,2)以別的類賦值。第一種狀況時會觸發拷貝構造函數,第二種狀況會觸發賦值操做符。這兩種狀況咱們均可以在類中實現其對應的方法。對於第一種狀況,只須要在string類的拷貝構造函數中作點處理,讓其引用計數累加;一樣,對於第二種狀況,只須要重載string類的賦值操做符,一樣在其中加上一點處理。
嘮叨幾句:
1)構造和賦值的差異
對於前面那個例程中的這兩句:
string str1 = "hello world";
string str2 = str1;
不要覺得有「=」就是賦值操做,其實,這兩條語句等價於:
string str1 ("hello world"); //調用的是構造函數
string str2 (str1); //調用的是拷貝構造函數
若是str2是下面的這樣狀況:
string str2; //調用參數默認爲空串的構造函數:string str2(「」);
str2 = str1; //調用str2的賦值操做:str2.operator=(str1);
2) 另外一種狀況
char tmp[]=」hello world」;
string str1 = tmp;
string str2 = tmp;
這種狀況下會觸發內存的共享嗎?想固然的,應該要共享。但是根據咱們前面所說的共享內存的狀況,兩個string類的聲明和初始語句並不符合我前述的兩種狀況,因此其並不發生內存共享。並且,C++現有特性也沒法讓咱們作到對這種狀況進行類的內存共享。
2.3.二、 string類在什麼狀況下觸發寫時才拷貝(Copy-On-Write)?
哦,何時會發現寫時才拷貝?很顯然,固然是在共享同一塊內存的類發生內容改變時,纔會發生Copy-On-Write。好比string類的[]、=、+=、+、操做符賦值,還有一些string類中諸如insert、replace、append等成員函數,包括類的析構時。
修改數據纔會觸發Copy-On-Write,不修改固然就不會改啦。這就是託延戰術的真諦,非到要作的時候纔去作。
2.3.三、 Copy-On-Write時,發生了什麼?
咱們可能根據那個訪問計數來決定是否須要拷貝,參看下面的代碼:
If ( RefCnt>0 ) {
char* tmp = (char*) malloc(strlen(_Ptr)+1);
strcpy(tmp, _Ptr);
_Ptr = tmp;
}
上面的代碼是一個假想的拷貝方法,若是有別的類在引用(檢查引用計數來獲知)這塊內存,那麼就須要把更改類進行「拷貝」這個動做。
咱們能夠把這個拷的運行封裝成一個函數,供那些改變內容的成員函數使用。
從結果中咱們能夠看到,在開始的兩個語句後,str1和str2存放數據的地址是同樣的,而在修改內容後,str1的地址發生了變化,而str2的地址仍是原來的。從這個例子,咱們能夠看到string類的Copy-On-Write技術。
2.3.四、 Copy-On-Write的具體實現是怎麼樣的?
最後的這個問題,咱們主要解決的是那個「民主集中」的難題。請先看下面的代碼:
string h1 = 「hello」;
string h2= h1;
string h3;
h3 = h2;
string w1 = 「world」;
string w2(「」);
w2=w1;
很明顯,咱們要讓h一、h二、h3共享同一塊內存,讓w一、w2共享同一塊內存。由於,在h一、h二、h3中,咱們要維護一個引用計數,在w一、w2中咱們又要維護一個引用計數。
如何使用一個巧妙的方法產生這兩個引用計數呢?咱們想到了string類的內存是在堆上動態分配的,既然共享內存的各個類指向的是同一個內存區,咱們爲何不在這塊區上多分配一點空間來存放這個引用計數呢?這樣一來,全部共享一塊內存區的類都有一樣的一個引用計數,而這個變量的地址既然是在共享區上的,那麼全部共享這塊內存的類均可以訪問到,也就知道這塊內存的引用者有多少了。
請看下圖:
因而,有了這樣一個機制,每當咱們爲string分配內存時,咱們老是要多分配一個空間用來存放這個引用計數的值,只要發生拷貝構造但是賦值時,這個內存的值就會加一。而在內容修改時,string類爲查看這個引用計數是否爲0,若是不爲零,表示有人在共享這塊內存,那麼本身須要先作一份拷貝,而後把引用計數減去一,再把數據拷貝過來。下面的幾個程序片斷說明了這兩個動做:
//構造函數(分存內存)
string::string(const char* tmp)
{
_Len = strlen(tmp);
_Ptr = new char[_Len+1+1];
strcpy( _Ptr, tmp );
_Ptr[_Len+1]=0; // 設置引用計數
}
//拷貝構造(共享內存)
string::string(const string& str)
{
if (*this != str){
this->_Ptr = str.c_str(); //共享內存
this->_Len = str.szie();
this->_Ptr[_Len+1] ++; //引用計數加一
}
}
//寫時才拷貝Copy-On-Write
char& string::operator[](unsigned int idx)
{
if (idx > _Len || _Ptr == 0 ) {
static char nullchar = 0;
return nullchar;
}
_Ptr[_Len+1]--; //引用計數減一
char* tmp = new char[_Len+1+1];
strncpy( tmp, _Ptr, _Len+1);
_Ptr = tmp;
_Ptr[_Len+1]=0; // 設置新的共享內存的引用計數
return _Ptr[idx];
}
//析構函數的一些處理
~string()
{
_Ptr[_Len+1]--; //引用計數減一
// 引用計數爲0時,釋放內存
if (_Ptr[_Len+1]==0) {
delete[] _Ptr;
}
}
哈哈,整個技術細節徹底浮出水面。
不過,這和STL中basic_string的實現細節還有一點點差異,在你打開STL的源碼時,你會發現其取引用計數是經過這樣的訪問:_Ptr[-1],標準庫中,把這個引用計數的內存分配在了前面(我給出來的代碼是把引用計數分配以了後面,這很很差),分配在前的好處是當string的長度擴展時,只須要在後面擴展其內存,而不須要移動引用計數的內存存放位置,這又節省了一點時間。
STL中的string的內存結構就像我前面畫的那個圖同樣,_Ptr指着是數據區,而RefCnt則在_Ptr-1 或是 _Ptr[-1]處。
2.四、 臭蟲Bug
是誰說的「有太陽的地方就會有黑暗」?或許咱們中的許多人都很迷信標準的東西,認爲其是久經考驗,不可能出錯的。呵呵,千萬不要有這種迷信,由於任何設計再好,編碼再好的代碼在某一特定的狀況下都會有Bug,STL一樣如此,string類的這個共享內存/寫時才拷貝技術也不例外,並且這個Bug或許還會讓你的整個程序crash掉!
不信?!那麼讓咱們來看一個測試案例:
假設有一個動態連接庫(叫myNet.dll或myNet.so)中有這樣一個函數返回的是string類:
string GetIPAddress(string hostname)
{
static string ip;
……
……
return ip;
}
而你的主程序中動態地載入這個動態連接庫,並調用其中的這個函數:
main()
{
//載入動態連接庫中的函數
hDll = LoadLibraray(…..);
pFun = GetModule(hDll, 「GetIPAddress」);
//調用動態連接庫中的函數
string ip = (*pFun)(「host1」);
……
……
//釋放動態連接庫
FreeLibrary(hDll);
……
cout << ip << endl;
}
讓咱們來看看這段代碼,程序以動態方式載入動態連接庫中的函數,而後以函數指針的方式調用動態連接庫中的函數,並把返回值放在一個string類中,而後釋放了這個動態連接庫。釋放後,輸入ip的內容。
根據函數的定義,咱們知道函數是「值返回」的,因此,函數返回時,必定會調用拷貝構造函數,又根據string類的內存共享機制,在主程序中變量ip是和函數內部的那個靜態string變量共享內存(這塊內存區是在動態連接庫的地址空間的)。而咱們假設在整個主程序中都沒有對ip的值進行修改過。那麼在當主程序釋放了動態連接庫後,那個共享的內存區也隨之釋放。因此,之後對ip的訪問,必然作形成內存地址訪問非法,形成程序crash。即便你在之後沒有使用到ip這個變量,那麼在主程序退出時也會發生內存訪問異常,由於程序退出時,ip會析構,在析構時就會發生內存訪問異常。
內存訪問異常,意味着兩件事:1)不管你的程序再漂亮,都會由於這個錯誤變得暗淡無光,你的聲譽也會由於這個錯誤受到損失。2)將來的一段時間,你會被這個系統級錯誤所煎熬(在C++世界中,找到並排除這種內存錯誤並非一件容易的事情)。這是C/C++程序員永遠的心頭之痛,千里之堤,潰於蟻穴。而若是你不清楚string類的這種特徵,在成千上萬行代碼中找這樣一個內存異常,簡直就是一場噩夢。
備註:要改正上述的Bug,有不少種方法,這裏提供一種僅供參考:
string ip = (*pFun)(「host1」).cstr();
三、 後記
文章到這裏也應該結束了,這篇文章的主要有如下幾個目的:
1) 向你們介紹一下寫時才拷貝/內存共享這種技術。
2) 以STL中的string類爲例,向你們介紹了一種設計模式。
3) 在C++世界中,不管你的設計怎麼精巧,代碼怎麼穩固,都難以照顧到全部的狀況。智能指針更是一個典型的例子,不管你怎麼設計,都會有很是嚴重的BUG。
4) C++是一把雙刃劍,只有瞭解了原理,你才能更好的使用C++。不然,必將引火燒身。若是你在設計和使用類庫時有一種「玩C++就像玩火,必須千萬當心」的感受,那麼你就入門了,等你能把這股「火」控制的駕輕就熟時,那纔是學成了。
最後,仍是利用這個後序,介紹一下本身。我目前從事於全部Unix平臺下的軟件研發,主要是作系統級的產品軟件研發,對於下一代的計算機革命——網格計算很是地感興趣,同於對於分佈式計算、P2P、Web Service、J2EE技術方向也很感興趣,另外,對於項目實施、團隊管理、項目管理也小有心得,但願一樣和我戰鬥在「技術和管理並重」的陣線上的年輕一代,可以和我多多地交流。個人MSN和郵件是:haoel@hotmail.com。程序員