博客 / 詳情

返回

DotMemory系列:3. 堆碎片化引發的內存暴漲分析

一:背景

1. 講故事

前面兩篇我們講的都是通過掛引用根的方式導致的內存暴漲,在快速檢測台上能夠一眼就看出是什麼類型的Type導致的,分析難度稍微較低,在真實的dump分析場景下,也會存在對象偏小而內存暴漲的情況,一般的新手會被這種場景搞懵逼,這篇就來分享這種奇葩的情況。

二:內存暴漲分析

1. 問題代碼

為了方便演示,我們做這樣的一個案例,現在的 .NET8 的SOH一個segment是 4M,所以我故意這麼設計,分配3M的臨時對象,然後再分配一個 50k 的Pinned對象,由於 Pinned 解封之前是GC不可移動對象,最終會導致 堆碎片化 現象,參考代碼如下:


    internal class Program
    {
        static void Main(string[] args)
        {
            var harmony = new Harmony("com.example.gchandleallchook");
            harmony.PatchAll();

            ProcessData();

            Console.ReadLine();
        }

        static void ProcessData()
        {
            for (int i = 1; i <= 1000; i++)
            {
                Allocate_Bytes(i);
                Allocate_Pinned(i);

                Console.WriteLine($"i={i} 次執行,3M byte[] 分配完畢,50k byte[] 分配完畢");
            }

            GC.Collect();
            Console.WriteLine("碎片化已形成,已強制執行GC,請觀察託管堆!");
        }

        static void Allocate_Bytes(int i)
        {
            //1k * 1024 * 3 = 3M (1個region)
            for (int j = 0; j < 1024 * 3; j++)
            {
                var bytes = new byte[1024]; // 分配 3096 個 1k 的 byte[]
            }
        }

        static void Allocate_Pinned(int i)
        {
            GCHandle.Alloc(new byte[1024 * 50], GCHandleType.Pinned); // 50k 的 pinned byte[]
        }
    }

代碼有了之後,接下來就是用 dotMemory 把程序給跑起來,內存走勢圖如下所示。

從卦中可以看到,內存總計為 1.9G,其中 gen2 就獨吃 1.8G,很顯然這是託管內存泄露,接下來的操作就是採一個 snapshot,打開快速檢測台,截圖如下:

從檢測台上看並沒有看到哪一個類型的對象有佔用過大的情況,這是不是讓人匪夷所思呢?

2. 為什麼對象佔用不大

雖然對象佔用不大,但內存確確實實被託管堆的gen2所吃,所以必須調轉槍頭直接觀測檢測台的尾部 Heap Fragmentation 區域,截圖如下:

哈哈,一下子就發現了 gen2 區域的奇觀,即使看不懂的話也會覺得奇奇怪怪的,接下來我就簡單分析下這裏面的幾個指標吧。

  1. heap: 表示當前有 810 個 segment 內存段
  2. total: 表示當前 gen2 吃了 1.77G 內存。
  3. used(pinned):表示 1.77G 內存中,pinned 對象佔了 48.8M 內存。
  4. used(unpinned): 表示 1.77G 內存中未固定對象吃了 46.8k 內存。
  5. free: 表示當前空閒塊吃了 1.73G。

上面幾個指標合起來就是説 gen2 用 1.77G 內存只裝近 50M 的對象,這種奇葩現象就是所謂的 堆碎片化

接下來就是要尋找這些 pinned 對象,他們到底是什麼,為什麼讓 GC 痛苦不堪,可以選擇 Generations 選項卡,雙擊其中任一個segment,截圖如下:

打開面板之後發現都是 Byte[] 數組,通過 Similar retention 選項卡發現都是 Pinning handle ,即通過 GCHandleType.Pinned 固定的,截圖如下

接下來的問題是這些 Byte[] 數組到底是被誰固定的?為什麼不解開呢?

2. byte[] 是誰創建的

如果把這個問題搞定了,那所有的真相就會大白,那怎麼做呢?一般來説有兩種做法,第一種就是 full 採集模式,然後觀察 byte[] 的調用棧即可,還有一種方式使用 harmony 注入的方式記錄調用棧。這裏都給大家介紹一下吧。

  1. full 採集模式

首先要説的是 full 採集模式在真實環境下很難實行,因為它對程序的性能傷害太大了,這個在官方文檔中也有所説明,截圖如下:

最後選擇 Start 按鈕開始採集,按照前面所述的方式找到 byte[] 數組再選擇 Back Traces 選項卡,可以清楚的看到是 Allocate_Pinned() 方法創建的。

剛才是通過 type 為依據尋找的調用棧,也可以找到具體的 byte[] 實例觀察其 Create Stack Trace 選項,同樣也能看到,截圖如下:

剛才也説了,這種方式雖然可行,但不是第一手段,更合適做萬不得已的備份方案,萬一程序能受得了這麼重的暴擊呢?

  1. harmony 注入

第二種方式就是脱離 dotmemory,採用一種 IL 注入的方式,原理非常簡單,就是在 SDK 的 GCHandle.Alloc 內部增加日誌,參考代碼如下:


public static GCHandle Alloc(object? value, GCHandleType type)
{
    // prefix: todo...

    return new GCHandle(value, type);
    
    // postfix:todo...
}

在 postfix 中我們記錄下調用 Alloc 方法的調用棧,這樣是不是就真相大白了,完整的參考代碼如下:


    internal class Program
    {
        static void Main(string[] args)
        {
            var harmony = new Harmony("com.example.gchandleallchook");
            harmony.PatchAll();

            ProcessData();

            Console.ReadLine();
        }

        static void ProcessData()
        {
            for (int i = 1; i <= 1000; i++)
            {
                Allocate_Bytes(i);
                Allocate_Pinned(i);

                Console.WriteLine($"i={i} 次執行,3M byte[] 分配完畢,50k byte[] 分配完畢");
            }

            GC.Collect();
            Console.WriteLine("碎片化已形成,已強制執行GC,請觀察託管堆!");
        }

        static void Allocate_Bytes(int i)
        {
            //1k * 1024 * 3 = 3M (1個region)
            for (int j = 0; j < 1024 * 3; j++)
            {
                var bytes = new byte[1024]; // 分配 3096 個 1k 的 byte[]
            }
        }

        static void Allocate_Pinned(int i)
        {
            GCHandle.Alloc(new byte[1024 * 50], GCHandleType.Pinned); // 50k 的 pinned byte[]
        }
    }

    [HarmonyPatch(typeof(GCHandle), "Alloc", new Type[] { typeof(object), typeof(GCHandleType) })]
    public class GCHandleAllocHook
    {
        public static void Postfix(GCHandle __result, GCHandleType type)
        {
            if (type == GCHandleType.Pinned)
            {
                Console.WriteLine($"  - 句柄指針: 0x{GCHandle.ToIntPtr(__result).ToInt64():X}");
                Console.WriteLine($"  - 句柄類型: {type}");
                Console.WriteLine(Environment.StackTrace);
            }
        }
    }

最後運行程序,觀察日誌輸出即可,截圖如下:

從卦中日誌看是不是輕鬆的就找到了 Allocate_Pinned() 方法,在真實場景中還是建議大家寫到 Nlog 這樣的日誌框架中。

三:總結

DotMemory 在可視化方面做的還是蠻強大的,感覺特別適合作為 技術支持工程師 的首選工具,希望本篇能給你帶來一些幫助。

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

發佈 評論

Some HTML is okay.