- A+
一:背景
1. 讲故事
前几天有位朋友找到我,说他的程序有内存泄露,让我帮忙排查一下,截图如下:
说实话看到 32bit, 1.5G 这些关键词之后,职业敏感告诉我,他这个可能是虚拟地址紧张所致,不管怎么说,有了 Dump 就可以上马分析。
二:WinDbg分析
1. 虚拟地址紧张所致吗
要看是不是虚拟地址紧张,可以用 !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
泄露。
2. 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
的泄露,算是完美的补充了我的案例库!