Index :
(1)類型語法、內存管理和垃圾回收基礎
(3)字符串、集合與流
(4)委托、事件、反射與特性
(5)多線程開發基礎
一、基礎類型和語法
1.1 .NET中所有類型的基類是什么?
在.NET中所有的內建類型都繼承自System.Object類型。在C#中,不需要顯示地定義類型繼承自System.Object,編譯器將自動地自動地為類型添加上這個繼承申明,以下兩行代碼的作用完全一致:
public class A { } public class A : System.Object { }
1.2 值類型和引用類型的區別?
在.NET中的類型分為值類型和引用類型,它們各有特點,其共同點是都繼承自System.Object,但最明顯的區分標准卻是是否繼承自System.ValueType(System.ValueType繼承自System.Object),也就是說所有繼承自System.ValueType的類型是值類型,而其他類型都是引用類型。常用的值類型包括:結構、枚舉、整數型、浮點型、布爾型等等;而在C#中所有以class關鍵字定義的類型都是引用類型。
PS:嚴格來講,System.Object作為所有內建類型的基類,本身並沒有值類型和引用類型之分。但是System.Object的對象,具有引用類型的特點。這也是值類型在某些場合需要裝箱和拆箱操作的原因。
(1)賦值時的區別
這是值類型與引用類型最顯著的一個區別:值類型的變量直接將獲得一個真實的數據副本,而對引用類型的賦值僅僅是把對象的引用賦給變量,這樣就可能導致多個變量引用到一個對象實例上。
(2)內存分配的區別
引用類型的對象將會在堆上分配內存,而值類型的對象則會在堆棧上分配內存。堆棧空間相對有限,但是運行效率卻比堆高很多。
(3)繼承結構的區別
由於所有的值類型都有一個共同的基類System.ValueType,因此值類型具有了一些引用類型所不具有的共同性質,比較重要的一點就是值類型的比較方法:Equals。所有的值類型已經實現了內容的比較(而不再是引用地址的比較),而引用類型沒有重寫Equals方法還是采用引用比較。
1.3 裝箱和拆箱的原理?
(1)裝箱:CLR需要做額外的工作把堆棧上的值類型移動到堆上,這個操作就被稱為裝箱。
(2)拆箱:裝箱操作的反操作,把堆中的對象復制到堆棧中,並且返回其值。
裝箱和拆箱都意味着堆和堆棧空間的一系列操作,毫無疑問,這些操作的性能代價是很大的,尤其對於堆上空間的操作,速度相對於堆棧的操作慢得多,並且可能引發垃圾回收,這些都將大規模地影響系統的性能。因此,我們應該避免任何沒有必要的裝箱和拆箱操作。
如何避免呢,首先分析裝箱和拆箱經常發生的場合:
①值類型的格式化輸出
②System.Object類型的容器
對於第①種情況,我們可以通過下面的改動示例來避免:
int i = 10; Console.WriteLine("The value is {0}", i.ToString());
對於第②種情況,則可以使用泛型技術來避免使用針對System.Object類型的容器,有效避免大規模地使用裝箱和拆箱:
ArrayList arrList = new ArrayList(); arrList.Add(0); arrList.Add("1"); // 使用泛型數據結構代替ArrayList List<int> intList = new List<int>(); intList.Add(1); intList.Add(2);
1.4 struct和class的區別,struct適用於哪些場合?
首先,struct(結構)是值類型,而class(類)是引用類型,所有的結構對象都分配在堆棧上,而所有的類對象都分配在堆上。
其次,struct與class相比,不具備繼承的特性,struct雖然可以重寫定義在System.Object中的虛方法,但不能定義新的虛方法和抽象方法。
最后,struct不能有無參數的構造方法(class默認就有),也不能為成員變量定義初始值。
public struct A { public int a = 1; // 這里不能編譯通過 }
結構對象在構造時必須被初始化為0,構造一個全0的對象是指在內存中為對象分配一個合適的空間,並且把該控件置為0。
如何使用struct or class?當一個類型僅僅是原始數據的集合,而不需要復雜的操作時,就應該設計為struct,否則就應該設計為一個class。
1.5 C#中方法的參數傳遞有哪幾種方式?
(1)ref關鍵字:引用傳遞參數,需要在傳遞前初始化;(ref 要求參數在傳入前被初始化)
(2)out關鍵字:引用傳遞參數,需要在返回前初始化;(out 要求參數在方法返回前被初始化)
ref和out這兩個關鍵字的功能極其類似,都用來說明該參數以引用方式進行傳遞。大家都知道,.NET的類型分為引用類型和值類型,當一個方法參數是引用類型時,傳遞的本質就是對象的引用。所以,這兩個關鍵字的作用都發生在值類型上。
(3)params關鍵字:允許方法在定義時不確定參數的數量,這種形式非常類似數組參數,但形式更加簡潔易懂。
But,params關鍵字的使用也有一定局限:當一個方法申明了一個params參數后,就不允許在其后面再有任何其他參數。
例如下面一段代碼,定義了兩個完全相等的方法:NotParams和UseParams,使用由params修飾參數的方法時,可以直接把所有變量集合傳入而無須先申明一個數組對象。

class Program { static void Main(string[] args) { // params string s = "I am a string"; int i = 10; double f = 2.3; object[] par = new object[3] { s, i, f }; // not use params NotParams(par); // use params UseParams(s, i, f); Console.ReadKey(); } // Not use params public static void NotParams(object[] par) { foreach (var obj in par) { Console.WriteLine(obj); } } // Use params public static void UseParams(params object[] par) { foreach (var obj in par) { Console.WriteLine(obj); } } }
1.6 淺復制和深復制的區別?
(1)淺復制:復制一個對象的時候,僅僅復制原始對象中所有的非靜態類型成員和所有的引用類型成員的引用。(新對象和原對象將共享所有引用類型成員的實際對象)
(2)深復制:復制一個對象的時候,不僅復制所有非靜態類型成員,還要復制所有引用類型成員的實際對象。
下圖展示了淺復制和深復制的區別:
在.NET中,基類System.Object已經為所有類型都實現了淺復制,類型所要做的就是公開一個復制的接口,而通常的,這個接口會由ICloneable接口來實現。ICloneable只包含一個方法Clone,該方法既可以被實現為淺復制也可以被實現為深復制,具體如何取舍則根據具體類型的需求決定。此外,在Sys-tem.Object基類中,有一個保護的MemeberwiseClone()方法,它便用於進行淺度復制。所以,對於引用類型,要想實現淺度復制時,只需要調用這個方法就可以了:
public object Clone() { return MemberwiseClone(); }
下面的代碼展示了一個使用ICloneable接口提供深復制的簡單示例:

public class DeepCopy : ICloneable { public int i = 0; public A a = new A(); public object Clone() { // 實現深復制-方式1:依次賦值和實例化 DeepCopy newObj = new DeepCopy(); newObj.a = new A(); newObj.a.message = this.a.message; newObj.i = this.i; return newObj; } public new object MemberwiseClone() { // 實現淺復制 return base.MemberwiseClone(); } public override string ToString() { string result = string.Format("I的值為{0},A為{1}", this.i.ToString(), this.a.message); return result; } } public class A { public string message = "我是原始A"; } public class Program { static void Main(string[] args) { DeepCopy dc = new DeepCopy(); dc.i = 10; dc.a = new A(); DeepCopy deepClone = dc.Clone() as DeepCopy; DeepCopy shadowClone = dc.MemberwiseClone() as DeepCopy; // 深復制的目標對象將擁有自己的引用類型成員對象 deepClone.a.message = "我是深復制的A"; Console.WriteLine(dc); Console.WriteLine(deepClone); Console.WriteLine(); // 淺復制的目標對象將和原始對象共享引用類型成員對象 shadowClone.a.message = "我是淺復制的A"; Console.WriteLine(dc); Console.WriteLine(shadowClone); Console.ReadKey(); } }
其執行結果如下圖所示,可以清楚地看到對深復制對象的屬性的賦值不會影響原始對象,而淺復制則相反。
從上面的代碼中可以看到,在深復制的實現中,如果每個對象都要這樣去進行深度復制就太麻煩了,可以利用序列化/反序列化來對對象進行深度復制:先把對象序列化(Serialize)到內存中,然后再進行反序列化,通過這種方式來進行對象的深度復制:

[Serializable] public class DeepCopy : ICloneable { ...... public object Clone() { // 實現深復制-方式1:依次賦值和實例化 //DeepCopy newObj = new DeepCopy(); //newObj.a = new A(); //newObj.a.message = this.a.message; //newObj.i = this.i; //return newObj; // 實現深復制-方式2:序列化/反序列化 BinaryFormatter bf = new BinaryFormatter(); MemoryStream ms = new MemoryStream(); bf.Serialize(ms, this); ms.Position = 0; return bf.Deserialize(ms); } ...... } [Serializable] public class A { public string message = "我是原始A"; }
PS:一般可被繼承的類型應該避免實現ICloneable接口,因為這樣做將強制所有的子類型都需要實現ICloneable接口,否則將使類型的深復制不能覆蓋子類的新成員。
二、內存管理和垃圾回收
2.1 .NET中棧和堆的差異?
每一個.NET應用程序最終都會運行在一個OS進程中,假設這個OS的傳統的32位系統,那么每個.NET應用程序都可以擁有一個4GB的虛擬內存。.NET會在這個4GB的虛擬內存塊中開辟三塊內存作為 堆棧、托管堆 以及 非托管堆。
(1).NET中的堆棧
堆棧用來存儲值類型的對象和引用類型對象的引用(地址),其分配的是一塊連續的地址,如下圖所示,在.NET應用程序中,堆棧上的地址從高位向低位分配內存,.NET只需要保存一個指針指向下一個未分配內存的內存地址即可。
對於所有需要分配的對象,會依次分配到堆棧中,其釋放也會嚴格按照棧的邏輯(FILO,先進后出)依次進行退棧。(這里的“依次”是指按照變量的作用域進行的),假設有以下一段代碼:
TempClass a = new TempClass(); a.numA = 1; a.numB = 2;
其在堆棧中的內存圖如下圖所示:
這里TempClass是一個引用類型,擁有兩個整型的int成員,在棧中依次需要分配的是a的引用,a.numA和a.numB。當a的作用域結束之后,這三個會按照a.numB→a.numA→a的順序依次退棧。
(2).NET中的托管堆
眾所周知,.NET中的引用類型對象時分配在托管堆上的,和堆棧一樣,托管堆也是進程內存空間中的一塊區域。But,托管堆的內存分配卻和堆棧有很大區別。受益於.NET內存管理機制,托管堆的分配也是連續的(從低位到高位),但是堆中卻存在着暫時不能被分配卻已經無用的對象內存塊。
當一個引用類型對象被初始時,會通過指向堆上可用空間的指針分配一塊連續的內存,然后使堆棧上的引用指向堆上剛剛分配的這塊內存塊。下圖展示了托管堆的內存分配方式:
如上圖所示,.NET程序通過分配在堆棧中的引用來找到分配在托管堆的對象實例。當堆棧中的引用退出作用域時,這時僅僅就斷開和實際對象實例的引用聯系。而當托管堆中的內存不夠時,.NET會開始執行GC(垃圾回收)機制。GC是一個非常復雜的過程,它不僅涉及托管堆中對象的釋放,而且需要移動合並托管堆中的內存塊。當GC之后,堆中不再被使用的對象實例才會被部分釋放(注意並不是完全釋放),而在這之前,它們在堆中是暫時不可用的。在C/C++中,由於沒有GC,因此可以直接free/delete來釋放內存。
(3).NET中的非托管堆
.NET程序還包含了非托管堆,所有需要分配堆內存的非托管資源將會被分配到非托管堆上。非托管的堆需要程序員用指針手動地分配和釋放內存,.NET中的GC和內存管理不適用於非托管堆,其內存塊也不會被合並移動,所以非托管堆的內存分配是按塊的、不連續的。因此,這也解釋了我們為何在使用非托管資源(如:文件流、數據庫連接等)需要手動地調用Dispose()方法進行內存釋放的原因。
2.2 執行string abc="aaa"+"bbb"+"ccc"共分配了多少內存?
這是一個經典的基礎知識題目,它涉及了字符串的類型、堆棧和堆的內存分配機制,因此被很多人拿來考核開發者的基礎知識功底。首先,我們都知道,判斷值類型的標准是查看該類型是否會繼承自System.ValueType,通過查看和分析,string直接繼承於System.Object,因此string是引用類型,其內存分配會遵照引用類型的規范,也就是說如下的代碼將會在堆棧上分配一塊存儲引用的內存,然后再在堆上分配一塊存儲字符串實例對象的內存。
string a = "edc";
現在再來看看string abc="aaa"+"bbb"+"ccc",按照常規的思路,字符串具有不可變性,大部分人會認為這里的表達式會涉及很多臨時變量的生成,可能C#編譯器會先執行"aaa"+"bbb",並且把結果值賦給一個臨時變量,再執行臨時變量和"ccc"相加,最后把相加的結果再賦值給abc。But,其實C#編譯器比想象中要聰明得多,以下的C#代碼和IL代碼可以充分說明C#編譯器的智能:
// The first format string first = "aaa" + "bbb" + "ccc"; // The second format string second = "aaabbbccc"; // Display string Console.WriteLine(first); Console.WriteLine(second);
該C#代碼的IL代碼如下圖所示:
正如我們所看到的,string abc="aaa"+"bbb"+"ccc";這樣的表達式被C#編譯器看成一個完整的字符串"aaabbbccc",而不是執行某些拼接方法,可以將其看作是C#編譯器的優化,所以在本次內存分配中只是在棧中分配了一個存儲字符串引用的內存塊,以及在托管堆分配了一塊存儲"aaabbbccc"字符串對象的內存塊。
那么,我們的常規思路在.NET程序中又是怎么體現的呢?我們來看一下一段代碼:
int num = 1; string str = "aaa" + num.ToString(); Console.WriteLine(str);
這里我們首先初始化了一個int類型的變量,其次初始化了一個string類型的字符串,並執行 + 操作,這時我們來看看其對應的IL代碼:
如上圖所示,在這段代碼中執行 + 操作,會調用String的Concat方法,該方法需要傳入兩個string類型的參數,也就產生了另一個string類型的臨時變量。換句話說,在此次內存分配中,堆棧中會分配一個存儲字符串引用的內存塊,在托管堆則分配了兩塊內存塊,分別存儲了存儲"aaa"字符串對象和"1"字符串對象。
可能這段代碼還是不熟悉,我們再來看看下面一段代碼,我們就感覺十分親切熟悉了:
string str = "aaa"; str += "bbb"; str += "ccc"; Console.WriteLine(str);
其對應的IL代碼如下圖所示:
如圖可以看出,在拼接過程中產生了兩個臨時字符串對象,並調用了兩次String.Concat方法進行拼接,就不用多解釋了。
2.3 簡要說說.NET中GC的運行機制
GC是垃圾回收(Garbage Collect)的縮寫,它是.NET眾多機制中最為重要的一部分,也是對我們的代碼書寫方式影響最大的機制之一。.NET中的垃圾回收是指清理托管堆上不會再被使用的對象內存,並且移動仍在被使用的對象使它們緊靠托管堆的一邊。下圖展示了一次垃圾回收之后托管堆上的變化(這里僅僅為了說明,簡化了GC的執行過程,省略了包含Finalize方法對象的處理以及大對象分配的特殊性):
如上圖所示,我們可以知道GC的執行過程分為兩個基本動作:
(1)一是找到所有不再被使用的對象:對象A和對象C,並標記為垃圾;
(2)二是移動仍在被使用的對象:對象B和對象D。
這樣之后,對象A和對象C所占用的內存空間就被騰空出來,以備下次分配的時候使用。
PS:通常情況下,我們不需要手動干預垃圾回收的執行,不過CLR仍然提供了一個手動執行垃圾回收的方法:GC.Collect()。當我們需要在某一批對象不再使用並且及時釋放內存的時候可以調用該方法來實現。But,垃圾回收的運行成本較高(涉及到了對象塊的移動、遍歷找到不再被使用的對象、很多狀態變量的設置以及Finalize方法的調用等等),對性能影響也較大,因此我們在編寫程序時,應該避免不必要的內存分配,也盡量減少或避免使用GC.Collect()來執行垃圾回收。
2.4 Dispose和Finalize方法在何時被調用?
由於有了垃圾回收機制的支持,對象的析構(或釋放)和C++有了很大的不同,這就需要我們在設計類型的時候,充分理解.NET的機制,明確怎樣利用Dispose方法和Finalize方法來保證一個對象正確而高效地被析構。
(1)Dispose方法
// 摘要: // 定義一種釋放分配的資源的方法。 [ComVisible(true)] public interface IDisposable { // 摘要: // 執行與釋放或重置非托管資源相關的應用程序定義的任務。 void Dispose(); }
Microsoft考慮到很多情況下程序員仍然希望在對象不再被使用時進行一些清理工作,所以.NET提供了IDispose接口並且在其中定義了Dispose方法。通常我們會在Dispose方法中實現一些托管對象和非托管對象的釋放以及業績業務邏輯的結束工作等等。
But,即使我們實現了Dispose方法,也不能得到任何有關釋放的保證,Dispose方法的調用依賴於類型的使用者,當類型被不恰當地使用,Dispose方法將不會被調用。因此,我們一般會借助using等語法來幫助Dispose方法被正確調用。
(2)Finalize方法
剛剛提到Dispose方法的調用依賴於類型的使用者,為了彌補這一缺陷,.NET還提供了Finalize方法。Finalize方法類似於C++中的析構函數(方法),但又和C++的析構函數不同。Finalize在GC執行垃圾回收時被調用,其具體機制如下:
①當每個包含Finalize方法的類型的實例對象被分配時,.NET會在一張特定的表結構中添加一個引用並且指向這個實例對象,暫且稱該表為“帶析構方法的對象表”;
②當GC執行並且檢測到一個不被使用的對象時,需要進一步檢查“帶析構方法的對象表”來查詢該對象類型是否含有Finalize方法,如果沒有則將該對象視為垃圾,如果存在則將該對象的引用移動到另外一張表,暫且稱其為“待析構的對象表”,並且該對象實例仍然被視為在被使用。
③CLR將有一個單獨的線程負責處理“待析構的對象表”,其執行方法內部就是依次通過調用其中每個對象的Finalize方法,然后刪除引用,這時托管堆中的對象實例就被視為不再被使用。
④下一個GC執行時,將釋放已經被調用Finalize方法的那些對象實例。
(3)結合使用Dispose和Finalize方法:標准Dispose模式
Finalize方法由於有CLR保證調用,因此比Dispose方法更加安全(這里的安全是相對的,Dispose需要類型使用者的及時調用),但在性能方面Finalize方法卻要差很多。因此,我們在類型設計時一般都會使用標准Dispose模式:Finalize方法作為Dispose方法的后備,只有在使用者沒有調用Dispose方法的情況下,Finalize方法才被視為需要執行。這一模式保證了對象能夠被高效和安全地釋放,已經被廣泛使用。
下面的代碼則是實現這種標准Dispose模式的一個模板:

public class BaseTemplate : IDisposable { // 標記對象是否已經被釋放 private bool isDisposed = false; // Finalize方法 ~BaseTemplate() { Dispose(false); } // 實現IDisposable接口的Dispose方法 public void Dispose() { Dispose(true); // 告訴GC此對象的Finalize方法不再需要被調用 GC.SuppressFinalize(this); } // 虛方法的Dispose方法做實際的析構工作 protected virtual void Dispose(bool isDisposing) { // 當對象已經被析構,則不必再繼續執行 if(isDisposed) { return; } if(isDisposing) { // Step1:在這里釋放托管資源 } // Step2:在這里釋放非托管資源 // Step3:最后標記對象已被釋放 isDisposed = true; } public void MethodA() { if(isDisposed) { throw new ObjectDisposedException("對象已經釋放"); } // Put the logic code of MethodA } public void MethodB() { if (isDisposed) { throw new ObjectDisposedException("對象已經釋放"); } // Put the logic code of MethodB } } public sealed class SubTemplate : BaseTemplate { // 標記子類對象是否已經被釋放 private bool disposed = false; protected override void Dispose(bool isDisposing) { // 驗證是否已被釋放,確保只被釋放一次 if(disposed) { return; } if(isDisposing) { // Step1:在這里釋放托管的並且在這個子類型中申明的資源 } // Step2:在這里釋放非托管的並且這個子類型中申明的資源 // Step3:調用父類的Dispose方法來釋放父類中的資源 base.Dispose(isDisposing); // Step4:設置子類的釋放標識 disposed = true; } }
真正做釋放工作的只是受保護的虛方法Dispose,它接收一個bool參數,主要用於區分調用者是類型的使用者還是.NET的GC機制。兩者的區別在於通過Finalize方法釋放資源時不能再釋放或使用對象中的托管資源,這是因為這時的對象已經處於不被使用的狀態,很有可能其中的托管資源已經被釋放掉了。在Dispose方法中GC.SuppressFinalize(this)告訴GC此對象在被回收時不需要調用Finalize方法,這一句是改善性能的關鍵,記住實現Dispose方法的本質目的就在於避免所有釋放工作在Finalize方法中進行。
2.5 GC中代(Generation)是什么,分為幾代?
在.NET的GC執行垃圾回收時,並不是每次都掃描托管堆內的所有對象實例,這樣做太耗費時間而且沒有必要。相反,GC會把所有托管堆內的對象按照其已經不再被使用的可能性分為三類,並且從最有可能不被使用的類別開始掃描,.NET對這樣的分類類別有一個稱呼:代(Generation)。
GC會把所有的托管堆內的對象分為0代、1代和2代:
第0代,新近分配在堆上的對象,從來沒有被垃圾收集過。任何一個新對象,當它第一次被分配在托管堆上時,就是第0代。
第1代,經歷過一次垃圾回收后,依然保留在堆上的對象。
第2代,經歷過兩次或以上垃圾回收后,依然保留在堆上的對象。如果第2代對象在進行完垃圾回收后空間仍然不夠用,則會拋出OutOfMemoryException異常。
對於這三代,我們需要知道的是並不是每次垃圾回收都會同時回收3個代的所有對象,越小的代擁有着越多被釋放的機會。
CLR對於代的基本算法是:每執行N次0代的回收,才會執行一次1代的回收,而每執行N次1代的回收,才會執行一次2代的回收。當某個對象實例在GC執行時被發現仍然在被使用,它將被移動到下一個代中上,下圖簡單展示了GC對三個代的回收操作。
根據.NET的垃圾回收機制,0代、1代和2代的初始分配空間分別為256KB、2M和10M。說完分代的垃圾回收設計,也許我們會有疑問,為什么要這樣弄?其實分代並不是空穴來風的設計,而是參考了這樣一個事實:
一個對象實例存活的時間越長,那么它就具有更大的機率去存活更長的時間。換句話說,最有可能馬上就不被使用的對象實例,往往是那些剛剛被分配的對象實例,而且新分配的對象實例通常都會被馬上大量地使用。這也解釋了為什么0代對象擁有最多被釋放的機會,並且.NET也只為0代分配了一塊只有256KB的小塊邏輯內存,以使得0代對象有機會被全部放入處理器的緩存中去,這樣做的結果就是使用頻率最高並且最有可能馬上可以被釋放的對象實例擁有了最高的使用效率和最快的釋放速度。
因為一次GC回收之后仍然被使用的對象會被移動到更高的代上,因此我們需要避免保留已經不再被使用的對象引用,將對象的引用置為null是告訴.NET該對象不需要再使用的最直接的方法。
在前面我們提到Finalize方法會大幅影響性能,通過結合對代的理解,我們可以知道:在帶有Finalize方法的對象被回收時,該對象會被視為正在被使用從而被留在托管堆中,且至少要等一個GC循環才能被釋放(為什么是至少一個?因為這取決於執行Finalize方法的線程的執行速度)。很明顯,需要執行Finalize方法的那些對象實例,被真正釋放時最樂觀的情況下也已經位於1代的位置上了,而如果它們是在1代上才開始釋放或者執行Finalize方法的線程運行得慢了一點,那該對象就在第2代上才被釋放,相對於0代,這樣的對象實例在堆中存留的時間將長很多。
2.6 GC機制中如何判斷一個對象仍然在被使用?
在.NET中引用類型對象實例通常通過引用來訪問,而GC判斷堆中的對象是否仍然在被使用的依據也是引用。簡單地說:當沒有任何引用指向堆中的某個對象實例時,這個對象就被視為不再使用。
在GC執行垃圾回收時,會把引用分為以下兩類:
(1)根引用:往往指那些靜態字段的引用,或者存活的局部變量的引用;
(2)非根引用:指那些不屬於根引用的引用,往往是對象實例中的字段。
垃圾回收時,GC從所有仍在被使用的根引用出發遍歷所有的對象實例,那些不能被遍歷到的對象將被視為不再被使用而進行回收。我們可以通過下面的一段代碼來直觀地理解根引用和非根引用:

class Program { public static Employee staticEmployee; static void Main(string[] args) { staticEmployee = new Employee(); // 靜態變量 Employee a = new Employee(); // 局部變量 Employee b = new Employee(); // 局部變量 staticEmployee.boss = new Employee(); // 實例成員 Console.ReadKey(); Console.WriteLine(a); } } public class Employee { public Employee boss; public override string ToString() { if(boss == null) { return "No boss"; } return "One boss"; } }
上述代碼中一共有兩個局部變量和一個靜態變量,這些引用都是根引用。而其中一個局部變量 a 擁有一個成員實例對象,這個引用就是一個非跟引用。下圖展示了代碼執行到Console.ReadKey()這行代碼時運行垃圾回收時的情況。
從上圖中可以看出,在執行到Console.ReadKey()時,存活的根引用有staticEmployee和a,前者因為它是一個公共靜態變量,而后者則因為后續代碼還會使用到a。通過這兩個存活的根引用,GC會找到一個非跟引用staticEmployee.boss,並且發現三個仍然存活的對象。而b的對象則將被視為不再使用從而被釋放。(更簡單地確保b對象不再被視為在被使用的方法時把b的引用置為null,即b=null;)
此外,當一個從根引用觸發的遍歷抵達一個已經被視為在使用的對象時,將結束這一個分支的遍歷,這樣做可以避免陷入死循環。
2.7 .NET中的托管堆中是否可能出現內存泄露的現象?
首先,必須明確一點:即使在擁有垃圾回收機制的.NET托管堆上,仍然是有可能發生內存泄露現象的。
其次,什么是內存泄露?內存泄露是指內存空間上產生了不再被實際使用卻又不能被分配的內存空間,其意義很廣泛,像內存碎片、不徹底的對象釋放等都屬於內存泄露現象。內存泄露將導致主機的內存隨着程序的運行而逐漸減少,無論其表現形式怎樣,它的危害是很大的,因此我們需要努力地避免。
按照內存泄露的定義,我們可以知道在大部分的時候.NET中的托管堆中存在着短暫的內存泄露情況,因為對象一旦不再被使用,需要等到下一個GC時才會被釋放。這里列舉幾個在.NET中常見的幾種對系統危害較大的內存泄露情況,我們在實際開發中需要極力避免:
(1)大對象的分配
.NET中所有的大對象(這里主要是指對象的大小超過指定數值[85000字節])將分配在托管堆內一個特殊的區域內,暫且將其稱為“大對象堆”(這也算是CLR對於GC的一個優化策略)。大對象堆中最重要的一個特點就是:沒有代級的概念,所有對象都被視為第2代。在回收大對象堆內的對象時,其他的大對象不會被移動,這是考慮到大規模地移動對象需要耗費過多的資源。這樣,在程序過多地分配和釋放大對象之后,就會產生很多內存碎片。下圖解釋了這一過程:
如圖所示可以看出,隨着對象的分配和釋放不斷進行,在不進行對象移動的大對象堆內,將不可避免地產生小的內存碎片。我們所需要做的就是盡量減少大對象的分配次數,尤其是那些作為局部變量的,將被大規模分配和釋放的大對象,典型的例子就是String類型。
(2)不恰當地保存根引用
最簡單的一個錯誤例子就是不恰當地把一個對象申明為公共靜態變量,一個公共的靜態變量將一直被GC視為一個在使用的根引用。更糟糕的是:當這個對象內部還包含更多的對象引用時,這些對象同樣不會被釋放。例如下面一段代碼:

public class Program { // 公共靜態大對象 public static RefRoot bigObject = new RefRoot("test"); public static void Main(string[] args) { Console.ReadKey(); } } public class RefRoot { // 這是一個占用大量內存的成員 public string[] BigMember; public RefRoot(string content) { // 初始化大對象 BigMember = new string[1000]; for (int i = 0; i < 1000; i++) { BigMember[i] = content; } } }
在代碼中,定義了一個公共靜態的大對象,這個對象將直到程序運行結束后才會被GC釋放掉。如果在整個程序中各個類型不斷地使用這個靜態成員,那這樣的設計有助於減少大對象堆內的內存碎片,但是如果整個程序極少地甚至只有一次使用了這個成員,那考慮到它占用的內存會影響整體系統性能,設計時則應該考慮設計成實例變量,以便GC能夠及時釋放它。
(3)不正確的Finalize方法
前面已經介紹了Finalize方法時由GC的一個專用的線程進行調用,拋開Microsoft怎樣實現的這個具體的調度算法,有一點可以肯定的是:不正確的Finalize方法將導致Finalize方法不能被正確執行。如果系統中所有的Finalize方法不能被正確執行,包含它們的對象也只能駐留在托管堆內不能被釋放,這樣的情況將會導致嚴重的后果。
那么,什么是不正確的Finalize方法?Finalize方法應該只致力於快速而簡單地釋放非托管資源,並且盡可能快地返回。相反,不正確的Finalize方法則可能包含以下這樣的一些代碼:
①沒有保護地寫文件日志;
②訪問數據庫;
③訪問網絡;
④把當前對象賦給某個存活的引用;
例如,當Finalize方法試圖訪問文件系統、數據庫或者網絡時,將會有資源爭用和等待的潛在危險。試想一個不斷嘗試訪問離線數據庫的Finalize方法,將會在長時間內不會返回,這不僅影響了對象的釋放,也使得排在Finalize方法隊列中的所有后續對象得不到釋放,這個連鎖反應將會導致很快地造成內存耗盡。此外,如果在Finalize方法中把對象自身又賦給了另外一個存活的引用,這時對象內的一部分資源已經被釋放掉了,而另外一部分還沒有,當這樣一個對象被激活后,將導致不可預知的后果。
參考資料
(1)朱毅,《進入IT企業必讀的200個.NET面試題》
(2)張子陽,《.NET之美:.NET關鍵技術深入解析》
(3)王濤,《你必須知道的.NET》