.NET 內存泄漏分析

目的

相信很多小夥伴,除了編碼以外,還經常需要和服務器打交道,處理服務器警報,這些警報中最常見的問題之一就是內存泄漏,大部分時候這個問題很難通過傳統的日誌手段來定位,所以很多的小夥伴遇見了內存泄漏問題常常急的抓耳撓腮,一邊百度(現在有了ChatGPT),一邊連蒙帶猜的嘗試,運氣好,完美定位,運氣不好,加內存!!!不過,目前網上的大部分文章都是着眼於解決具體問題,很少會系統性的提及.NET的內存結構和內存泄漏爲什麼會產生,所以我打算寫兩到三篇文章來說一說內存泄漏以及如何分析內存泄漏,希望能對大家解決此類問題有所幫助。

內存管理

由於內存的執行速度遠超過硬盤,所以計算程序正常都是將數據加載到內存中執行,雖然現代計算機的內存足夠大,但隨着程序的不斷執行,內存依舊會不夠用,所以需要對內存進行管理。不過內存管理是一件很麻煩的事兒,爲了讓開發人員高效編程,.NET CLR爲開發人員提供了自動內存管理服務,由GC(垃圾回收器)管理應用程序內存的分配和釋放。

餐廳模型

內存管理是一個很抽象的概念,我一直在思考怎麼更形象的幫助大家去理解它,直到有一天我和幾個同事一起去餐廳喫飯時,突然想到餐廳喫飯和內存管理不是很相似嘛,下面我就以餐廳來舉例,.NET內存管理都做了什麼。

假設我開了一家餐廳,這個餐廳有4張桌子,每張桌子有4個位置,那16個位置就是我這個餐廳所能容納最大客流量了,我們稱之爲物理內存(比如16G內存)。當然,我作爲老闆,有時候爲了多賺錢,我就在餐廳外(磁盤)又擺了兩張桌子,客戶也可以用,我們稱之爲虛擬內存,只不過因爲在餐廳外的緣故,點餐和送餐的響應總是比餐廳內要慢(內存交換)。

注:這裏我簡化了概念,實際上同一臺機器上的所有進程共享物理內存。
餐廳來了客人,餐廳總歸要知道還有沒有空桌,客人喫完了,桌子是不是可以清掃一下迎接下一波客人,不過我這個當老闆的忙前忙後,沒有那麼多精力處理這些事兒,所以我專門請了個員工(GC)來幫我管理餐位,畢竟客人坐不到位置就會去投訴我們(內存溢出)。GC帶客人去對應餐桌的過程我門稱之爲分配內存;客人用餐完畢離開後,GC清掃餐桌供下一桌客人使用的過程我們稱爲垃圾回收,GC主要乾的就是這兩件事兒。我們觀察上圖可以發現,不管1號桌還是2號桌亦或者3號桌,都是沒有坐滿的(紅色代表有繁忙,綠色表示空閒),這3張桌子雖有空閒也不太可能再次安排人去坐,於是內存碎片就產生了。一般情況下,餐廳客來客往,餐廳正常運轉,但是某些情況會導致客人走了之後,GC無法正常的垃圾回收,於是內存泄漏就產生了,直到內存耗盡,應用程序崩潰。

.NET 內存模型

.NET中主要使用以下幾種內存:堆棧、非託管堆和託管堆,下面我簡單介紹以下它們:

堆棧

堆棧是應用程序執行期間用於存儲局部變量、方法參數、返回值和其他臨時值的地方,就像餐廳知道你點的每道菜,這些菜在後廚製作,但這只是暫時的,堆棧會爲每個線程分配工作的暫存區。GC並負責清理堆棧,方法返回時,堆棧空間會自動處理。雖然堆棧會自動清理,但並不表示堆棧就不會發生泄漏,我知道的堆棧泄漏就有兩種:一種是進行極其耗費資源且從不返回的方法調用,從而使其關聯的堆棧無法釋放(如例1中的無限遞歸);另一種是線程堆棧泄漏,如果一個應用程序只顧着創建線程而忽視了去終止這些線程,就可能引發線程堆棧泄漏。
例1:無限遞歸

