一、基礎型別和語法
1.1 .NET中所有型別的基類是什麼?
在.NET中所有的內建型別都繼承自System.Object型別。在C#中,不需要顯示地定義型別繼承自System.Object,編譯器將自動地自動地為型別新增上這個繼承申明,以下兩行程式碼的作用完全一致:
1 2 |
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型別的容器
對於第①種情況,我們可以通過下面的改動示例來避免:
1 2 |
int i = 10; Console.WriteLine("The value is {0}", i.ToString()); |
對於第②種情況,則可以使用泛型技術來避免使用針對System.Object型別的容器,有效避免大規模地使用裝箱和拆箱:
1 2 3 4 5 6 7 |
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預設就有),也不能為成員變數定義初始值。
1 2 3 4 |
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修飾引數的方法時,可以直接把所有變數集合傳入而無須先申明一個陣列物件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
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()方法,它便用於進行淺度複製。所以,對於引用型別,要想實現淺度複製時,只需要呼叫這個方法就可以了:
1 2 3 4 |
public object Clone() { return MemberwiseClone(); } |
下面的程式碼展示了一個使用ICloneable介面提供深複製的簡單示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
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)到記憶體中,然後再進行反序列化,通過這種方式來進行物件的深度複製:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
[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,先進後出)依次進行退棧。(這裡的“依次”是指按照變數的作用域進行的),假設有以下一段程式碼:
1 2 3 |
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是引用型別,其記憶體分配會遵照引用型別的規範,也就是說如下的程式碼將會在堆疊上分配一塊儲存引用的記憶體,然後再在堆上分配一塊儲存字串例項物件的記憶體。
1 |
string a = "edc"; |
現在再來看看string abc=”aaa”+”bbb”+”ccc”,按照常規的思路,字串具有不可變性,大部分人會認為這裡的表示式會涉及很多臨時變數的生成,可能C#編譯器會先執行”aaa”+”bbb”,並且把結果值賦給一個臨時變數,再執行臨時變數和”ccc”相加,最後把相加的結果再賦值給abc。But,其實C#編譯器比想象中要聰明得多,以下的C#程式碼和IL程式碼可以充分說明C#編譯器的智慧:
1 2 3 4 5 6 7 |
// 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程式中又是怎麼體現的呢?我們來看一下一段程式碼:
1 2 3 |
int num = 1; string str = "aaa" + num.ToString(); Console.WriteLine(str); |
這裡我們首先初始化了一個int型別的變數,其次初始化了一個string型別的字串,並執行 + 操作,這時我們來看看其對應的IL程式碼:
如上圖所示,在這段程式碼中執行 + 操作,會呼叫String的Concat方法,該方法需要傳入兩個string型別的引數,也就產生了另一個string型別的臨時變數。換句話說,在此次記憶體分配中,堆疊中會分配一個儲存字串引用的記憶體塊,在託管堆則分配了兩塊記憶體塊,分別儲存了儲存”aaa”字串物件和”1″字串物件。
可能這段程式碼還是不熟悉,我們再來看看下面一段程式碼,我們就感覺十分親切熟悉了:
1 2 3 4 |
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方法
1 2 3 4 5 6 7 8 9 |
// 摘要: // 定義一種釋放分配的資源的方法。 [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模式的一個模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
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從所有仍在被使用的根引用出發遍歷所有的物件例項,那些不能被遍歷到的物件將被視為不再被使用而進行回收。我們可以通過下面的一段程式碼來直觀地理解根引用和非根引用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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視為一個在使用的根引用。更糟糕的是:當這個物件內部還包含更多的物件引用時,這些物件同樣不會被釋放。例如下面一段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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》