想必有些朋友也經常使用事件,可是不多解除事件掛鉤,程序也沒有據說過內存泄漏之類的問題。幸運的是,在某些狀況下,的確不會出問題,不少年前作的項目就跑得好好的,包括我也是,雖然如此,但也不能一直心存僥倖,總得搞清楚這類內存泄漏的神祕事件是怎麼發生的吧,咱們今天能夠作一個實驗來再次驗證下。ide
能夠,爲了驗證這個問題,我一度懷疑本身代碼寫錯了,甚至照着書上(網上)例子寫也沒法重現事件引發內存泄漏的問題,難道教科書說錯了麼?測試
首先來看看個人代碼,先準備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 ()); } }
而後,在主程序裏面寫下面的方法:this
static void TestInitEvent(A a) { var b = new B(100 * 1024 * 1024); a.ToDoSomething += b.PrintA; }
這裏將初始化一個 100M的B的實例對象b,而後讓對象a的事件ToDoSomething 掛鉤在b的方法PrintA 上。日常狀況下,b是方法內部的局部變量,在方法外就是不可訪問的,但因爲b對象的方法掛鉤在了方法參數 a 對象的事件上,因此在這裏對象 b的生命週期並無結束,這能夠稍後由對象 a發起事件,b的 PrintA 方法被調用獲得證明。spa
PS:有朋友問爲什麼不在這裏寫取消掛鉤的代碼,我這裏是研究使用的,實際項目代碼通常不會這麼寫。調試
爲了監測當前測試耗費了多少內存,準備一個方法 getWorkingSet,代碼以下:code
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(); }
下面,開始在主程序裏面開始寫以下測試代碼:orm
getWorkingSet(); A a = new A(); TestInitEvent(a); Console.WriteLine("1,按下任意鍵開始垃圾回收"); Console.ReadKey(); GC.Collect(); getWorkingSet();
看屏幕輸出:對象
---------當前進程名稱:ConsoleApplication1.vshost----------- 4848 工做集(進程類)25,260.00 KB 工做集 25,260.00 KB 私有工做集 8,612.00 KB -------------------------------------------------------- 1,按下任意鍵開始垃圾回收 ---------當前進程名稱:ConsoleApplication1.vshost----------- 4848 工做集(進程類)135,236.00 KB 工做集 135,236.00 KB 私有工做集 111,256.00 KB
程序開始運行後,正好多了100M內存佔用。當前程序處於IDE的調試狀態下,而後,咱們直接運行測試程序,不調試(Release),再次看下結果:blog
---------當前進程名稱:ConsoleApplication1----------- 7056 工做集(進程類)10,344.00 KB 工做集 10,344.00 KB 私有工做集 7,036.00 KB -------------------------------------------------------- 1,按下任意鍵開始垃圾回收 ---------當前進程名稱:ConsoleApplication1----------- 7056 工做集(進程類)121,460.00 KB 工做集 121,460.00 KB 私有工做集 109,668.00 KB --------------------------------------------------------
能夠看到在Release 編譯模式下,內存仍是無法回收。
分析下上面這段測試程序,咱們只是在一個單獨的方法內掛鉤了一個事件,而且事件尚未執行,緊接着開始垃圾回收,但結果顯示沒有回收成功。這個符合咱們教科書上說的狀況:對象的事件掛鉤以後,若是不解除掛鉤,可能形成內存泄漏。
同時,上面的結果也說明了被掛鉤的對象 b 沒有被回收,這能夠發起事件來測試下,看b對象是否還可以繼續處理對象a 發起的事件,繼續上面主程序代碼:
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----------- 7056 工做集(進程類)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----------- 7056 工做集(進程類)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 --------------------------------------------------------
此次的確印證了前面的說明,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----------- 6576 工做集(進程類)10,344.00 KB 工做集 10,344.00 KB 私有工做集 7,240.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1----------- 6576 工做集(進程類)121,500.00 KB 工做集 121,500.00 KB 私有工做集 110,292.00 KB -------------------------------------------------------- 4,按下任意鍵開始註銷事件,以後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1----------- 6576 工做集(進程類)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----------- 4424 工做集(進程類)10,308.00 KB 工做集 10,308.00 KB 私有工做集 7,040.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1----------- 4424 工做集(進程類)121,256.00 KB 工做集 121,256.00 KB 私有工做集 7,592.00 KB -------------------------------------------------------- 4,按下任意鍵開始註銷事件,以後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1----------- 4424 工做集(進程類)19,436.00 KB 工做集 19,436.00 KB 私有工做集 7,600.00 KB --------------------------------------------------------
大跌眼鏡:竟然沒有發生大量內存佔用的狀況!
看來只有一個可能性:
對象a 在GC回收內存以前,沒有操做事件之類的代碼,所以能夠很是明確對象a 以前的事件代碼再也不有效,相關的對象b能夠在 TestInitEvent(a); 方法調用以後馬上回收,這樣就看到了如今的測試結果。
若是不是 Release 編譯模式優化,咱們來看看在IDE調試或者Debug編譯模式運行的結果(前面的代碼不作任何修改):
---------當前進程名稱:ConsoleApplication1.vshost----------- 8260 工做集(進程類)25,148.00 KB 工做集 25,148.00 KB 私有工做集 9,816.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1.vshost----------- 8260 工做集(進程類)136,048.00 KB 工做集 136,048.00 KB 私有工做集 112,888.00 KB -------------------------------------------------------- 4,按下任意鍵開始註銷事件,以後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1.vshost----------- 8260 工做集(進程類)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----------- 8652 工做集(進程類)26,344.00 KB 工做集 26,344.00 KB 私有工做集 9,452.00 KB -------------------------------------------------------- ---------當前進程名稱:ConsoleApplication1.vshost----------- 8652 工做集(進程類)135,628.00 KB 工做集 135,628.00 KB 私有工做集 10,008.00 KB -------------------------------------------------------- 4,按下任意鍵開始註銷事件,以後再次垃圾回收 5,垃圾回收完成 ---------當前進程名稱:ConsoleApplication1.vshost----------- 8652 工做集(進程類)33,768.00 KB 工做集 33,768.00 KB 私有工做集 10,008.00 KB --------------------------------------------------------
符合預期,內存佔用量沒有增長,因此此時調用GC回收內存都沒有意義了。
不必定,若是發起事件的對象生命週期比較短,不是靜態對象,不是單例對象,當該對象生命週期結束的時候,GC能夠回收該對象,只不過,該對象可能要通過多代才能成功回收,而且每一次回收什麼時候才執行是不肯定的,回收的代數越長,那麼最後被回收的時間越長。
因此,若是發起事件的對象不是根對象,而是附屬於另一個生命週期很長的對象,不解除事件掛鉤,這些處理事件的對象也不能被釋放,因而內存泄漏就發生了。
爲了不潛在發生內存泄漏的問題,咱們應該養成不使用事件就馬上解除事件掛鉤的良好習慣!
不必定,除非你很是清楚要在什麼時候回收內存而且確定此時GC可以有效工做,好比像本文測試的例子這樣,不然,調用GC非但沒有效果,可能還會引發反作用,好比引發整個應用程序的暫停業務處理。
使用事件的時候若是不在使用完以後解除事件掛鉤,有可能發生內存泄漏,
GC內存回收的時機的確具備不肯定性,因此GC不是救命稻草,最佳的作法仍是用完事件當即解除事件掛鉤。
若是你忘記了這個事情,也請必定不要忘記發佈程序的時候,使用Release編譯模式!