Release編譯模式下,事件是否會引發內存泄漏問題初步研究

題記:不常發生的事件內存泄漏現象

想必有些朋友也經常使用事件,可是不多解除事件掛鉤,程序也沒有據說過內存泄漏之類的問題。幸運的是,在某些狀況下,的確不會出問題,不少年前作的項目就跑得好好的,包括我也是,雖然如此,但也不能一直心存僥倖,總得搞清楚這類內存泄漏的神祕事件是怎麼發生的吧,咱們今天能夠作一個實驗來再次驗證下。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
--------------------------------------------------------
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-----------
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內存回收的時機的確具備不肯定性,因此GC不是救命稻草,最佳的作法仍是用完事件當即解除事件掛鉤。

若是你忘記了這個事情,也請必定不要忘記發佈程序的時候,使用Release編譯模式!

相關文章
相關標籤/搜索