1. 程式人生 > >c# -- 物件銷燬和垃圾回收

c# -- 物件銷燬和垃圾回收

有些物件需要顯示地銷燬程式碼來釋放資源,比如開啟的檔案資源,鎖,作業系統控制代碼和非託管物件。在.NET中,這就是所謂的物件銷燬,它通過IDisposal介面來實現。不再使用的物件所佔用的記憶體管理,必須在某個時候回收;這個被稱為無用單元收集的功能由CLR執行。

物件銷燬和垃圾回收的區別在於:物件銷燬通常是明確的策動;而垃圾回收完全是自動地。換句話說,程式設計師負責釋放檔案控制代碼,鎖,以及作業系統資源;而CLR負責釋放記憶體。

本章將討論物件銷燬和垃圾回收,還描述了C#處理銷燬的一個備選方案--Finalizer及其模式。最後,我們討論垃圾回收器和其他記憶體管理選項的複雜性。

物件銷燬 垃圾回收
1)IDisposal介面
2) Finalizer
垃圾回收
物件銷燬用於釋放非託管資源 垃圾回收用於自動釋放不再被引用的物件所佔用的記憶體;並且垃圾回收什麼時候執行時不可預計的
為了彌補垃圾回收執行時間的不確定性,可以在物件銷燬時釋放託管物件佔用的記憶體

IDisposal,Dispose和Close

image

.NET Framework定義了一個特定的介面,型別可以使用該介面實現物件的銷燬。該介面的定義如下:

public interface IDisposable
{
void Dispose();
}

C#提供了鴘語法,可以便捷的呼叫實現了IDisposable的物件的Dispose方法。比如:

using (FileStream fs = new FileStream ("myFile.txt", FileMode.Open))
{
// ... Write to the file ...
}

編譯後的程式碼與下面的程式碼是一樣的:

複製程式碼
FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
try
{
// ... Write to the file ...
}
finally
{
if (fs != null
) ((IDisposable)fs).Dispose(); }
複製程式碼

finally語句確保了Dispose方法的呼叫,及時發生了異常,或者程式碼在try語句中提前返回。

在簡單的場景中,建立自定義的可銷燬的型別值需要實現IDisposable介面即可

複製程式碼
sealed class Demo : IDisposable
{
public void Dispose()
{
// Perform cleanup / tear-down.
...
}
}
複製程式碼

請注意,對於sealed類,上述模式非常適合。在本章後面,我們會介紹另外一種銷燬物件的模式。對於非sealed類,我們強烈建議時候後面的那種銷燬物件模式,否則在非sealed類的子類中,也希望實現銷燬時,會發生非常詭異的問題。

物件銷燬的標準語法

Framework在銷燬物件的邏輯方面遵循一套規則,這些規則並不限用於.NET Framework或C#語言;這些規則的目的是定義一套便於使用的協議。這些協議如下:

  • 一旦銷燬,物件不可恢復。物件不能被再次啟用,呼叫物件的方法或者屬性丟擲ObjectDisposedException異常
  • 重複地呼叫物件的Disposal方法會導致錯誤
  • 如果一個可銷燬物件x包含,或包裝,或處理另外一個可銷燬物件y,那麼x的Dispose方法自動呼叫x的Dispose方法,除非另有指令(不銷燬y)

這些規則同樣也適用於我們平常建立自定義型別,儘管它並不是強制性的。沒有誰能阻止你編寫一個不可銷燬的方法;然而,這麼做,你的同事也許會用高射炮攻擊你。

對於第三條規則,一個容器物件自動銷燬其子物件。最好的一個例子就是,windows容器物件比如Form對著Panel。一個容器物件可能包含多個子控制元件,那你也不需要顯示地銷燬每個字物件:關閉或銷燬父容器會自動關閉其子物件。另外一個例子就是如果你在DeflateStream包裝了FileStream,那麼銷燬DeflateStream時,FileStream也會被銷燬--除非你在構造器中指定了其他的指令。

Close和Stop

有一些型別除了Dispose方法之外,還定義了Close方法。Framework對於Close方法並沒有保持完全一致性,但在幾乎所有情況下,它可以:

  • 要麼在功能上與Dispose一致
  • 或只是Dispose的一部分功能

