內存泄漏是指:當一塊內存被分配后,被丟棄,沒有任何實例指針指向這塊內存, 並且這塊內存不會被GC視為垃圾進行回收。這塊內存會一直存在,直到程序退出。C#是托管型代碼,其內存的分配和釋放都是由CLR負責,當一塊內存沒有任何實例引用時,GC會負責將其回收。既然沒有任何實例引用的內存會被GC回收,那么內存泄漏是如何發生的?
- 內存泄漏示例
為了演示內存泄漏是如何發生的,我們來看一段代碼
class Program { static event Action TestEvent; static void Main(string[] args) { var memory = new TestAction(); TestEvent += memory.Run; OnTestEvent(); memory = null; //強制垃圾回收 GC.Collect(GC.MaxGeneration); Console.WriteLine("GC.Collect"); //測試是否回收成功 OnTestEvent(); Console.ReadLine(); } public static void OnTestEvent() { if (TestEvent != null) TestEvent(); else Console.WriteLine("Test Event is null"); } class TestAction { public void Run() { Console.WriteLine("TestAction Run."); } } }
該例子中,memory.run訂閱了TestEvent事件,引發事件后,會在屏幕上看到 TestAction Run。當memory =null 后,memory原來指向的內存就沒有任何實例再引用該塊內存了,這樣的內存就是待回收的內存。GC.Collect(GC.MaxGeneration)語句會強制執行一次垃圾回收,再次引發事件,發現屏幕上還是會顯示TestAction Run。該內存沒有被GC回收,這就是內純泄漏。這是由TestEvent+=memory.Run語句引起的,當GC.Collect執行的時候,當他看到該塊內存還有TestEvent引用,就不會進行回收。但是該內存已經是“無法到達”的了,即無法調用該塊內存,只有在引發事件的時候,才能執行該內存的Run方法。這顯然不是我想要的效果,當memory = null執行時,我希望該內存在GC執行時被回收,並且當TestEvent被引發時,Run方法不會執行,因為我已經把該內存“解放”了。
這里有一個問題,就是C#中如何“釋放”一塊內存。像C和C++這樣的語言,內存的聲明和釋放都是開發人員負責的,一旦內存new了出來,就要delete,不然就會造成內存泄漏。這更靈活,也更麻煩,一不小心就會泄漏,忘記釋放、線程異常而沒有執行釋放的代碼...有手動分配內存的語言就有自動分配和釋放的語言。最開始使用垃圾回收的語言是LISP,之后被用在Java和C#等托管語言中。像C#,CLR負責內存的釋放,當程序執行一段時間后,CLR檢測到垃圾內存已經值得進行一次垃圾回收時,會執行垃圾回收。至於如何判定一塊內存是否為垃圾內存,比較著名的是計數法,即有一個實例引用了該內存后,就在該內存的計數上+1,改實例取消了對該內存的引用,計數就-1,當計數為0時,就被判定為垃圾。該種方法的問題是對循環引用束手無策,如A的某個字段引用了B,而B的某個字段引用了A,這樣A和B的技術都不會降到0。CLR改用的方法是類似“標記引用法”(我自己的命名):在執行GC時,會掛起全部線程,並將托管堆中所有的內存都打上垃圾的標記,之后遍歷所有可到達的實例,這些實例如果引用了托管堆的內存,就將該內存的標記由垃圾變為被引用。當遇到A和B相互引用的時候,如果沒有其他實例引用A或者B,雖然A和B相互引用,但是A和B都是不可到達的,即沒辦法引用A或者B,則A和B都會被判定為垃圾而被回收。講解了這么一大堆,目的就是要說,在C#中,你想要釋放一塊內存,你只要讓該塊內存沒有任何實例引用他,就可以了。那么當執行memory = null后,除了對TestEvent的訂閱,沒有任何實例再引用了該塊內存,那么為什么訂閱事件會阻止內存的釋放?
我們來看看TestEvent+=memory.Run()這句話都干了什么。我們利用IL反編譯上面的dll,可以看到
1 IL_0000: nop 2 IL_0001: newobj instance void EventLeakMemory.Program/TestAction::.ctor() 3 IL_0006: stloc.0 4 IL_0007: ldloc.0 5 IL_0008: ldftn instance void EventLeakMemory.Program/TestAction::Run() 6 IL_000e: newobj instance void [mscorlib]System.Action::.ctor(object, native int) 7 IL_0013: call void EventLeakMemory.Program::add_TestEvent(class [mscorlib]System.Action)
...//其他部分
關鍵在5-7行。第5和6行,聲明了一個System.Action型的委托,參數為TestAction.Run方法,第七行,執行了Program.add_TestEvent方法,參數是上面聲明的委托。也就是說+=操作符相當於執行了Add_TestEvent(new Action(memory.Run)),就是這個new Action包含了對memory指向的內存的引用。而這個引用在CLR看來是可達的,可以通過引發事件來調用該內存。
- 解決辦法
我們已經找到了內存泄漏的元凶,就是訂閱事件時,隱式聲明的匿名委托對內存的引用。最簡單的解決辦法是手動取消訂閱事件,只要TestEvent -= memory.Run就可以了。但如何實現一個不需要手動取消訂閱的事件?該問題的解決辦法是使用一種和普通的引用不同的方式來引用方法的實例對象:該引用不會影響垃圾回收,不會在GC時被判定為對該內存的引用,也就是“弱引用”。C#中,絕大部分的類型都是強引用。如何實現弱引用?來看一個例子:
static void Main(string[] args){ var obj = new object(); var gcHandle = GCHandle.Alloc(obj, GCHandleType.Weak); Console.WriteLine("gcHandle.Target == null is :{0}", gcHandle.Target == null); obj = null; GC.Collect(); Console.WriteLine("GC.Collect"); Console.WriteLine("gcHandle.Target == null is :{0}", gcHandle.Target == null); Console.ReadLine(); }
當執行GC。Collect后,gcHandle.Target == null 由false 變成了true。這個gcHandle就是obj的一個弱引用。這個類的詳細介紹見 GCHandle 。比較關鍵的是GCHandle.Alloc方法的第二個參數,該參數接受一個枚舉類型。我使用的是GCHandleType.Weak,表明該引用是個弱引用。利用這個方法,就可以封裝一個自己的WeakReference類,代碼如下:

public class WeakReference<TDelegate> : IEquatable<Delegate> { private GCHandle _handle; public WeakReference(Delegate obj) { if (obj == null) return; _handle = GCHandle.Alloc(obj, GCHandleType.Weak); } /// <summary> /// 引用的目標是否還存活(沒有被GC回收) /// </summary> public bool IsAlive { get { return _handle != default(GCHandle) && _handle.Target != null; } } /// <summary> /// 引用的目標 /// </summary> public TDelegate Target { get { if (_handle == default(GCHandle)) return default(TDelegate); return (TDelegate)_handle.Target; } } /// <summary> /// 實現接口,方便與委托的比較 /// </summary> /// <param name="other"></param> /// <returns></returns> public bool Equals(Delegate other) { return _handle != default(GCHandle) && other != null && ((Delegate)_handle.Target).Method.Equals(other.Method); } /// <summary> /// 釋放弱引用 /// </summary> ~WeakReference() { _handle.Free(); } }
我實現了IEquatable<Delegate>接口,該接口能方便的比較WeakReference實例和委托是否指一個方法。利用該類,就可以寫一個自己的弱事件封裝器。

public class WeakEventManager { private readonly List<WeakReference<Delegate>> _delegateList; public WeakEventManager() { _delegateList = new List<WeakReference<Delegate>>(); } /// <summary> /// 訂閱 /// </summary> public void AddHandler(Delegate handler) { if (handler != null) _delegateList.Add(new WeakReference<Delegate>(handler)); } /// <summary> /// 取消訂閱 /// </summary> public void RemoveHandler(Delegate handler) { if (handler == null) return; //由於我實現了IEquatable<Delegate>,這里能夠很方便的比較 var sameHandler = _delegateList.FirstOrDefault(e => e.Equals(handler)); if (sameHandler != null) _delegateList.Remove(sameHandler); } /// <summary> /// 引發事件 /// </summary> public void Raise(object sender, EventArgs e) { foreach (var d in _delegateList.ToList()) { if (d.IsAlive) d.Target.DynamicInvoke(sender, e); else _delegateList.Remove(d); } } }
最后,就可以像下面這樣定義自己的事件了
public class TestEventClass { private WeakEventManager<Action<object, EventArgs>> _testEvent = new WeakEventManager<Action<object, EventArgs>>(); public event Action<object, EventArgs> TestEvent { add { _testEvent.AddHandler(value); } remove { _testEvent.RemoveHandler(value); } } protected virtual void OnEvent(EventArgs e) { _testEvent.Raise(this, e); } }
這里,要感謝@delowly網友的提醒,告訴我代碼有錯誤,達不到效果。這個簡易的弱事件是改版的,老版本由於我沒有測試代碼,就放到了文章中,造成了錯誤,對此深表歉意。我保證以后所有的文章中寫的代碼都要經過反復的測試。
針對評論區@delowly的評論,我看了它代碼,它的和我的最大的不同是在WeakReference沒有實現IEquatable,要取消訂閱,必須要調用WeakReference的實例,很是不方便。
與人分享自己的想法的感覺很棒,我會在以后的日子里寫更多個人的心得,來和大家分享。歡迎在評論區與我交流。