Release編譯模式下,事件是否會引起記憶體泄漏問題初步研究

来源:http://www.cnblogs.com/bluedoctor/archive/2016/03/13/5268615.html
-Advertisement-
Play Games

GC記憶體回收的時機的確具有不確定性,所以GC不是救命稻草,請一定不要忘記發佈程式的時候,使用Release編譯模式!


題記:不常發生的事件記憶體泄漏現象

想必有些朋友也常常使用事件,但是很少解除事件掛鉤,程式也沒有聽說過記憶體泄漏之類的問題。幸運的是,在某些情況下,的確不會出問題,很多年前做的項目就跑得好好的,包括我也是,雖然如此,但也不能一直心存僥幸,總得搞清楚這類記憶體泄漏的神秘事件是怎麼發生的吧,我們今天可以做一個實驗來再次驗證下。

可以,為了驗證這個問題,我一度懷疑自己代碼寫錯了,甚至照著書上(網上)例子寫也無法重現事件引起記憶體泄漏的問題,難道教科書說錯了麽?

首先來看看我的代碼,先準備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的事件ToDoSomething 掛鉤在b的方法PrintA 上。平常情況下,b是方法內部的局部變數,在方法外就是不可訪問的,但由於b對象的方法掛鉤在了方法參數 a 對象的事件上,所以在這裡對象 b的生命周期並沒有結束,這可以稍後由對象 a發起事件,b的 PrintA 方法被調用得到證實。

PS:有朋友問為何不在這裡寫取消掛鉤的代碼,我這裡是研究使用的,實際項目代碼一般不會這麼寫。

為了監測當前測試耗費了多少記憶體,準備一個方法  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-----------
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),再次看下結果:

---------當前進程名稱: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編譯模式!

 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 要理解async和await的用法,首先要瞭解Task相關知識,這裡不做說明,因為這不是本文的重點。 如果你已經對Task很瞭解,那麼如何使用async和await,在此主要總結了以下三點:   對於第三點說的有點繞,所以下麵結合代碼說一下:   執行結果:   對結果解釋是: Main方法調用具有
  • 效果圖 直接附帶code http://files.cnblogs.com/52net/MultiSelectDropDownEx.zip
  • 如果是直接執行SQL語句時,事務很好處理,對於大多數的Erp應用,不能能用SQL來處理數據,所以更新DataSet更為常用,更新單個的DataSet也非常簡單,不需要事務的處理,給多個DataSet增加事務多數應用於分散式的程式代碼中,下麵為在Webservice中更新Winform傳遞過來的經過壓
  • 一、什麼是特性路由? 特性路由是指將RouteAttribute或自定義繼承自RouteAttribute的特性類標記在控制器或ACTION上,同時指定路由Url字元串,從而實現路由映射,相比之前的通過Routes.Add或Routes.MapHttpRoute來講,更加靈活與直觀。 若要使用特性路
  • 一、開發環境 操作系統:Win10 編譯器:VS2013 framework版本:.net 4.5 Spring版本:1.3.1 二、涉及程式集 Spring.Core.dll Common.Loggin.dll 三、項目結構 四、開發過程 1.新建一個介面文件 namespace SpringNe...
  •    
  • 屬性分為無參屬性和有參屬性(即索引器)。 屬性相對於欄位的優點不僅僅是為了封裝,還可以在讀寫的時候做一些額外操作,緩存某些值或者推遲創建一些內部對象,也適用於以線程安全的方式訪問欄位。 話說最基本的屬性就不講了,太平常了。 基本上很多文章都是講屬性的好處的,所以下麵就講一下屬性的不足: 屬性不能作為
  • C#語法中有個特別的關鍵字yield, 它是乾什麼用的呢? 來看看專業的解釋: yield 是在迭代器塊中用於向枚舉數對象提供值或發出迭代結束信號。它的形式為下列之一:yield return <expression>;yield break   看如下例子:         上面的例子是實現了一個
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...