想必有些朋友可能也常常使用事件,但是很少解除事件掛鉤程序也沒有聽說過內存洩漏之類的問題。幸運的是,在某些情況下,的確不會出問題,很多年前做的項目就跑得好好的,我們靜態可以做一個實驗來再次驗證下。為了驗證這個問題,我一度懷疑自己代碼寫錯了,甚至照著書上(網上)例子寫也無法重現事件引起內存洩漏的問題,難道教科書說錯了麼?
首先來看看我的代碼,先准備2個類,一個發起事件,一個處理事件:
class A { public event EventHandler ToDoSomething ; public A() { } public void RaiseEvent() { ToDoSomething(this, new EventArgs()); } public void DelEvent() { ToDoSomething = null; } public void Print(string msg) { Console.WriteLine("A:{0}", msg); } } class B { byte[] data = null; public B(int size) { data = new byte[size]; for (int i = 0; i < size ; i++) data[i] = 0; } public void PrintA(object sender, EventArgs e) { ((A)sender).Print("sender:"+ sender.GetType ()); } }
然後,在主程序裡面寫下面的方法:
static void TestInitEvent(A a) { var b = new B(100 * 1024 * 1024); a.ToDoSomething += b.PrintA; }
這裡將初始化一個 100M的對象B的實例b,然後讓對象a的事件掛鉤在b的方法PrintA 上。平常情況下,b是方法內部的局部變量,但由於b對象的方法掛鉤在了方法參數 a 對象的事件上,所以在這裡對象 b的生命周期沒有結束,這可以稍後由對象 a發起事件,b的 PrintA 方法被調用得到證實。
為了監測當前測試耗費了多少內存,准備一個方法 getWorkingSet,代碼如下:
static void getWorkingSet() { using (var process = Process.GetCurrentProcess()) { Console.WriteLine("---------當前進程名稱:{0}-----------",process.ProcessName); using (var p1 = new PerformanceCounter("Process", "Working Set - Private", process.ProcessName)) using (var p2 = new PerformanceCounter("Process", "Working Set", process.ProcessName)) { Console.WriteLine(process.Id); //注意除以CPU數量 Console.WriteLine("{0}{1:N} KB", "工作集(進程類)", process.WorkingSet64 / 1024); Console.WriteLine("{0}{1:N} KB", "工作集 ", process.WorkingSet64 / 1024); // process.PrivateMemorySize64 私有工作集 不是很准確,大概多9M Console.WriteLine("{0}{1:N} KB", "私有工作集 ", p1.NextValue() / 1024); //p1.NextValue() //Logger("{0};內存(專用工作集){1:N};PID:{2};程序名:{3}", // DateTime.Now, p1.NextValue() / 1024, process.Id.ToString(), process.ProcessName); } } Console.WriteLine("--------------------------------------------------------"); Console.WriteLine(); }
下面,開始在主程序裡面開始寫如下測試代碼:
getWorkingSet(); A a = new A(); TestInitEvent(a); Console.WriteLine("1,按下任意鍵開始垃圾回收"); Console.ReadKey(); GC.Collect(); getWorkingSet();
看屏幕輸出:
---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)25,260.00 KB 工作集 25,260.00 KB 私有工作集 8,612.00 KB -------------------------------------------------------- 1,按下任意鍵開始垃圾回收 ---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)135,236.00 KB 工作集 135,236.00 KB 私有工作集 111,256.00 KB
程序開始運行後,正好多了100M內存占用。當前程序處於IDE的調試狀態下,然後,我們直接運行測試程序,不調試(Release),再次看下結果:
---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)10,344.00 KB 工作集 10,344.00 KB 私有工作集 7,036.00 KB -------------------------------------------------------- 1,按下任意鍵開始垃圾回收 ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)121,460.00 KB 工作集 121,460.00 KB 私有工作集 109,668.00 KB --------------------------------------------------------
可以看到在Release 編譯模式下,內存還是沒法回收。
分析下上面這段測試程序,我們只是在一個單獨的方法內掛鉤了一個事件,並且事件還沒有執行,緊接著開始垃圾回收,但結果顯示沒有成功。這個符合我們教科書上說的情況:對象的事件掛鉤之後,如果不解除掛鉤,可能造成內存洩漏。
同時,上面的結果也說明了被掛鉤的對象 b 有沒有被回收,那麼發起事件來測試下,看b對象是否還能夠繼續處理別人發起的事件,繼續上面主程序代碼:
Console.WriteLine("2,按下任意鍵,主對象發起事件"); Console.ReadKey(); a.RaiseEvent();//此處內存不能正常回收 getWorkingSet();
結果:
2,按下任意鍵,主對象發起事件 A:sender:ConsoleApplication1.A ---------當前進程名稱:ConsoleApplication1----------- 7056 工作集(進程類)121,576.00 KB 工作集 121,576.00 KB 私有工作集 109,672.00 KB --------------------------------------------------------
這說明,雖然對象 b 脫離了方法 TestInitEvent 的范圍,但它依然存活,打印了一句話:A:sender:ConsoleApplication1.A
是不是GC多回收幾次才能夠成功呢?
我們繼續在主程序上調用GC試試看:
Console.WriteLine("3,按下任意鍵開始垃圾回收,之後再次發起事件"); Console.ReadKey(); GC.Collect(); a.RaiseEvent();//此處內存不能正常回收 getWorkingSet();
結果:
3,按下任意鍵開始垃圾回收,之後再次發起事件 A:sender:ConsoleApplication1.A ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)14,424.00 KB 工作集 14,424.00 KB 私有工作集 2,972.00 KB --------------------------------------------------------
果然,內存被回收了,但請注意,我們在GC執行成功後,仍然調用了發起事件的方法 a.RaiseEvent();並且得到了成功執行,這說明,對象b 仍然存活,不過它內部大量無用的內存被回收了。
注意:上面這段代碼的結果是我再寫博客過程中,一邊寫一遍測試偶然發現的情況,如果是連續執行的,情況並不是這樣,上面這端代碼不能回收成功內存。
這說明,GC內存回收的時機,的確是不確定的。
繼續,我們注銷事件,解除事件掛鉤,再看結果:
Console.WriteLine("4,按下任意鍵開始注銷事件,之後再次垃圾回收"); Console.ReadKey(); a.DelEvent(); GC.Collect(); Console.WriteLine("5,垃圾回收完成"); getWorkingSet();
結果:
4,按下任意鍵開始注銷事件,之後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)15,252.00 KB 工作集 15,252.00 KB 私有工作集 3,196.00 KB --------------------------------------------------------
內存沒有明顯變化,說明之前的內存的確成功回收了。
為了印證前面的猜測,我們讓程序重新運行並且連續執行(Release模式),來看看執行結果:
---------當前進程名稱:ConsoleApplication1----------- 4280 工作集(進程類)10,364.00 KB 工作集 10,364.00 KB 私有工作集 7,040.00 KB -------------------------------------------------------- 1,按下任意鍵開始垃圾回收 ---------當前進程名稱:ConsoleApplication1----------- 4280 工作集(進程類)121,456.00 KB 工作集 121,456.00 KB 私有工作集 109,668.00 KB -------------------------------------------------------- 2,按下任意鍵,主對象發起事件 A:sender:ConsoleApplication1.A ---------當前進程名稱:ConsoleApplication1----------- 4280 工作集(進程類)121,572.00 KB 工作集 121,572.00 KB 私有工作集 109,672.00 KB -------------------------------------------------------- 3,按下任意鍵開始垃圾回收,之後再次發起事件 A:sender:ConsoleApplication1.A ---------當前進程名稱:ConsoleApplication1----------- 4280 工作集(進程類)121,628.00 KB 工作集 121,628.00 KB 私有工作集 109,672.00 KB -------------------------------------------------------- 4,按下任意鍵開始注銷事件,之後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1----------- 4280 工作集(進程類)19,228.00 KB 工作集 19,228.00 KB 私有工作集 7,272.00 KB --------------------------------------------------------View Code
這次的確印證了前面的說明,GC真正回收內存的時機是不確定的。
精簡下之前的測試代碼,僅初始化事件對象然後就GC回收,看看結果:
getWorkingSet(); A a = new A(); TestInitEvent(a); getWorkingSet(); Console.WriteLine("4,按下任意鍵開始注銷事件,之後再次垃圾回收"); Console.ReadKey(); a.DelEvent(); GC.Collect(); Console.WriteLine("5,垃圾回收完成"); getWorkingSet(); Console.ReadKey();
結果:
---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)10,344.00 KB 工作集 10,344.00 KB 私有工作集 7,240.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)121,500.00 KB 工作集 121,500.00 KB 私有工作集 110,292.00 KB -------------------------------------------------------- 4,按下任意鍵開始注銷事件,之後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)19,788.00 KB 工作集 19,788.00 KB 私有工作集 7,900.00 KB --------------------------------------------------------
符合預期,GC之後內存恢復到正常水平。
將上面的代碼稍加修改,僅僅注釋掉GC前面的一句代碼:a.DelEvent();
getWorkingSet(); A a = new A(); TestInitEvent(a); getWorkingSet(); Console.WriteLine("4,按下任意鍵開始注銷事件,之後再次垃圾回收"); Console.ReadKey(); //a.DelEvent(); GC.Collect(); Console.WriteLine("5,垃圾回收完成"); getWorkingSet(); Console.ReadKey();
再看結果:
---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)10,308.00 KB 工作集 10,308.00 KB 私有工作集 7,040.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)121,256.00 KB 工作集 121,256.00 KB 私有工作集 7,592.00 KB -------------------------------------------------------- 4,按下任意鍵開始注銷事件,之後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1----------- 工作集(進程類)19,436.00 KB 工作集 19,436.00 KB 私有工作集 7,600.00 KB --------------------------------------------------------
大跌眼鏡:居然沒有發生大量內存占用的情況!
看來只有一個可能性:
對象a 在GC回收內存之前,沒有操作事件之類的代碼,因此可以非常明確對象a 之前的事件代碼不再有效,相關的對象b可以在 TestInitEvent(a); 方法調用之後立刻回收,這樣就看到了現在的測試結果。
如果不是 Release 編譯模式優化,我們來看看在IDE調試或者Debug編譯模式運行的結果(前面的代碼不做任何修改):
---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)25,148.00 KB 工作集 25,148.00 KB 私有工作集 9,816.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)136,048.00 KB 工作集 136,048.00 KB 私有工作集 112,888.00 KB -------------------------------------------------------- 4,按下任意鍵開始注銷事件,之後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)136,692.00 KB 工作集 136,692.00 KB 私有工作集 112,892.00 KB --------------------------------------------------------
這一次,盡管仍然調用了GC垃圾回收,但實際上根本沒有立刻起到效果,內存仍然100多M。
最後,我們在發起事件掛鉤之後,立即解除事件掛鉤,再看下Debug模式下的結果,為此僅僅需要修改下面代碼一個地方:
static void TestInitEvent(A a) { var b = new B(100 * 1024 * 1024); a.ToDoSomething += b.PrintA; // a.ToDoSomething -= b.PrintA; }
然後看在Debug模式下的執行結果:
---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)26,344.00 KB 工作集 26,344.00 KB 私有工作集 9,452.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)135,628.00 KB 工作集 135,628.00 KB 私有工作集 10,008.00 KB -------------------------------------------------------- 4,按下任意鍵開始注銷事件,之後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1.vshost----------- 工作集(進程類)33,768.00 KB 工作集 33,768.00 KB 私有工作集 10,008.00 KB --------------------------------------------------------
符合預期,內存占用量沒有增加,所以此時調用GC回收內存都沒有意義了。
使用事件的時候如果不在使用完之後解除事件掛鉤,的確可能發生內存洩漏,
並且GC內存回收的時機的確具有不確定性,所以GC不是救命稻草,最佳的做法還是用完事件立即解除事件掛鉤。
如果你忘記了這個事情,也請一定不要忘記發布程序的時候,使用Release編譯模式!