internal class RedisPool
{
    protected static Dictionary<string, ConnectionMultiplexer> ConnPool = new Dictionary<string, ConnectionMultiplexer>(); 

    static SemaphoreSlim hore = new SemaphoreSlim(1);

    public static ConnectionMultiplexer GetRedisConn(string connStr, int rDb = 0)
    {
        try
        {
            ConnPool.TryGetValue(connStr, out var conn);
            if (conn == null)
            {
                hore.Wait();
                conn = ConnectionMultiplexer.Connect(connStr);
                ConnPool.Add(connStr, conn);
            	hore.Release();
            }
        }
        catch (Exception ex)
        {
            hore.Release();
            GetRedisConn(connStr, rDb);
        }
        return ConnPool[connStr];
    }
}

我本人不太喜歡用遞歸,因爲遞歸會讓程序的執行邏輯變得複雜,上面的這段代碼就是一個平時運行起來正常但特定情況下無限遞歸的例子。在示例中,我們需要通過GetRedisConn方法獲取Redis鏈接,但是如果Redis無法正常鏈接,就會引發GetRedisConn的無限遞歸。
例2:線程泄漏

    class Program
    {
        static void Main(string[] args)
        {
            for (int i = 0; i < 1000; i++)
            {
                Console.WriteLine("創建一個新的線程:");
                Thread t = new Thread(() => { TreadProc(); });
                t.Start();
                Console.ReadLine();
            }
        }

        static void TreadProc()
        {
            Thread.CurrentThread.Join();
        }
    }

上面的例子中,線程啓動後,嘗試調用他自己的Join()方法,這就導致線程陷入一個自己等待自己終止的尷尬局面,如果打開任務管理器,就會發現每次按下回車時,內存都會增長。堆棧空間不足時,最可能引發的異常爲StackOverflowException,在.NET中,每個線程都有一個默認的堆棧大小,在32位平臺上爲1MB,64位平臺上爲4MB。如果在性能計數器(perfmon)中,我們觀察到 Process->Private Bytes和.NET CLR LocksAndThreads-># of current logical Threads隨着時間的的推移同步增大,那麼罪魁禍首就極有可能是線程堆棧泄漏。

非託管堆

.NET中分爲託管堆和非託管堆,之所以把非託管堆放到前面來講,是因爲GC並不負責管理非託管堆的內存,需要你使用完後,顯示的釋放資源,所以並不需要太多的文字來闡述。非託管堆就像一些外帶的客戶,在餐廳裏佔了位置,喫的卻是自帶的食物,古板的GC並不知道如何清理這些垃圾,這都需要需要客戶自行清理。.NET中最常用的非託管資源類型是包裝操作系統資源的對象,如文件、窗口、Win32API、網絡連接和數據庫連接。.NET提供了Dispose和Finalize兩種方式來確定非託管資源的釋放。
非託管內存泄漏的最常見問題就是忘記調用Dispose,如下面這個例子:

   class Program
    {
        static void Main(string[] args)
        {
            var qUrl = "http://www.baidu.com";
            ServicePointManager.DefaultConnectionLimit = 1000;
            for (int i = 0; i < 1000; i++)
            {
                HttpWebRequest req = (HttpWebRequest)WebRequest.Create(qUrl);
                req.Method = "GET";
                req.ContentType = "application/x-www-form-urlencoded";
                var rep = req.GetResponse();
                Console.WriteLine(rep.ContentLength);
                //rep.Dispose();
                Console.ReadLine();
            }
        }
    }

示例中的第12行,返回了WebResponse對象,這個對象繼承了IDisposable接口,但示例中我們卻沒有使用Dispose()方法,當然你也可以通過using來釋放資源,比如這樣:

    class Program
    {
        static void Main(string[] args)
        {
            var qUrl = "http://www.baidu.com";
            //ServicePointManager.DefaultConnectionLimit = 1000;
            for (int i = 0; i < 1000; i++)
            {
                HttpWebRequest req = (HttpWebRequest)WebRequest.Create(qUrl);
                req.Method = "GET";
                req.ContentType = "application/x-www-form-urlencoded";
                using (var rep = req.GetResponse())
                {
                    Console.WriteLine(rep.ContentLength);
                }
                Console.ReadLine();
            }
        }
    }