對於後者一個典型的例子就是IDbConnecton型別,一個Closed的連線可以再次被開啟;而一個Disposed的連線物件則不能。另外一個例子就是Windows程式使用ShowDialog的啟用某個視窗物件:Close方法隱藏該視窗;而Dispose釋放視窗所使用的資源。

有一些類定義Stop方法(比如Timer或HttpListener)。與Dipose方法一樣,Stop方法可能會釋放非託管資源;但是與Dispose方法不同的是,它允許重新啟動。

何時銷燬物件

銷燬物件應該遵循的規則是“如有疑問,就銷燬”。一個可以被銷燬的物件--如果它可以說話--那麼將會說這些內容:

“如果你結束對我的使用,那麼請讓我知道。如果只是簡單地拋棄我,我可能會影響其他例項物件、應用程式域、計算機、網路、或者資料庫”

如果物件包裝了非託管資源控制代碼,那麼經常會要求銷燬,以釋放控制代碼。例子包括Windows Form控制元件、檔案流或網路流、網路sockets,GDI+畫筆、GDI+刷子,和bitmaps。與之相反,如果一個型別是可銷燬的,那麼它會經常(但不總是)直接或間接地引用非託管控制代碼。這是由於非託管控制代碼對作業系統資源,網路連線,以及資料庫鎖之外的世界提供了一個閘道器(出入口),這就意味著使用這些物件時,如果不正確的銷燬,那麼會對外面的世界程式碼麻煩。

但是,遇到下面三種情形時,要銷燬物件

  • 通過靜態成員或屬性獲取一個共享的物件
  • 如果一個物件的Dispose方法與你的期望不一樣
  • 從設計的角度看,如果一個物件的Dispose方法不必要,且銷燬物件給程式添加了複雜度

第一種情況很少見。多數情形都可以在System.Drawing名稱空間下找到:通過靜態成員或屬性獲取的GDI+物件(比如Brushed.Blue)就不能銷燬,這是因為該實現在程式的整個生命週期中都會用到。而通過構造器得到的物件例項,比如new SolidBrush,就應該銷燬,這同樣適用於通過靜態方法獲取的例項物件(比如Font.FromHdc)。

第二種情況就比較常見。下表以System.IO和System.Data名稱空間下型別舉例說明

型別 銷燬功能 何時銷燬
MemoryStream 防止對I/O繼續操作 當你需要再次讀讀或寫流
StreamReader,
StreamWriter
清空reader/writer,並關閉底層的流 當你希望底層流保持開啟時(一旦完成,你必須改為呼叫StreamWriter的Flush方法)
IDbConnection 釋放資料庫連線,並清空連線字串 如果你需要重新開啟資料庫連線,你需要呼叫Close方法而不是Dispose方法
DataContext
(LINQ to SQL)
防止繼續使用 當你需要延遲評估連線到Context的查詢

第三者情況包含了System.ComponentModel名稱空間下的這幾個類:WebClient, StringReader, StringWriter和BackgroundWorker。這些型別有一個共同點,它們之所以是可銷燬的是源於它們的基類,而不是真正的需要進行必要的清理。如果你需要在一個方法中使用這樣的型別,那麼在using語句中例項化它們就可以了。但是,如果例項物件需要持續一段較長的時間,並記錄何時不再使用它們以銷燬它們,就會給程式帶來不惜要的複雜度。在這樣的情況下,那麼你就應該忽略銷燬物件。

選擇性地銷燬物件

正因為IDisposable實現類可以使用using語句來例項化,因而這可能很容易導致該實現類的Dispose方法延伸至不必要的行為。比如:

複製程式碼
public sealed class HouseManager : IDisposable
{
public void Dispose()
{
CheckTheMail();
}
...
}
複製程式碼

想法是該類的使用者可以選擇避免不必要的清理--簡單地說就是不呼叫Dispose方法。但是,這就需要呼叫者知道HouseManager類Dispose方法的實現細節。及時是後續添加了必要的清理行為也破壞了規則。

public void Dispose()
{
CheckTheMail(); // Nonessential
LockTheHouse(); // Essential
}

在這種情況下,就應該使用選擇性銷燬模式

