博客 / 詳情

返回

DotMemory系列:1. 終結隊列積壓引發的內存暴漲分析

一:背景

1. 講故事

説實話本來是不想寫這個系列的,因為我潛意識裏覺得這款工具就像美圖秀秀一樣,拉低專業人士的檔次,但奈何在訓練營裏我需要用到 dottrace 這款工具,而我向官方申請再續了一年免費的Pack套件也給我通過了,所以我覺得要對得起他們,得要寫點什麼,截圖如下:

這幾天我也仔細看了下DotMemory的文檔,發現還是有一些可圈可點的地方,畢竟美圖秀秀也有美圖秀秀的閃光點,在某些場景下完全可以用 DotMemory 作為WinDbg出場的第一套關卡,想來想去我決定還是寫5篇託管內存故障來演示下DotMemory的使用,也確實它的可視化做的非常好,那這篇就先從 終結隊列積壓 導致的內存暴漲開始吧。

二:內存暴漲分析

1. 問題代碼

為了演示 終結隊列積壓 引發的內存暴漲,我故意讓 終結器線程 處理的慢一些,這樣就會存在不斷的囤積情況,參考代碼如下:


    internal class Program
    {
        static void Main(string[] args)
        {
            for (int i = 1; i < 500000; i++)
            {
                NewPerson(i);
            }
            Console.WriteLine("50w 個對象插入完畢!");
            Console.ReadLine();
        }

        static void NewPerson(int i)
        {
            var person = new Person()
            {
                ID = i + 1,
                Name = string.Join(",", Enumerable.Range(0, 1000))
            };
        }
    }

    public class Person
    {
        public int ID { get; set; }

        public string Name { get; set; }

        ~Person()
        {
            Thread.Sleep(1000);
            Console.WriteLine($"析構函數 {ID}: 執行完畢...");
        }
    }

2. DotMemory 分析

這裏我用的是 DotMemory 2025.1 版本,用 dotmemory 開啓子進程的方式啓動,大概三步走就行了,截圖如下:

這裏一定要選擇 Sampled 採樣模式,如果選擇 Full 模式那幾乎是無法跑的,因為都是基於 ETW 的,所以和 perfview 的 .NET SampleAlloc 模式是一模一樣的。

點擊 Start 後,會有一個內存用量的動態圖,在內存出現暴漲後,使用 Get Snapshot 採一個快照下來,截圖如下:

打開圖中左下角的 Snapshot #1 快照,映入眼簾的就是 Inspections 視圖,翻譯過來用 檢測台 比較合適,截圖如下:

稍微熟悉 DotMemory 的朋友,看到快速通覽之後肯定會發現問題所在,我就單獨開一節來説吧!

3. 問題浮現

  1. Largest Size 環形圖

這個圖是告訴大家某一類對象的淺層大小,即不包含他們的孩子節點,用 windbg 的話術就是直接取Person自身的 Size=32byte,很顯然這 32byte 是不包含 Person.<Name>k__BackingFieldSize=7800byte 的,輸出如下:


0:015> !dumpobj /d 2e9693a2fa0
Name:        Example_20_1_1.Person
MethodTable: 00007ffa0b5fa898
EEClass:     00007ffa0b6046a8
Tracked Type: false
Size:        32(0x20) bytes
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffa0b4b1188  4000001       10         System.Int32  1 instance            23906 <ID>k__BackingField
00007ffa0b52ec08  4000002        8        System.String  0 instance 000002e9693a3000 <Name>k__BackingField

0:015> !DumpObj /d 000002e9693a3000
Name:        System.String
MethodTable: 00007ffa0b52ec08
EEClass:     00007ffa0b50a5d8
Tracked Type: false
Size:        7800(0x1e78) bytes
String:      0,1,2,3,...
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffa0b4b1188  400033b        8         System.Int32  1 instance             3889 _stringLength
00007ffa0b4bb538  400033c        c          System.Char  1 instance               30 _firstChar
00007ffa0b52ec08  400033a       c8        System.String  0   static 000002e900000008 Empty

有了上面的思路之後,你應該就知道這個程序中吃的最多的就是String類型,總計 3.63G,對 String 產生重大懷疑之後,接下來就是看第二個環形圖。

  1. Largest Retained Size 環形圖

如果説剛才的圖是不包含孩子節點的,那這張圖就是切切實實的包含孩子節點,有些人可能要問,既然是包含關係,那包含的起點在哪裏呢?熟悉 gc標記階段的朋友應該知道,這個起點應該就是 root 根。

有了這個基礎之後,你就應該能明白為什麼 Person 類型的總量是排在第一位的,剛才的 windbg 輸出已經告訴了我們,看樣子 Person.<Name>k__BackingField 正是我們的問題所在。

  1. String duplicates 問題

在內功修煉訓練營裏跟大家分享過 駐留池 的底層原理,其實這個就是和 駐留池 有關,從卦中可以看到由 49.9w 的字符串理應都要進池子,結果都是以副本的形式存在於託管堆中,所以這裏有了 Wasted=3.63G 一説,哈哈,到這裏又看到了一處非常不合理的地方,也就説如果把這 49.9w 的string全部進池子,那麼內存一下子就下去了,等一會我們來驗證吧。

  1. Finalizable Objects 問題

這裏有一個異常的信號,即 紅色感嘆號,説明這裏可能存在一個大問題,從列表中可以看到 Person=49.9w,截圖如下:

這裏的 49.9w 表示什麼呢? 熟悉clr終結隊列的朋友應該知道,這個 queued 其實就是 freachable queue 區域,即 終結器線程 提取對象的地方。

如果有些朋友還是搞不清楚,在我的訓練營裏有詳細的畫圖説明,其中的 深綠色區域 就是所謂的提取區域,截圖如下:

如果一定要在 dotmemory 上驗證,那就雙擊唄,觀察 Similar Retention 選項即可,截圖如下:

言歸正傳,接下來的問題就來了,為什麼 終結器隊列 中有那麼多的囤積?

4. 尋求問題之道

由於是採樣模式,直接觀察 CallTreeBack Traces 選項卡會不準,所以就直接觀察 Person 的源代碼,為什麼 析構函數 這麼不給力,很快就發現有不對的地方,這裏居然有慢處理 Thread.Sleep(1000),參考如下:


        ~Person()
        {
            Thread.Sleep(1000);
            Console.WriteLine($"析構函數 {ID}: 執行完畢...");
        }

這裏稍微提醒一下,在真實場景中,一般會用 windbg 去觀察此時的 終結器線程 的調用棧,但無奈 dotmemory 不具備觀察線程的調用棧能力。

所以解決辦法就比較簡單了,將 Thread.Sleep(1000); 註釋掉即可。

最後再説一種辦法,也就是剛才説到了 wasted,如果全部送到駐留池,其實也是治標不治本的方法,但在這種場景下可以絕對的延遲OOM的時間,即用 string.Intern 給包起來,參考代碼如下:


        static void NewPerson(int i)
        {
            var person = new Person()
            {
                ID = i + 1,
                Name = string.Intern(string.Join(",", Enumerable.Range(0, 1000)))
            };
        }

從卦中可以看到,其實送入了 50w 的超大 string,因為內存中只保有一份,所以再怎麼大也大不起來,從檢測台上也能看到那玩意在 String duplicates 列表中消失了,截圖如下:

三:總結

DotMemory雖為美圖秀秀,但秀秀也有秀秀的場景,在進一步深度分析之前,它是一款很好的快速通覽利器。

圖片名稱
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.