題外話:我這個人總喜歡說一些廢話,相信細心的同學會發現,我額外寫的第6行代碼,這是因爲.NET在創建HTTP鏈接時會爲了複用TCP鏈接,對於相同的主機和端口,會複用ServicePoint對象,但是.NET中ServicePoint的最大併發連接默認爲2,超出這個連接的請求會等待直至超時。所以有時候接口調用超時並不一定是服務端接口的問題,也有可能是ServicePoint併發達到了瓶頸。
不過,我們也會遇見一些不調用Dispose或Close方法,內存卻並未升高的情況,如StreamReader,這是因爲.NET框架進行了優化,會自動進行垃圾回收。但是爲了保證代碼的健壯性,還是建議大家在使用非託管代碼時,手動的釋放資源。
當然,還有一種更爲隱蔽的泄漏方式,就是錯誤的使用我們前面提到的終結器(Finalize,也有人叫他析構器),終結器可以告訴GC如何清理實例的資源,畢竟總有些人會忘記調用Dispose,請看下面這個例子:

    internal class Car
    {
        public Car(string color, string model)
        {
            Color = color;
            Model = model;
        }

        public string Color { get; set; }

        public string Model { get; set; }

        public void Run()
        {
            Console.WriteLine("一輛{0}的{1}在路上行駛", Color, Model);
        }

        /// <summary>
        /// 終結器
        /// </summary>
        ~Car()
        {
            if (Model == "特斯拉")
                throw new Exception("新車不讓報廢");
            Console.WriteLine("準備銷燬{0}", Model);
            Color = null;
            Model = null;
        }
    }
  class Program
    {
        static void Main(string[] args)
        {
            try
            {
                RunningCar();
                GC.Collect();//手動觸發GC
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("執行完畢");
            Console.ReadLine();
        }

        public static void RunningCar()
        {
            Car car1 = new Car("紅色", "桑塔納");
            car1.Run();

            Car car2 = new Car("紅色", "特斯拉");
            car2.Run();
        }
    }

在上述的例子中,我在終結器中拋出了異常,特定情況下會觸發,由於CLR中只維護了一個線程來處理終結器,這會導致某些情況下資源無法被正確回收。不過由於終結器的調用時間不確定,程序的性能和可靠性都無法得到保證,微軟從.NET 5開始廢棄了終結器的使用。

程序集泄漏

其實程序集泄漏放在這個位置並不合適,我只是懶得後面再單獨開篇來寫,你可以把他理解爲餐廳中的烹飪流程,以前我是做中餐的,只要洗菜->切菜->炒菜就行;現在我要做西餐,就又引入了一套和麪->發酵->烘焙的流程。當你需要執行程序集中的代碼之前,必須先將程序集加載到應用程序域中。不過一旦程序集被加載,知道AppDomain卸載前,它無法卸載,這本身沒什麼問題,除非你使用了動態編程,我本人並不喜歡動態編程,原因嘛,很簡單,因爲我不熟。但是仍有一種泄漏是需要我們關注的,就是XmlSerializer泄漏,且看下面的例子:

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                var xml = @"<Tesla>
                              <Id>0001</Id>
                              <Color>紅色</Color>
                              <Model>特斯拉</Model>
                            </Tesla>";
                Console.ReadLine();
                for (int i = 0; i < 10000; i++)
                {
                    GetCar(i, "Tesla", xml);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("執行完畢");
            Console.ReadLine();
        }


        public static Car GetCar(int i, string rootName, string xml)
        {
            var xs = new XmlSerializer(typeof(Car), new XmlRootAttribute(rootName));
            using (var textReader = new StringReader(xml))
            {
                using (var xmlReader = XmlReader.Create(textReader))
                {
                    Console.WriteLine(i);
                    return (Car)xs.Deserialize(xmlReader);
                }
            }
        }
    }

    public class Car
    {
        public string Color { get; set; }

        public string Model { get; set; }
    }