複製程式碼
public sealed class HouseManager : IDisposable
{
public readonly bool CheckMailOnDispose;
public Demo (bool checkMailOnDispose)
{
CheckMailOnDispose = checkMailOnDispose;
}
public void Dispose()
{
if (CheckMailOnDispose) CheckTheMail();
LockTheHouse();
}
...
}
複製程式碼

這樣,任何情況下,呼叫者都可以呼叫Dispose--上述實現不僅簡單,而且避免了特定的文件或通過反射檢視Dispose的細節。這種模式在.net中也有實現。System.IO.Compression空間下的DeflateStream類中,它的構造器如下

public DeflateStream (Stream stream, CompressionMode mode, bool leaveOpen)

非必要的行為就是在銷燬物件時關閉內在的流(第一個引數)。有時候,你希望內部流保持開啟的同時並銷燬DeflateStream以執行必要的銷燬行為(清空bufferred資料)

這種模式看起來簡單,然後直到Framework 4.5,它才從StreamReader和StreamWriter中脫離出來。結果卻是醜陋的:StreamWriter必須暴露另外一個方法(Flush)以執行必要的清理,而不是呼叫Dispose方法(Framework 4.5在這兩個類上公開一個構造器,以允許你保持流處於開啟狀態)。System.Security.Cryptography名稱空間下的CryptoStream類,也遭遇了同樣的問題,當需要保持內部流處於開啟時你要呼叫FlushFinalBlock銷燬物件。

銷燬物件時清除欄位

在一般情況下,你不要在物件的Dispose方法中清除該物件的欄位。然而,銷燬物件時,應該取消該物件在生命週期內所有訂閱的事件。退訂這些事件避免了接收到非期望的通知--同時也避免了垃圾回收器繼續對該物件保持監視。

設定一個欄位用以指明物件是否銷燬,以便在使用者在該物件銷燬後訪問該物件丟擲一個ObjectDisposedException,這是非常值得做的。一個好的模式就是使用一個public的制度的屬性:

public bool IsDisposed { get; private set; }

儘管技術上沒有必要,但是在Dispose方法清除一個物件所擁有的事件控制代碼(把控制代碼設定為null)也是非常好的一種實踐。這消除了在銷燬物件期間這些事件被觸發的可能性。

偶爾,一個物件擁有高度祕密,比如加密金鑰。在這種情況下,那麼在銷燬物件時清除這樣的欄位就非常有意義(避免被非授權元件或惡意軟體發現)。System.Security.Cryptography命令空間下的SymmetricAlgorithm類就屬於這種情況,因此在銷燬該物件時,呼叫Array.Clear方法以清除加密金鑰。

自動垃圾回收機制

無論一個物件是否需要Dispose方法以實現銷燬物件的邏輯,在某個時刻,該物件在堆上所佔用的記憶體空間必須釋放。這一切都是由CLR通過GC自動處理. 你不需要自己釋放託管記憶體。我們首先來看下面的程式碼

public void Test()
{
byte[] myArray = new byte[1000];
}

當Test方法執行時,在記憶體的堆上分配1000位元組的一個數組;該陣列被變數myArray引用,這個變數儲存在變數棧上。當方法退出後,區域性變數myArray就失去了存在的範疇,這也意味著沒有引用指向記憶體堆上的陣列。那麼該孤立的陣列,就非常適合通過垃圾回收機制進行回收。

垃圾回收機制並不會在一個物件變成孤立的物件之後就立即執行。與大街上的垃圾收集不一樣,.net垃圾回收是定期執行,盡享不是按照一個估計的計劃。CLR決定何時進行垃圾回收,它取決於許多因素,比如,剩餘記憶體,已經分配的記憶體,上一次垃圾回收的時間。這就意味著,在一個物件被孤立後到期佔用的記憶體被釋放之間,有一個不確定的時間延遲。該延遲的範圍可以從幾納秒到數天。

