盡管在 .net framework 中 我們不太需要關注記憶體管理和垃圾回收這方面的問題,但是出于提高我們應用程式性能的目的,在我們的腦子裡還是需要有這方面的意識。明白記憶體管理的基本行為 将有助于我們解釋我們程式中變量是如何操作的。在本文中我将讨論棧和堆的一些基本知識,變量的類型和某些變量的工作原理。
當你在執行程式的時候記憶體中有兩個地方用于存儲程式變量。如果你還不知道,那麼就來看看堆和棧的概念。堆和棧都是用于幫助我們程式運作的,包含某些特殊資訊的作業系統記憶體子產品。那麼堆和棧有什麼不同呢?
堆 VS 棧的差別
棧 主要用于存儲代碼,自動變量等資訊;而堆則主要用于存儲運作期生成的對象等資訊。将棧看作是一個有着層級關系的盒子,我們每一次隻能操作盒子最上一格的東 西。這也就是棧先進後出的資料結構特性。是以棧在我們程式中主要是用于儲存程式運作時的一些狀态資訊。堆則主要是用于儲存對象内容,以便我們能夠在任何時 候去通路這些對象。總的來說,堆就是一種資料結構,我們不需要通過一套規則,可以随時通路的記憶體區域;棧則總是依據先進後出的,每次隻能通路最頂層元素的 記憶體區域。下面是個示意圖:
由于棧的特性所至,是以棧具有自我維護性,棧的記憶體管理可以通過作業系統來完成。而堆的管理就需要通過 GC (垃圾回收器)來完成,使用一定的算法來掃描并釋放沒有用的對象。
關于棧和堆的更多内容
我們代碼中有四種主要的類型需要存儲在棧和堆當中:值類型,引用類型,指針和程式指令。
值類型:
在 c# 中主要的值類型有:
bool , byte , char , decimal , double , enum , float , int , long , sbyte , short , struct , uint , ulong , ushort 都來自于 System.TypeValue 。
引用類型:
在 C# 中主要的引用類型有:
class , interface , delegate , object , string 所有的引用類型都繼承自 System.Object 。
指針:
在我們的記憶體管理中一個指針的意義就是一個引用對應到一個類型上。在 .net framework 中我們不能顯式的使用指針,所有的指針都被通用語言運作時 (CLR) 管理。指針是一塊指向其他記憶體區域的記憶體區域。指針需要占據一定的記憶體空間就像其他任何資料一樣。
指令:
指令就是計算機執行代碼,如函數調用或是資料運算等。
内容和位址的問題
首先有兩點需要說明:
1. 引用類型總是存在于堆裡 – 很簡單,但是完全正确嗎?
2. 值類型和指針總是出現在他們聲明的地方。這個有點複雜需要相關的棧工作原理的知識。
棧就像我們之前提到的那樣,記錄我們程式執行時的一些資訊。當我們在調用一個類的方法時,作業系統将調用指令壓棧并附帶方法參數。然後進入函數體處理變量操作。這個可以用下面的代碼來解釋:
public int AddFive( int pValue) {
int result;
result = pValue + 5;
return result;
}
這個操作發生在棧的頂部,請注意我們看到已經有很多成員之前被壓入到棧中了。首先是方法的本身先被壓入棧中,緊接着是參數入棧。
然後是通過 AddFive()裡面的指令來執行函數。
函數執行的結果同樣也需要配置設定一些記憶體來存放,而這些記憶體也配置設定在棧中。
函數執行結束後,就要将結果傳回。
最後,通過删除 AddFive()的指針來清除所有之前棧中有關于函數運作時配置設定的記憶體。并繼續下一個函數(可能之前就存在在棧中)。
在 這個例子中,我們的結果存儲在棧中。事實上,所有函數體内的值類型聲明都會配置設定到棧中。但是現在有些值類型也被配置設定在堆中。記住一個規則,值類型總是出現 在聲明它們的地方。如果一個值類型聲明在函數體外,但是存于一個引用類型内,那麼它将跟這個引用類型一樣位于堆中。這裡用另外的一個例子來說明這個問題:
public class MyInt {
public int MyValue;
}
public MyInt AddFive( int pValue) {
MyInt result = new MyInt();
result.MyValue = pValue + 5;
return result;
}
現在這個函數的執行跟先前的有了點不同。這裡的函數傳回是一個 MyInt 類對象,也就是說是一個引用類型。引用類型是被配置設定在堆中的,而引用的指針是配置設定在棧中。
在 AddFive() 函數執行結束後,我們将清理棧中的記憶體。
在 這裡我們看到除了棧中有資料,在堆中也有一些資料。而堆中的資料将被垃圾回收器回收。當我們的程式需要一塊記憶體并且已經沒有空閑的記憶體可以配置設定時,垃圾回 收器開始運作。垃圾回收器會先停止所有運作中的線程,掃描堆中的所有對象并删除那些沒有被主程式通路的對象。垃圾回收器将重新組織堆中的所有空閑的空間, 并調整所有棧中和堆中的相關指針。就像你能想到的那樣,這樣的操作會非常的影響效率。是以這也是為什麼我們要強調編寫高性能的代碼。好,那我要怎麼樣去做 呢?
當我們在操作一個引用類型的時候,我們操作的是它的指針而不是它本身。當我們使用值類型的時候我們使用的是它本身,這個很明顯。我們看一下代碼:
public int ReturnValue() {
int x = new int ();
x = 3;
int y = new int ();
y = x;
y = 4;
return x;
}
這段代碼很簡單,傳回 3 。但是如果我們改用引用類型 MyInt 類,結果可能不同:
public class MyInt {
public int MyValue;
}
public int ReturnValue2() {
MyInt x = new MyInt();
x.MyValue = 3;
MyInt y = new MyInt();
y = x;
y.MyValue = 4;
return x.MyValue;
}
這裡的傳回值卻是 4 。為什麼呢? 想象一下,我們之前講的内容,我們在操作值類型資料的時候隻是操作該值的一個副本。而在操作引用類型資料的時候,我們操作的是該類型的指針,是以 y = x 就修改了 y 的指針内容,進而使得 y 也指向了 x 那一部分棧空間。是以 y.MyValue = 4 => x.MyValue = 4 。是以傳回值會是 4 。
參數
當我們開始調用一個方法的時候,發生了什麼呢?
1. 在棧中配置設定我們方法所需的空間,包括回調的指針空間,該指針通過一條 goto 指令來回到函數調用開始的那個棧位置的下一個位置,以便繼續執行。
2. 我們方法的參數将被拷貝過來。
3. 控制器通過 JIT 方法和線程開始執行代碼,是以我們有了另外一個稱呼叫調棧。
代碼如下:
public int AddFive(int pValue){
int result;
result = pValue + 5;
return result;
}
棧的結構模式:
參數在棧中的位置取決于它的類型,值類型本身被拷貝而引用類型的引用被拷貝。
傳遞值類型參數
當我們傳遞一個值類型參數時,記憶體先被配置設定然後是值被拷貝到棧中。代碼如下:
class Class1 {
public void Go () {
int x = 5;
AddFive(x);
Console.WriteLine(x.ToString());
}
public int AddFive ( int pValue) {
pValue += 5;
return pValue;
}
}
AddFive 方法被執行, x 位置變成 5
當 AddFive() 方法執行結束後,線程回到執行 go 方法, pValue 将被删除。
是以當我們在傳遞一個很大的值類型的時候,程式會逐位的拷貝到棧中,這很明顯就是效率很低。更何況我們的程式如果要傳遞這個值數千次的進行,那麼效率就更低。
這時我們就要用到引用類型來解決這樣的問題。
public void Go() {
MyStruct x = new MyStruct();
DoSomething( ref x);
}
public struct MyStruct {
long a, b, c, d, e, f, g, h, i, j, k, l, m;
}
public void DoSomething( ref MyStruct pValue) {
// DO SOMETHING HERE....
}
這種方法就更有效的進行操作記憶體,其實我們并不需要拷貝這塊記憶體。
當我們傳遞的是值類型的引用,那麼程式修改這個引用的内容都會直接反映到這個值上。
傳遞引用類型
傳遞引用類型參數有點類似于前面的傳遞值類型的引用。
public class MyInt {
public int MyValue;
}
public void Go() {
MyInt x = new MyInt();
x.MyValue = 2;
DoSomething(x);
Console.WriteLine(x.MyValue.ToString());
}
public void DoSomething(MyInt pValue) {
pValue.MyValue = 12345;
}
這段代碼做了如下工作:
1. 開始調用 go() 方法讓 x 變量進棧。
2. 調用 DoSomething() 方法讓參數 pValue 進棧
3. 然後 x 值拷貝到 pValue
這裡有一個有趣的問題是,如果傳遞一個引用類型的引用又會發生什麼呢?
如果我們有兩類:
public class Thing {
}
public class Animal : Thing {
public int Weight;
}
public class Vegetable : Thing {
public int Length;
}
我們要執行 go() 的方法,如下:
public void Go () {
Thing x = new Animal();
Switcharoo( ref x);
Console.WriteLine(
"x is Animal : "
+ (x is Animal).ToString());
Console.WriteLine(
"x is Vegetable : "
+ (x is Vegetable).ToString());
}
public void Switcharoo ( ref Thing pValue) {
pValue = new Vegetable();
}
X 的輸出結果:
x is Animal : False
x is Vegetable : True
為什麼是這樣的結果呢?我們來看一下程式過程:
如果我們沒傳遞 Thing 對象的引用,那麼我們将得到相反的結果。
拷貝和不拷貝
首先我們檢視值類型,請使用下面的類和結構體。我們擁有一個 Dude 類包含個 Name 元素和 2 個 Shoe 。我們還有一個 CopyDude() 方法去産生一個新的 Dude 對象。
public struct Shoe {
public string Color;
}
public class Dude {
public string Name;
public Shoe RightShoe;
public Shoe LeftShoe;
public Dude CopyDude () {
Dude newPerson = new Dude();
newPerson.Name = Name;
newPerson.LeftShoe = LeftShoe;
newPerson.RightShoe = RightShoe;
return newPerson;
}
public override string ToString () {
return (Name + " : Dude!, I have a " + RightShoe.Color +
" shoe on my right foot, and a " +
LeftShoe.Color + " on my left foot." );
}
}
Dude 類是一個引用類型并且因為 Shoe 結構是類的一個成員,是以它們都被配置設定到堆中。
運作下面的程式:
public static void Main () {
Class1 pgm = new Class1();
Dude Bill = new Dude();
Bill.Name = "Bill" ;
Bill.LeftShoe = new Shoe();
Bill.RightShoe = new Shoe();
Bill.LeftShoe.Color = Bill.RightShoe.Color = "Blue" ;
Dude Ted = Bill.CopyDude();
Ted.Name = "Ted" ;
Ted.LeftShoe.Color = Ted.RightShoe.Color = "Red" ;
Console.WriteLine(Bill.ToString());
Console.WriteLine(Ted.ToString());
}
我們将得到如下的輸出:
Bill : Dude!, I have a Blue shoe on my right foot, and a Blue on my left foot.
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot.
那麼我們将 Shoe 聲明為一個引用類型又會産生什麼結果呢?
public class Shoe {
public string Color;
}
再次運作 main ()函數, 我們得到的結果是:
Bill : Dude!, I have a Red shoe on my right foot, and a Red on my left foot
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot
明顯 Red shoe 在 Bill 的腳上是錯誤的。為什麼會這樣呢?看一下圖
因為 我們使用 Shoe 作為一個引用類型來取代值類型。當一個引用被拷貝的時候,隻拷貝了其指針,是以我們不得不做一些額外的工作來確定我們的引用類型看起來更像是值類型。
幸運的是我們擁有一個名為 ICloneable 接口可以幫助我們。這個接口基于一個契約,所有的 Dude 對象都将定義一個引用類型如何被複制以確定我們的 Shoe 不會發生共享錯誤。我們所有的類都可以使用 ICloneable 接口的 clone 方法來複制類對象。
public class Shoe : ICloneable {
public string Color;
#region ICloneable Members
public object Clone () {
Shoe newShoe = new Shoe();
newShoe.Color = Color.Clone() as string ;
return newShoe;
}
#endregion
}
在 Clone() 方法内我們建立了一個 Shoe ,拷貝所有引用類型并拷貝所有值類型并傳回一個新的對象執行個體。你可能注意到 string 類已經實作了 ICloneable 接口,是以我們可以調用 Color.Clone() 。因為 Clone() 傳回的是一個對象的引用,我們不得不進行類型轉換在我們設定 Shoe 的 Color 前。
接下來,我們用 CopyDude() 方法去克隆 shoe 。
public Dude CopyDude () {
Dude newPerson = new Dude();
newPerson.Name = Name;
newPerson.LeftShoe = LeftShoe.Clone() as Shoe;
newPerson.RightShoe = RightShoe.Clone() as Shoe;
return newPerson;
}
public static void Main () {
Class1 pgm = new Class1();
Dude Bill = new Dude();
Bill.Name = "Bill" ;
Bill.LeftShoe = new Shoe();
Bill.RightShoe = new Shoe();
Bill.LeftShoe.Color = Bill.RightShoe.Color = "Blue" ;
Dude Ted = Bill.CopyDude();
Ted.Name = "Ted" ;
Ted.LeftShoe.Color = Ted.RightShoe.Color = "Red" ;
Console.WriteLine(Bill.ToString());
Console.WriteLine(Ted.ToString());
}
重新運作程式,我們将得到如下輸出:
Bill : Dude!, I have a Blue shoe on my right foot, and a Blue on my left foot
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot
圖示如下:
包裝實體
一般來說,我們總是想克隆一個引用類型和拷貝一個值類型。記住這點将有助于你解決調試時發生的錯誤。讓我們更進一步分析并清理一下 Dude 類實作,使用 ICloneable 接口來代替 CopyDude() 方法。
public class Dude : ICloneable {
public string Name;
public Shoe RightShoe;
public Shoe LeftShoe;
public override string ToString () {
return (Name + " : Dude!, I have a " + RightShoe.Color +
" shoe on my right foot, and a " +
LeftShoe.Color + " on my left foot." );
}
#region ICloneable Members
public object Clone () {
Dude newPerson = new Dude();
newPerson.Name = Name.Clone() as string ;
newPerson.LeftShoe = LeftShoe.Clone() as Shoe;
newPerson.RightShoe = RightShoe.Clone() as Shoe;
return newPerson;
}
#endregion
}
我們再來修改 Main() 中的方法:
public static void Main () {
Class1 pgm = new Class1();
Dude Bill = new Dude();
Bill.Name = "Bill" ;
Bill.LeftShoe = new Shoe();
Bill.RightShoe = new Shoe();
Bill.LeftShoe.Color = Bill.RightShoe.Color = "Blue" ;
Dude Ted = Bill.Clone() as Dude;
Ted.Name = "Ted" ;
Ted.LeftShoe.Color = Ted.RightShoe.Color = "Red" ;
Console.WriteLine(Bill.ToString());
Console.WriteLine(Ted.ToString());
}
最後,運作我們的程式,會得到如下的輸出:
Bill : Dude!, I have a Blue shoe on my right foot, and a Blue on my left foot.
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot.
還有些比較有意思的東西,比如 System.String 重載的操作符 = 号就實作了 clones 方法,是以你不用過于擔心 string 類的引用複制問題。但是你要注意記憶體的消耗問題。如果你仔細檢視上圖,由于 string 是引用類型是以需要一個指針指向堆中的另一個對象,但是看起來它像是一個值類型。
圖
接下來讓我們從垃圾回收器的角度來看一下記憶體管理。如果我們想清理一下沒用的東西我們可能需要計劃一下怎麼做才更有效率。很明顯,我們需要先區分什麼是垃圾,什麼不是垃圾。那麼我們要先做一個假設:任何東西如果沒有用了那麼就認為是垃圾。幸好我們身邊有兩位好朋友 : 即時編譯器( JIT )和統一語言運作時( CLR )。 JIT 和 CLR 保持着一個清單關于它們正在使用的對象。我們将使用這個清單作為起始清單。我們将保持關于所有正在使用的對象到一個圖表中。所有的活動的對象都将被添加到這個圖表裡。
這也是垃圾回收器所作的事情,從即時編譯器和統一語言運作時那裡得到一份關于所有根對象的引用清單。然後遞歸的查找活動對象的引用去建立一個圖表。
根的組成如下:
l 全局 / 靜态指針。一種方法确定我們的對象不會被垃圾回收通過保持他們的引用在一個靜态變量裡。
l 棧内指針。我們不想抛棄那些我們應用程式需要使用的東西。
l CPU 寄存器指針。在托管堆裡的任何被 CPU 寄存器的記憶體位址指向的對象都應該保留。
在上圖當中,對象 1 和 5 都被 roots 直接引用,而對象 3 則在遞歸搜尋中被發現被 1 引用。如果我們進行類比,那麼對象 1 是可以看成遙控器,而對象 3 被看成遠端的裝置。當所有對象都進入圖表中後,我們就進行下一步分析。
調整堆
現在我們已經将我們的要保留的對象加到圖表中,現在我們可以分析一下這些東西。
由于對象 2 是不需要的,是以就像垃圾回收器那樣,我們下移對象 3 并修改對象 1 的指針。
然後我們在将對象 5 下移。
現在我們已經将托管堆進行了緊縮調整,為新來的對象騰出空間。
知道垃圾回收器的工作原理就知道移動對象的工作是很繁重的。從這裡看出如果我們減少移動對象的大小就能提高垃圾回收器的工作效率,因為減少了拷貝内容。
托管堆之外
有時候垃圾回收器需要執行代碼去清理非托管的資源諸如檔案,資料庫連接配接,網絡連接配接等等。一種有效的控制這些内容的方式是終結器( finalizer )。
class Sample {
~Sample () {
// FINALIZER: CLEAN UP HERE
}
}
當對象在建立的時候,所有對象附帶的終結器 (finalizer) 都會添加到終結隊列裡。我們可以說圖中的對象 1,4,5 擁有終結器 (finalizer) 并都處于終結隊列中。讓我們看一下當對象 2 和 4 在沒有被應用程式引用并且垃圾回收器準備好的情況下會發生什麼。
圖裡對象 2 被作為無用對象處理。但是,當我們處理對象 4 的時候,垃圾回收器會先檢視它的終結隊列并重新聲明對象 4 所擁有的記憶體,對象 4 被移動并且它的終結器 (finalizer) 被添加到一個特殊的隊列 - freachable 。
這裡有專門的線程去處理 freachable 隊列的成員。一旦對象 4 的終結器被線程執行,那麼它就會從 freachable 隊列中移除。然後對象 4 就可以被回收了。
而對象 4 在下一次回收開始前仍然存在。
在建立對象時添加終結器 (finalizer) 是垃圾回收器的一個額外工作。它要花費很高的代價并且嚴重影響垃圾回收器和我們的應用程式的性能。是以請确定在絕對必要的情況下再使用終結器 (finalizer) 。
有更好的方案用作清理非托管資源。就像你想的那樣,我們可以使用 IDisposable 接口取代終結器 (finalizer) 去關閉資料庫連結并清理資源。
IDisposible
使用 IDisposable 接口的 Dispose() 方法做清理工作。是以如果我們有一個 ResouceUser 的類使用到了終結器 (finalizer) ,如下 :
public class ResourceUser {
~ResourceUser () // THIS IS A FINALIZER
{
// DO CLEANUP HERE
}
}
那麼我們可以使用 IDisposable 來實驗同樣的功能:
public class ResourceUser : IDisposable {
#region IDisposable Members
public void Dispose () {
// CLEAN UP HERE!!!
}
#endregion
}
IDisposable 已經被內建到了關鍵字中。在 using() 的最後 Dispose() 的代碼塊會被調用。對象不應該在 Dispose() 的代碼塊後被引用,因為它被标上了 ”gone” 并且準備被垃圾回收器回收。
public static void DoSomething () {
ResourceUser rec = new ResourceUser();
using (rec) {
// DO SOMETHING
} // DISPOSE CALLED HERE
// DON'T ACCESS rec HERE
}
我喜歡把代碼放在 using 塊内,這樣所有的變量和資源在塊結束後回被自動回收(主要是因為 using 關鍵字擴充了後是 try … finally …, 而所有的具有 IDisposable 接口的對象的 Dispose() 方法會在 finally 的代碼塊中被自動調用)。
public static void DoSomething () {
using (ResourceUser rec = new ResourceUser()) {
// DO SOMETHING
} // DISPOSE CALLED HERE
}
通過實作類的 IDisposible 接口,這樣我們可以在垃圾回收器前通過強制方式釋放我們的對象。
謹防靜态變量
class Counter {
private static int s_Number = 0;
public static int GetNextNumber () {
int newNumber = s_Number;
// DO SOME STUFF
s_Number = newNumber + 1;
return newNumber;
}
}
如果同時有兩個線程同時調用 GetNextNumber() 方法并同時為 newNumber 配置設定同樣的變量在 s_Num 前。
那麼兩個線程同時将得到同樣的傳回值。為了解決這個問題,你需要去鎖定一部分的代碼塊,使得競争線程進入一個等待隊列但是這樣會降低效率。
class Counter {
private static int s_Number = 0;
public static int GetNextNumber () {
lock ( typeof (Counter)) {
int newNumber = s_Number;
// DO SOME STUFF
newNumber += 1;
s_Number = newNumber;
return newNumber;
}
}
}
謹防靜态變量 2
接下來我們要關注引用類型的靜态變量。記住,任何被根引用的對象都不能被清除。下面是一段代碼:
class Olympics {
public static Collection<Runner> TryoutRunners;
}
class Runner {
private string _fileName;
private FileStream _fStream;
public void GetStats () {
FileInfo fInfo = new FileInfo(_fileName);
_fStream = _fileName.OpenRead();
}
}
因為 Collection 是存儲 Olympics 類的靜态集合,是以集合内的對象不會被垃圾回收器釋放(因為它們都被 root 間接引用)。但是你可能要注意,每一次我們都要運作 GetStats() 來擷取被打開檔案流的狀态。因為它們不能被關閉也不能被垃圾回收器釋放而一直等待在那。想象一下我們如果有 100000 這樣的對象存在,那麼程式的性能就變得有多差。
單件
通過某種方式我們可以永久的保持一個對象執行個體在記憶體中。我們通過使用單件模式來實作。
單件可以看成是一個全局變量并且它會帶來很多頭疼的問題和奇怪的行為在多線程應用程式中。如果我們使用單模式,那麼我們要進行适當的調整。
public class Earth {
private static Earth _instance = new Earth();
private Earth() { }
public static Earth GetInstance() { return _instance; }
}
我們擁有一個私有的構造器是以使用者隻能通過靜态的 GetInstance() 方法來擷取一個 Earth 執行個體。這是一個比較經典的線程安全實作,因為 CLR 會去建立安全的靜态變量。這也是 c# 中我發現的最優雅的單件實作模式。
總結
1. 不要留下打開的資源!明确關閉所有連接配接和清理所有非托管資源。一個通用的規則在 using 塊内使用非托管資源。
2. 不要過度的使用引用。當我們的對象活着,那麼所有相關的引用對象将不會被回收。當我們操作了引用類的一些屬性後,我們需要明确的将引用變量設定為 null 。以便垃圾回收器回收這些對象。
3. 使用終結器 (finalizer) 使工作更容易,但是是在必須的情況下。終結器 (finalizer) 需要花費垃圾回收器的昂貴的代價,是以必須在必要的時候使用它。一個更好的方案是使用 IDisposible 接口來取代終結器 (finalizer) 。這樣做會使垃圾回收器工作的更有效率。
4. 将對象和它們的孩子保持在一起。這樣使得垃圾回收器更容易去産生大塊記憶體而不用去收集托管堆上的每一個零散的記憶體。是以當我們聲明一個對象由多個其他對象組合成的時候,我們應該顯示的将它們安排的緊密一些。