泄漏的位置發生在:
var xs = new XmlSerializer(typeof(Car), new XmlRootAttribute(rootName));
按照MSDN官方的說法,Xml序列化會動態生成程序集,當使用下面兩個構造函數時:

XmlSerializer.XmlSerializer(Type)
XmlSerializer.XmlSerializer(Type, String)

這些程序集會被複用,但是如果使用任何其他構造函數,則會生成同一程序集的多個版本,並且永遠不會卸載,這會導致內存泄漏和性能不佳。
要解決這個問題也很簡單,使用上面兩個構造函數,或者緩存生成的程序集。

    [XmlRoot("Tesla")]
    public class Car
    {
        public string Color { get; set; }

        public string Model { get; set; }
    }

    var xs = new XmlSerializer(typeof(Car));

託管堆

初始化新進程時,CLR會爲進程保留一段連續的地址空間用於保存託管對象,這段保留的地址空間被稱爲託管堆,GC就像餐廳的大堂的服務員那樣既負責將食客們領到空閒的位置上(內存分配),又負責清理已經使用完的座位(內存回收)。不過由於大內存對象的回收成本比較高,爲了避免頻繁回收,GC根據對象大小,將託管堆分爲了SOH(小對象堆)和LOH(大對象堆),在.NET 5之後又引入了一個名爲POH(固定堆)用來分配釘住的對象。
SOH通常是一些數據庫連接、大型數組和集合、大型數據結構如樹和圖、緩存、音視頻文件等,由於大內存對象的特性(後續會講到),我們在使用大內存對象時要非常小心,避免出現內存泄漏的問題。
POH是.NET5裏引入的概念,主要爲Pinning(釘住)操作設計,釘住的對象不能被移動,如果釘住的對象很多,會導致內存碎片的增加。

分代壓縮

.NET GC採用分代壓縮算法,該算法基於啓發式算法,即一個對象已經存活了一段時間,那麼它繼續存活的一段時間的概率會非常大。So~,GC堆中的對象被分爲3代:gen0(新生代/第0代)、gen1(中生代/第1代)、gen2(老年代/第2代),分代GC不會在每次回收整個堆,GC更偏向於年輕代的GC,也就是說gen0比gen1回收的更頻繁。如果一個對象在gen0回收後存活下來,就會升代到gen1,以此類推,直到gen2爲止。
● 第0代:最年輕一代,用戶代碼新分配的對象都在這一代,也是回收最頻繁的一代,但如果新對象是大型對象的,則會被分配到LOH(大對象堆)上,也有人稱之爲第3代,這個第3代與第2代在一起回收。大多數對象會在第0代中被回收,不會保留到下一代;
● 第1代:這一代用作短生存期對象和長生存期對象的緩衝區,第0代的託管堆回收後,會壓縮可訪問對象的內存,並升代到第1代,這樣GC在回收時,就不需要重複檢查這部分對象,因爲這部分對象可能的生存期會更長,gen1回收時,會同時回收gen0;
● 第2代:這一代包含長生存期對象,第2代垃圾回收也稱爲完整垃圾回收(Full GC),它會回收所有代中的對象。

回收算法

GC會定期掃描堆中的所有對象,先找到倖存對象,然後清理死對象,最後壓縮倖存對象,GC主要通過以下兩種方式來判斷對象是否存活:

引用計數法

引用計數法是一種最簡單的垃圾回收算法,它通過在對象上維護一個引用計數器來判斷對象是否存活,當對象被引用時,引用計數器加1;當對象被釋放時,引用計數器減1。當引用計數器爲0時,對象即可被GC回收。然而,引用計數法有一個致命缺陷,就是無法處理循環引用。例如,兩個對象之間相互引用時,它們的引用計數器都不爲0,即使它們不再被其他對象引用,也無法被回收,最終導致內存泄漏。

可達性分析法