垃圾回收和記憶體佔用
垃圾收集試圖在執行垃圾回收的時間與程式的記憶體佔用之間建立一個平衡。因此,程式可以佔用比它們實際需要更多的記憶體,尤其特現在程式建立的大的臨時陣列。
你可以通過Windows工作管理員監視某一個程序記憶體的佔用,或者通過程式設計的方式查詢效能計數器來監視記憶體佔用:
// These types are in System.Diagnostics:
string procName = Process.GetCurrentProcess().ProcessName;
using (PerformanceCounter pc = new PerformanceCounter
("Process", "Private Bytes", procName))
Console.WriteLine (pc.NextValue());
上面的程式碼查詢內部工作組,返回你當前程式的記憶體佔用。尤其是,該結果包含了CLR內部釋放,以及把這些資源讓給作業系統以供其他的程序使用。

根就是指保持物件依然處於活著的事物。如果一個物件不再直接或間接地被一個根引用,那麼該物件就適合於垃圾回收。

一個跟可以是:

  • 一個正在執行的方法的區域性變數或引數(或者呼叫棧中任意方法的區域性變數或引數)
  • 一個靜態變數
  • 存貯在結束佇列中的一個物件

正在執行的程式碼可能涉及到一個已經刪除的物件,因此,如果一個例項方法正在執行,那麼該例項方法的物件必然按照上述方式被引用。

請注意,一組相互引用的物件的迴圈被視作無根的引用。換一種方式,也就是說,物件不能通過下面的箭頭指向(引用)而從根獲取,這也就是引用無效,因此這些物件也將被垃圾回收器處理。

image

Finalizers

在一個物件從記憶體釋放之前,如果物件包含finalizer,那麼finalizer開始執行。一個finalizer的宣告類似構造器函式,但是它使用~字首符號

複製程式碼
class Test
{
    ~Test()
    {
        // finalizer logic ...
    }
}
複製程式碼

(儘管與構造器的宣告相似,finalizer不能被宣告為public或static,也不能有引數,還不能呼叫其基類)

Finalizer是可能的,因為垃圾收集工作在不同的時間段。首先,垃圾回收識別沒有使用的物件以刪除該物件。這些待刪除的物件如果沒有Finalizer那麼就立即刪除。而那些擁有finalizer的物件會被保持存活並存在放到一個特殊的佇列中。

在這一點上,當你的程式在繼續執行的時候,垃圾收集也是完整的。而Finalizer執行緒卻在你程式執行時,自動啟動並在另外一個執行緒中併發執行,收集擁有Finalizer的物件到特殊佇列,然後執行它們的終止方法。在每個物件的finalizer方法執行之前,它依然非常活躍--排序行為視作一個跟物件。而一檔這些物件被移除佇列,並且這些物件的fainalizer方法已經執行,那麼這些物件就變成孤立的物件,會在下一階段的垃圾回收過程中被回收。

Finalizer非常有用,但它們也有一些限制:

  • Finalizer減緩記憶體分配和收集(因為GC需要追蹤那些Finalizer在執行)
  • Finalizer延長物件及其所引用物件的生命週期(這些物件只有在下一次垃圾回收執行過程中被真正地刪除)
  • 對於一組物件,Finalizer的呼叫順序是不可預測的
  • 你不能控制一個物件的finalizer何時被呼叫
  • 如果一個物件的finalizer被阻塞,那麼其他物件不能處置(Finalized)
  • 如果程式沒有解除安裝(unload)乾淨,那麼finalizer會被忽略

總之,finalizer在一定程度上就好比律師--一旦有訴訟那麼你確實需要他們,一般你不想使用他們,除非萬不得已。如果你使用他們,那麼你需要100%確保你瞭解他們會為你做什麼。

下面是實施finalizer的一些準則:

  • 確保finalizer快速執行
  • 絕對不要在finalier中使用阻塞
  • 不要引用其他可finalizable物件
  • 不要丟擲異常
在Finalizer中呼叫Dispose

一個流行的模式是使finalizer呼叫Dispose方法。這麼做是有意義的,尤其是當清理工作不是緊急的,並且通過呼叫Dispose加速清理;那麼這樣的方式更多是一個優化,而不是一個必須。

下面的程式碼展示了該模式是如何實現的

