前幾天有位朋友找到我,說他的程式有記憶體洩露,讓我幫忙排查一下,截圖如下:
說實話看到 32bit, 1.5G 這些關鍵詞之後,職業敏感告訴我,他這個可能是虛擬地址緊張所致,不管怎麼說,有了 Dump 就可以上馬分析。
要看是不是虛擬地址緊張,可以用 !address -summary
觀察下記憶體段統計資訊,截圖如下:
我去,用 WinDbg Preview 盡然分析不了,在載入 ntdll
的過程中死掉了,如果你是我們偵錯訓練營的朋友,應該會深深的有體會,我們分析的第一個dump就存在這個情況,這個載入不了其實就預示著一種非託管洩露,這裡暫不劇透。
用 WinDbg Preview
分析不了怎麼辦呢?可以用 Windbg 的其他版本哈,比如 Windbg10, WinDbg6
等等,這裡就採用 WinDbg10 X86
版本開啟吧。
0:000> !address -summary
--- Usage Summary ---------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
Free 179 8cbb1000 ( 2.199 GB) 54.97%
Heap 6598 376f6000 ( 886.961 MB) 48.09% 21.65%
<unknown> 3091 31954000 ( 793.328 MB) 43.02% 19.37%
Image 376 8c0d000 ( 140.051 MB) 7.59% 3.42%
Stack 75 1780000 ( 23.500 MB) 1.27% 0.57%
Other 7 4e000 ( 312.000 kB) 0.02% 0.01%
TEB 25 19000 ( 100.000 kB) 0.01% 0.00%
PEB 1 1000 ( 4.000 kB) 0.00% 0.00%
--- State Summary ---------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_FREE 179 8cbb1000 ( 2.199 GB) 54.97%
MEM_COMMIT 9821 6bfad000 ( 1.687 GB) 93.68% 42.18%
MEM_RESERVE 352 7492000 ( 116.570 MB) 6.32% 2.85%
從卦中 MEM_COMMIT
的 %ofTotal= 42.18%
來看,提交記憶體佔總的虛擬地址比重還不到一半,這說明我的猜測是錯的,不存在虛擬地址緊張的情況,這裡稍微提醒一下的是,這裡不存在虛擬地址緊張是因為它開的是 Any CPU
模式,預設能吃到 4G 記憶體。
不管怎麼說,現在被當頭一棒,既然這條路走不通,那會是什麼情況導致的呢?一般來說這個記憶體量我是不願意分析的,但既然分析到這裡也只能繼續分析,接下來用 !eeheap -gc
觀察下託管堆記憶體佔用情況。
0:000> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x777c0434
generation 1 starts at 0x77781000
generation 2 starts at 0x01861000
ephemeral segment allocation context: none
segment begin allocated size
01860000 01861000 0285ffdc 0xffefdc(16773084)
...
77780000 77781000 77aa25c0 0x3215c0(3282368)
Large object heap starts at 0x02861000
segment begin allocated size
02860000 02861000 031e5cc0 0x984cc0(9981120)
Total Size: Size: 0x1f7e47e4 (528369636) bytes.
------------------------------
GC Heap Size: Size: 0x1f7e47e4 (528369636) bytes.
從卦中看當前託管堆也才 528M
和 提交記憶體 1.6G
相距甚遠,所以這個 dump 大概率是存在非託管記憶體洩露,其實 !address -summary
中的 Heap
也能佐證,說到底就是 ntheap
洩露。
深挖 ntheap 我就不挖了,省的誤入歧途,文章開頭我說過 ntdll 無法載入的現象預示著一種非託管洩露,對 ,就是 GC 的載入堆洩露,載入堆是 CLR 用來對映 C# 程式集,模組,型別,方法等用途的一塊私有記憶體,那怎麼去洞察它呢?可以使用 !eeheap -loader
命令洞察。
0:000> !eeheap -loader
Loader Heap:
--------------------------------------
...
Module 05829f78: Size: 0x0 (0) bytes.
Module 0582a8f8: Size: 0x0 (0) bytes.
Module 0582b278: Size: 0x0 (0) bytes.
Module 0582bbf8: Size: 0x0 (0) bytes.
Module 0582c578: Size: 0x0 (0) bytes.
Module 0582cef8: Size: 0x0 (0) bytes.
Module 0582d878: Size: 0x0 (0) bytes.
...
Module 362ea420: Size: 0x0 (0) bytes.
Total size: Size: 0x0 (0) bytes.
--------------------------------------
Total LoaderHeap size: Size: 0x7e7e000 (132636672) bytes total, 0x28000 (163840) bytes wasted.
=======================================
雖然載入堆只統計到了 132M
,但其中的 module 高達 2.3w
個,其實這裡會有一些相關記憶體是載入堆之外無法統計到的,一般正常的程式不可能有這麼多的module,所以這就是我們接下來突破的點,那怎麼突破呢?最好的辦法就是觀察下這個 module 中到底有什麼 type,使用 !dumpmodule
命令即可。
0:000> !dumpmodule -mt 0582d878
Name: Unknown Module
Attributes: Reflection
Assembly: 0c229d38
LoaderHeap: 00000000
TypeDefToMethodTableMap: 050676e4
TypeRefToMethodTableMap: 050676f8
MethodDefToDescMap: 0506770c
FieldDefToDescMap: 05067734
MemberRefToDescMap: 00000000
FileReferencesMap: 05067784
AssemblyReferencesMap: 05067798
Types defined in this module
MT TypeDef Name
------------------------------------------------------------------------------
0582dcb0 0x02000002
0582df90 0x02000003
0582e018 0x02000004
0582e0b8 0x02000005
0582e194 0x02000006
Types referenced in this module
MT TypeRef Name
------------------------------------------------------------------------------
從模組中並沒有看到型別
的文字描述,那怎麼辦呢,我們隨便抽一個 mt 看下這個 mt 下有什麼方法,使用 !dumpmt
命令即可。
0:000> !dumpmt -md 0582dcb0
EEClass: 05068980
Module: 0582d878
Name:
mdToken: 02000002
File: Unknown Module
BaseSize: 0x44
ComponentSize: 0x0
Slots in VTable: 8
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDe JIT Name
739819c8 735e61fc PreJIT System.Object.ToString()
73987850 735e6204 PreJIT System.Object.Equals(System.Object)
7398bd80 735e6224 PreJIT System.Object.GetHashCode()
738ddbe8 735e6238 PreJIT System.Object.Finalize()
0583b529 0582dc8c NONE Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriterCallBack.InitCallbacks()
0583b52d 0582dc94 NONE Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriterCallBack..ctor()
0583c7d0 0582dc74 JIT Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriterCallBack.Write3_root(System.Object)
0583c868 0582dc80 JIT Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriterCallBack.Write2_CallBack(System.String, System.String, xxx.Models.xxxBack, Boolean, Boolean)
看到卦中的這些資訊,我相信有很多朋友知道是怎麼回事了,對,就是 Serialization
洩露,那它序列化什麼型別呢 ? 從卦中看就是 xxx.Models.xxxBack
類,即 xmlSerializer.Serialize(xxx.Models.xxxBack)
的相關邏輯,接下來就需要逆向看下到底是哪裡寫的,結果發現是他的底層庫封裝的,有些方法有問題,有些沒問題,真的是無語哈。
//有問題的方法
public static string Serialize(object o, Encoding encoding, string rootName)
{
XmlSerializer xmlSerializer = new XmlSerializer(o.GetType(), new XmlRootAttribute(rootName));
...
xmlSerializer.Serialize(memoryStream, o, xmlSerializerNamespaces);
return encoding.GetString(memoryStream.ToArray());
}
//正確的方法
public static string Serialize(object Obj, Encoding encoding)
{
...
using (XmlWriter xmlWriter = XmlWriter.Create(memoryStream, xmlWriterSettings))
{
XmlSerializerNamespaces xmlSerializerNamespaces = new XmlSerializerNamespaces();
xmlSerializerNamespaces.Add("", "");
new XmlSerializer(Obj.GetType()).Serialize(xmlWriter, Obj, xmlSerializerNamespaces);
}
return encoding.GetString(memoryStream.ToArray());
}
這是一個老生常談的問題,如果你用 new XmlSerializer(o.GetType(), new XmlRootAttribute(rootName));
模式的話,一定要快取起來,否則就會洩露,只能說是微軟造的一個大坑吧,多少人都踩上去了。
在我分析的真實dump案例中,見過 Castle ProxyGenerator
的洩露,也見過 CodeAnalysis.CSharp.Scripting
的洩露,還真沒見過 XmlSerializer
的洩露,算是完美的補充了我的案例庫!