可達性分析法是一種更爲常用的垃圾回收算法,它通過判斷對象是否可達來判斷對象是否存活。當對象可以被程序中的任意一個根對象(GC根)訪問到時,該對象即爲可達對象;當對象不可被任何一個GC根訪問到時,該對象即爲未被引用的對象,可以被垃圾回收器回收。GC根指的是直接或者間接應用其他對象的對象,如活動線程的堆棧中引用的對象、靜態對象、Finalizer隊列中等待執行的對象,尚未完成異步操作的對象等。

分配與回收

內存是一個動態變化的過程,就像餐廳人來人往,內存也會被GC不停的分配和回收,我們可以通過微軟提供的官方示例來探祕GC的過程:

上圖向大家展示了gen0和gen1的GC過程,由於其GC的時間並不長,所以被稱爲短暫GC,gen0和gen1也總是生活在同一個段上,被稱爲短暫段。如果SOH的增長超過了一個段的容量容量,在GC期間將獲得一個新的段。gen0和gen1所在的段是新的短暫段,另一個段變成了gen2段。

對象回收後,會出現空閒空間,如上圖的Obj0和Obj3,如果空間足夠,新分配的對象會填充進來,但隨着內存塊被分配和釋放的次數越來越多時,就會出現內存碎片,比如在LOH中一個Free空間的大小小於85000字節,這段空間就永遠不可能被利用,所以需要進行壓縮操作。移動對象是一件很耗時的操作,尤其是LOH對象,所以.NET4.5之前GC並不會進行LOH的壓縮操作,碎片會一直存在,.NET4.5之後可以通過GCSettings.LargeObjectHeapCompactionMode設置來指定GC期間壓縮LOH(默認爲不壓縮),當該屬性被設置爲CompactOnce之後,GC會在下一次完整回收時壓縮LOH,不過由於這個過程想當耗時,所以壓縮完後,這個設置又被被置爲默認值Default。

託管內存泄漏

託管堆的泄漏方式五花八門,但大部分的案例都可歸咎於對象引用被長期持有,無法正確釋放這一點,下面我會給出一些我所知道內存泄漏類型(有機會會繼續補充):

1. 對象引用泄漏

當一個對象不再使用時,應用程序未能及時釋放該對象的引用,導致該對象無法被垃圾回收器回收,比較常見的是在事件通信時發生:

    //首先,定義了一個事件發佈者,用於發佈TaskChanged事件
    /// <summary>
    /// 事件發佈。
    /// </summary>
    internal class Publisher
    {
        public Action<object> TaskChanged; //變更事件
    }

    //然後,定義一個消費者來訂閱事件
    /// <summary>
    /// 訂閱者
    /// </summary>
    internal class Subscriber
    {
        public int Id;

        private readonly Publisher _publisher;

        public Subscriber(int i, Publisher publisher)
        {
            _publisher = publisher;
            _publisher.TaskChanged += OnChanged; //訂閱OnChange事件
            Id = i;
        }

        public void UnSubscriber()
        {
            _publisher.TaskChanged -= OnChanged; //取消訂閱事件
        }

        public void OnChanged(object sender)
        {

        }

        ~Subscriber()
        {
            Console.WriteLine("實例{0}被回收", Id); //在GC時會觸發
        }
    }

    internal class Task01
    {
        public static void Run()
        {
            Publisher mychange = new Publisher();

            for (int i = 0; i < 100; i++)
            {
                Subscriber task = new Subscriber(i, mychange);
                //task.UnSubscriber(); //如果忘記取消訂閱,則會導致對象引用泄漏
            }

            GC.Collect();//手動GC,如果沒有取消訂閱,終結器~Subscriber不會觸發,取消了訂閱後,~Subscriber纔會觸發
            Console.ReadLine();
        }
    }

示例中的100個task實例,在mychange消亡前由於其引用被長期持有,所以無法被GC回收,所以在編碼過程中,我們應當儘量避免長期生存對象引用生存期較短的對象。

2. 靜態對象泄漏