複製程式碼
class Test : IDisposable
{
public void Dispose() // NOT virtual
{
Dispose (true);
GC.SuppressFinalize (this); // Prevent finalizer from running.
}
protected virtual void Dispose (bool disposing)
{
if (disposing)
{
// Call Dispose() on other objects owned by this instance.
// You can reference other finalizable objects here.
// ...
}
// Release unmanaged resources owned by (just) this object.
// ...
}
˜Test()
{
Dispose (false);
}
}
複製程式碼

Dispose方法被過載,並且接收一個bool型別引數。而沒有引數的Dispose方法並沒有被宣告為virtual,只是在該方法內部呼叫了帶引數的Dispose方法,且傳遞的引數的值為true。

帶引數的Dispose方法包含了真正的處置物件的邏輯,並且它被宣告為protected和virtual。這樣就可以保證其子類可以新增自己的處置邏輯。引數disposing標記意味著它在Dispose方法中被正確的呼叫,而不是從finalizer的最後採取模式所呼叫。這也就表明,如果呼叫Dispose時,其引數disposing的值如果為false,那麼該方法,在一般情況下,都會通過finalizer引用其他物件(因為,這樣的物件可能自己已經被finalized,因此處於不可預料的狀態)。這裡面涉及的規則非常多!當disposing引數是false時,在最後採取的模式中,仍然會執行兩個任務:

釋放對作業系統資源的直接引用(這些引用可能是因為通過P/Invoke呼叫Win32 API而獲取到)

刪除由構造器建立的臨時檔案

為了使這個模式更強大,那麼任何會丟擲異常的程式碼都應包含在一個try/catch程式碼塊中;而且任何異常,在理想狀態下,都應該被記錄。此外,這些記錄應當今可能既簡單又強大。

請注意,在無引數的Dispose方法中,我們呼叫了GC.SuppressFinalize方法,這會使得GC在執行時,阻止finalizer執行。從技術角度講,這沒有必要,因為Dispose方法必然會被重複呼叫。但是,這麼做會改進效能,因為它允許物件(以及它所引用的物件)在單個迴圈中被垃圾回收器回收。

復活

假設一個finalizer修改了一個活的物件,使其引用了一個“垂死”物件。那麼當下一次垃圾回收發生時,CLR會檢視之前垂死的物件是否確實沒有任何引用指向它--從而確定是否對其執行垃圾回收。這是一個高階的場景,該場景被稱作復活(resurrection)。

為了證實這點,假設我們希望建立一個類管理一個臨時檔案。當類的例項被回收後,我們希望finalizer刪除臨時檔案。這看起來很簡單

複製程式碼
public class TempFileRef
{
public readonly string FilePath;
public TempFileRef (string filePath) { FilePath = filePath; }

~TempFileRef() { File.Delete (FilePath); }
}
複製程式碼

實際,上訴程式碼存在bug,File.Delete可能會丟擲一個異常(引用缺少許可權,或者檔案處於使用中) 。這樣的異常會導致拖垮整個程式(還會阻止其他finalizer執行)。我們可以通過一個空的catch程式碼塊來“消化”這個異常,但是這樣我們就不能獲取任何可能發生的錯誤。 呼叫其他的錯誤報告API也不是我們所期望的,因為這麼做會加重finalizer執行緒的負擔,並且會妨礙對其他物件進行垃圾回收。 我們期望顯示finalization行為簡單、可靠、並快速。

一個好的解決方法是在一個靜態集合中記錄錯誤資訊:

複製程式碼
public class TempFileRef
{
static ConcurrentQueue<TempFileRef> _failedDeletions
= new ConcurrentQueue<TempFileRef>();
public readonly string FilePath;
public Exception DeletionError { get; private set; }
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch (Exception ex)
{
DeletionError = ex;
_failedDeletions.Enqueue (this); // Resurrection
}
}
}
複製程式碼

把物件插入到靜態佇列_failedDeletions中,使得該物件處於引用狀態,這就確保了它仍然保持活著的狀態,直到該物件最終從佇列中出列。

GC.ReRegisterForFinalize

一個復活物件的finalizer不會再次執行--除非你呼叫GC.ReRegisterForFinalize

在下面的例子中,我們試圖在一個finalizer中刪除一個臨時檔案。但是如果刪除失敗,我們就重新註冊帶物件,以使其在下一次垃圾回收執行過程中被回收。

<