一:背景
1. 講故事
事件泄露導致的內存暴漲,説實話我以前是不敢相信的,因為我認為沒人會寫這樣的代碼,但現實往往都會打臉,還是太年輕了,今年年中的時候還真給遇到了,也算是無語啦,這一篇我們就來聊一聊如何通過 DotMemory 來一探究竟。
二:內存暴漲分析
1. 問題代碼
為了方便講述,先來一段測試代碼,代碼非常簡單,也就調用 1kw 次 SomeOperation 方法,調用完之後使用 GC.Collect() 強行回收,參考代碼如下:
internal class Program
{
static void Main(string[] args)
{
WiFiManager wifiManager = new WiFiManager();
for (int i = 0; i < 10000000; i++)
{
SomeOperation(wifiManager);
}
GC.Collect();
Console.WriteLine("全部執行完成,GC也觸發完畢!!!");
Console.ReadKey();
}
static void SomeOperation(WiFiManager wifiManager)
{
var room = new Room(wifiManager);
var wifiStatus = room.GetWifiStatus();
}
}
public class WiFiManager
{
public event EventHandler<WifiEventArgs> WiFiSignalChanged;
}
public class Room
{
public Room(WiFiManager wiFiManager)
{
wiFiManager.WiFiSignalChanged += OnWiFiChanged;
}
private void OnWiFiChanged(object sender, WifiEventArgs e)
{
}
public string GetWifiStatus()
{
return "wifi 狀態良好...";
}
}
public class WifiEventArgs : EventArgs { }
接下來使用 DotMemory 的默認配置(採樣模式)跟蹤程序,會發現即使觸發了 FullGC ,內存還維持1.15G左右,很明顯存在內存泄露,截圖如下:
接下來就是找原因了,為什麼會這樣?
2. 問題分析
要想找原因,必須用 Get Snapshot 採一個快照下來,採集完成之後打開 Snapshot #1 快照,可以看到如下的 檢測台。
從檢測台上可以看到如下三點信息:
- Largest Size 區域
前面的文章跟大家説過,這個區域是每個Type的淺層大小,可以看到 EventHandler<WifiEventArgs> 和 Room 聯合吃了 940M 左右,和內存總量 1.15G 比較接近了,説明這兩塊是禍根,先重點備註一下。
- Largest Retained Size 區域
這個區域是以root根為出發點,幷包含所有孩子節點的size,從圖中可以看到 WifiManager 就屬於其中的一個 root 根,有些人可能好奇它是什麼 root 根? 可以單擊 item 選擇 Key Retention Path 選項,截圖如下:
上面的 Regular local variable 表示局部變量,也就是説這個變量是棧引用根。
還有一點就是 EventHandler<WifiEventArgs> + Room 剛好接近 WifiManager 的總大小,説明前者應該都是它的孩子節點。
- Event handlers leak
從英文解釋上就能知道,這個列表中的類實例是被訂閲到別人的事件上,並且還沒有 解訂閲,那這樣的對象有多少呢? 從列表中就可以看到有 1000w 的 Room,這個在數據上是一個異常信號。雖然 Retained Size=228.88M,但這個只算了淺層大小,深層大小不得而知。
有了上面三點信息之後,我們就從 Room 這個點出來,觀察它的 root 鏈,單擊 Room 類型之後再次選擇 Similar Retention 選項,截圖如下:
還有一點如果你想可視化觀察,可以點擊 檢測台 上的 Dominators 選項卡觀察 旭日圖,這也是 DotMemory 快速可視化的一個亮點,截圖如下:
如果想要觀察 WifiManager 類實例的內容也比較簡單,這個也是 DotMemory 非常好的一個亮點,比如下圖的 _invocationList[],這也是 多播調用 的底層核心,截圖如下:
到這裏就已經豁然開朗了,接下來就是去看 Room 是怎麼掛接到 WiFiManager.WiFiSignalChanged 上,翻看源碼很快就找到了問題,參考如下:
public Room(WiFiManager wiFiManager)
{
wiFiManager.WiFiSignalChanged += OnWiFiChanged;
}
可能有些人比較懵逼,我明明是把 OnWiFiChanged 方法注進去的,為什麼當前的 this (room) 對象也進去了呢?
3. 為什麼會註冊 this
要想找到這個答案,直接觀察彙編即可,參考如下:
// wiFiManager.WiFiSignalChanged += OnWiFiChanged;
00007FFAAD7B16F2 mov rcx,7FFAADAE8BF0h
00007FFAAD7B16FC call CORINFO_HELP_NEWSFAST (07FFB0D30FA50h)
00007FFAAD7B1701 mov qword ptr [rbp+28h],rax
00007FFAAD7B1705 mov rcx,qword ptr [rbp+28h]
00007FFAAD7B1709 mov rdx,qword ptr [rbp+50h]
00007FFAAD7B170D mov r8,offset Example_9_9_2.Room.OnWiFiChanged(System.Object, Example_9_9_2.WifiEventArgs) (07FFAADB022B0h)
00007FFAAD7B1717 call qword ptr [Pointer to stub for: System.MulticastDelegate.CtorClosed(System.Object, IntPtr) (07FFAAD794210h)]
00007FFAAD7B171D mov rcx,qword ptr [rbp+58h]
00007FFAAD7B1721 mov rdx,qword ptr [rbp+28h]
00007FFAAD7B1725 cmp dword ptr [rcx],ecx
00007FFAAD7B1727 call Example_9_9_2.WiFiManager.add_WiFiSignalChanged(System.EventHandler`1<Example_9_9_2.WifiEventArgs>) (07FFAADB01A40h)
00007FFAAD7B172C nop
從卦中看上面的 rdx,qword ptr [rbp+50h] 就是我們的 Room 實例,然後通過 OnWiFiChanged 方法傳遞下去,即下面的 target 字段。
private void CtorClosed(object target, nint methodPtr)
{
if (target == null)
{
ThrowNullThisInDelegateToInstance();
}
_target = target;
_methodPtr = methodPtr;
}
三:總結
是不是挺有意思的, DotMemory 這些界面真的是太有愛了。