靜態對象泄漏也是一種比較常見的引用泄漏,比如一些開發者喜歡用靜態對象做緩存,但卻忘了移除他們:
當一個對象不再使用時,應用程序未能及時釋放該對象的引用,導致該對象無法被垃圾回收器回收,比較常見的是在事件通信時發生:

    //用戶信息
    public class UserInfo
    {
        public int Id { get; set; }

        public string Name { get; set; }

        public string BirthDay { get; set; }

        ~UserInfo()
        {
            Console.WriteLine("用戶{0}被釋放", Id);
        }
    }

	//用戶緩存
    internal class LoginCache
    {
        public static Dictionary<int, UserInfo> UserCache = new Dictionary<int, UserInfo>();

        public static void AddCache(UserInfo info)
        {
            UserCache.TryAdd(info.Id, info);
        }

        public static void RemoveCache(int id)
        {
            //移除
            UserCache.Remove(id);
        }


        public static UserInfo GetCache(int id)
        {
            if (UserCache.ContainsKey(id))
                return UserCache[id];
            return null;
        }
    }

    internal class Task01
    {
        public static void Run()
        {

            for (int i = 0; i < 100; i++)
            {
                //假設這裏i用戶進行了登錄操作,LoginCache中會緩存UserInfo
                UserInfo info = new UserInfo()
                {
                    Id = i,
                    Name = i.ToString(),
                    BirthDay = DateTime.Now.ToString("yyyy-MM-dd")
                };
                LoginCache.AddCache(info);
                //做了一些操作,用到了UserInfo
                LoginCache.GetCache(info.Id);
                //假設這裏就不需要用到UserInfo了,但由於沒有從靜態變量中移除,UserInfo就無法被回收
                //LoginCache.RemoveCache(info.Id);
            }

            GC.Collect();//手動GC,移除緩存後,~UserInfo纔會觸發
            Console.ReadLine();
        }
    }

其實有不少人認爲這種並不算內存泄漏,因爲對象確實被引用了,但設想一下,如果只增加不移除,緩存的用戶量就可能達到千萬甚至上億級別,我認爲這是一種內存泄漏。

3. 永不終止的線程

如果處於某種原因,你需要創建一個永不停止的線程,那麼就有可能引起內存泄漏:
當一個對象不再使用時,應用程序未能及時釋放該對象的引用,導致該對象無法被垃圾回收器回收,比較常見的是在事件通信時發生:

    public class Scheduler
    {
        public Scheduler()
        {
            Timer timer = new Timer(Handle);
            timer.Change(0, 5000); //創建了一個timer,這個timer每隔5秒執行
        }

        private void Handle(object e)
        {
            Console.WriteLine("任務調度中……");
        }


        ~Scheduler()
        {
            Console.WriteLine("資源被釋放");
        }
    }

    internal class Task01
    {
        public static void Run()
        {

            for (int i = 0; i < 3; i++)
            {
                Scheduler scheduler = new Scheduler();
            }
            GC.Collect();//手動GC
            Console.ReadLine();
        }
    }

上面的示例中由於計時器沒有停止,GC就無法回收創建的實例。

4. LOH泄漏

由於LOH本身的特性,在程序中,我們當儘量避免頻繁的使用大內存對象,如果不能就應當儘量避免內存碎片,請先看下面這個例子:

    internal class Task01
    {
        public static void Run()
        {
            List<byte[]> objs = new List<byte[]>();
            for (int i = 0; i < 500; i++)
            {
                //兩種大對象交替出現
                if (i % 2 == 0)
                {
                    objs.Add(new byte[150000]);
                    objs[i] = null;
                    if (i % 10 == 0)
                        GC.Collect(); //模擬GC觸發
                }
                else
                {
                    objs.Add(new byte[85000]);
                }
            }
            Console.WriteLine("執行完畢");
            Console.ReadLine();
        }
    }

實例中我們交替產生了150000字節和85000字節的大內存對象,同時我們模擬了GC的頻繁觸發,我們通過Winddbg中的!dumpheap命令分析,就會看到內存中出現了大量的碎片,Free和Live交替出現:

但如果我們把數據的大小固定住85000,那麼後續新分配的對象就有很大概率繼續使用前面的空閒空間,大大減少了內存碎片:

在應用中,我們對於大對象的使用通常可能來自於某些大對象的更新緩存,比如:

        public static void Main()
        {
            Console.WriteLine("開始執行");
            byte[] bigFastCache = new byte[150000];
            for (int i = 0; i < 500; i++)
            {
                //更新操作,數據大小會不同
                if (i % 2 == 0)
                {
                    bigFastCache = new byte[150000];
                }
                else
                {
                    bigFastCache = new byte[85000];
                }
            }
			GC.Collect(); //模擬GC觸發
            Console.WriteLine("執行完畢");
            Console.ReadLine();
        }

只是對於這個數據的交替更新,其對象創建和銷燬的開銷都很大,這裏我建議使用池化對象,在使用的時候從池中租借一個新對象,使用完成後歸還即可:

        public static void Main()
        {
            Console.WriteLine("開始執行");

            byte[] bigFastCache = null;
            var bigPool = ArrayPool<byte>.Shared; //使用池化對象要慎重
            for (int i = 0; i < 500; i++)
            {
                //更新操作,數據大小會不同
                if (i % 2 == 0)
                {
                    bigFastCache = bigPool.Rent(100000);
                    Console.WriteLine(bigFastCache.Length);
                }
                else
                {
                    bigFastCache = bigPool.Rent(85000);
                    Console.WriteLine(bigFastCache.Length);
                }
                bigPool.Return(bigFastCache);
            }
            Console.WriteLine("執行完畢");
            Console.ReadLine();
        }

如果你用到了Dictionary大對象緩存,建議提前在構造函數中設置Capacity來優化GC,這樣對的性能和內存佔用都有好處。

工作站模式和服務器模式

GC操作本身是一件成本比較高的操作,尤其是Full GC和內存壓縮的時候,可能會涉及到GC暫停,所以GC在回收時通過短暫暫停和分代GC來儘量避免影響應用程序。根據GC類型的不同,.NET提供了兩種GC模式,服務器模式和工作站模式:

工作站模式

工作站模式適用於單CPU系統或者少量CPU的系統,回收發生在觸發回收的用戶線程上,並保留相同的優先級,也就是說垃圾回收期必須與其他先線程競爭CPU時間。當計算機只有一個邏輯CPU時,無論如何配置,GC都只會採用工作站模式。

服務器模式

服務器回收發生在多個專用線程上,且優先級很高,提高了垃圾回收效率,提升了應用程序的性能。不過服務器垃圾回收可能會增加一些額外的內存和CPU開銷,不過和應用想必,這些開銷基本可以忽略不計。

傳統的ASP.NET程序最初通常運行在單個CPU的計算機上,所以其默認使用的是工作站垃圾回收(Workstation GC mode),所以建議大家把ASP.NET的回收模式改爲服務器模式(Server GC mode)。不過若是單純的通過修改GC模式就能大幅度提升應用程序的性能,那大概率還是內存使用出現了問題,建議好好排查,MVC和ASP.NET Core中已經將GC模式默認爲服務器模式,大家也不需要去關心調優的事情。

結束語

由於內存的特性,我們想要分析內存泄漏問題,就需要藉助內存分析工具,不過正所謂富人靠科技,窮人靠努力,那些商用的如Ants Memory Profiler、dotMemory我就不做介紹了,大家有興趣的可以去了解下。這裏給大家推薦的是PerfView+Windbg,這兩個工具基本可以定位到絕大部分的內存泄漏問題。
PerfView:https://learn.microsoft.com/zh-cn/shows/perfview-tutorial/
WinDbg:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/getting-started-with-windows-debugging
不過,這兩個對新手使用不太友好,我會在後續的章節給大家講解,大家也可以關注一線碼農的博客,有很多的案例來講解PerfView和WinDbg的使用。
推薦閱讀:
關於.NET內存的更多知識,大家可以從Microsoft Learn瞭解關於.NET內存的更多知識,也可以關注Maoni Steaphens(微軟架構師,負責.NET Runtime GC的設計與實現)的個人博客,另外《.NET 內存管理寶典》一書也值得一讀。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章