一:背景
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 區域的奇觀,即使看不懂的話也會覺得奇奇怪怪的,接下來我就簡單分析下這裏面的幾個指標吧。
- heap: 表示當前有 810 個 segment 內存段
- total: 表示當前 gen2 吃了 1.77G 內存。
- used(pinned):表示 1.77G 內存中,pinned 對象佔了 48.8M 內存。
- used(unpinned): 表示 1.77G 內存中未固定對象吃了 46.8k 內存。
- free: 表示當前空閒塊吃了 1.73G。
上面幾個指標合起來就是説 gen2 用 1.77G 內存只裝近 50M 的對象,這種奇葩現象就是所謂的 堆碎片化。
接下來就是要尋找這些 pinned 對象,他們到底是什麼,為什麼讓 GC 痛苦不堪,可以選擇 Generations 選項卡,雙擊其中任一個segment,截圖如下:
打開面板之後發現都是 Byte[] 數組,通過 Similar retention 選項卡發現都是 Pinning handle ,即通過 GCHandleType.Pinned 固定的,截圖如下
接下來的問題是這些 Byte[] 數組到底是被誰固定的?為什麼不解開呢?
2. byte[] 是誰創建的
如果把這個問題搞定了,那所有的真相就會大白,那怎麼做呢?一般來説有兩種做法,第一種就是 full 採集模式,然後觀察 byte[] 的調用棧即可,還有一種方式使用 harmony 注入的方式記錄調用棧。這裏都給大家介紹一下吧。
- full 採集模式
首先要説的是 full 採集模式在真實環境下很難實行,因為它對程序的性能傷害太大了,這個在官方文檔中也有所説明,截圖如下:
最後選擇 Start 按鈕開始採集,按照前面所述的方式找到 byte[] 數組再選擇 Back Traces 選項卡,可以清楚的看到是 Allocate_Pinned() 方法創建的。
剛才是通過 type 為依據尋找的調用棧,也可以找到具體的 byte[] 實例觀察其 Create Stack Trace 選項,同樣也能看到,截圖如下:
剛才也説了,這種方式雖然可行,但不是第一手段,更合適做萬不得已的備份方案,萬一程序能受得了這麼重的暴擊呢?
- 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 在可視化方面做的還是蠻強大的,感覺特別適合作為 技術支持工程師 的首選工具,希望本篇能給你帶來一些幫助。