众所周知,在CLR中,内存管理是由垃圾收集器(GC)完成的。当GC在新对象的预分配内存块(GC堆)中找不到内存,并且无法从操作系统预订足够的内存来扩展GC堆时,它抛出OutOfMemoryException(OOM)。
问题
我不时听到关于OOM的抱怨——人们分析代码并监控内存使用情况,发现有时当有足够的可用内存时,他们的.NET应用程序会抛出OOM。在我见过的大多数情况下,问题是:
- 操作系统的虚拟地址空间是碎片。这通常是由应用程序中的某些非托管组件引起的。这个问题在非托管世界中存在很长一段时间,但它可能会对GC造成严重影响。GC堆以段为单位进行管理,在V1.0和V1.1中,工作站版本的GC堆大小为16MB,服务器版本为32MB。这意味着当CLR需要扩展GC堆时,它必须为服务器应用程序找到32MB连续的可用虚拟内存。在用户模式下,对于2GB地址空间的系统,这通常不是问题。但是,如果应用程序中有一些非托管dll不小心操作虚拟内存,则虚拟地址空间可能会被划分为可用内存和保留内存的小块。因此,GC将无法找到足够大的空闲内存,尽管总的空闲内存足够。这种问题可以通过查看整个虚拟地址空间来找出哪个块被哪个组件保留。
- GC堆本身是碎片化的,这意味着GC不能在已经保留的段中分配对象,而这些段实际上有足够的空闲空间。我想在这个博客里关注这个问题。
GC堆一瞥
通常托管堆不应该出现碎片问题,因为在GC期间堆是压缩的。下面显示了一个过于简单化的CLR GC堆模型:
- 所有对象彼此相邻;堆的顶部是可用空间。
|---------|
|free |
|_________|
|Object B |
| |
|_________|
|Object A |
|_________|
| ... |
- 新对象在空闲空间中分配。分配总是发生在顶部,就像堆栈一样。
|---------|
|free |
|_________|
|Object C |
|_________|
|Object B |
| |
|_________|
|Object A |
|_________|
| ... |
- 当空闲空间用完时,会发生GC。在GC期间,标记可访问的对象。
|---------|
|Object C | (marked)
|_________|
|Object B |
| |
|_________|
|Object A | (marked)
|_________|
| ... |
- 在GC之后,堆被压缩,活动(可访问)对象被重新定位,死(不可访问)对象被清除。
|---------|
|free |
|_________|
|Object C |
|_________|
|Object A |
|_________|
| ... |
GC堆中的可用空间
在上面的模型中,您可以看到GC实际上在对堆进行碎片整理方面做得很好。可用空间始终位于堆的顶部,可用于新的分配。但在实际生产中,空闲空间可以驻留在分配的对象之间。这是因为:
- 有时,GC可以选择在不必要时不压缩堆的一部分。由于重新定位所有对象的成本可能很高,因此在某些情况下,GC可能会避免这样做。在这种情况下,GC将在堆中保留一个可用空间列表,以备将来压缩。这不会导致堆碎片化,因为GC完全控制可用空间。GC可以在以后任何时候在必要时填充这些块。
- 固定对象不能移动。因此,如果一个固定对象在GC中幸存下来,它可能会创建一个可用空间块,如下所示:
GC前:GC后:
|---------| |---------|
|Object C | (pinned, reachable) |Object C | (pinned)
|_________| |_________|
|Object B | (unreachable) | free |
| | | |
|_________| |_________|
|Object A | (reachable) |Object A |
|_________| |_________|
| ... | | ... |
如何锁定分割GC堆
如果应用程序一直以这种模式固定对象:固定一个新对象,执行一些分配,固定另一个对象,执行一些分配。。。并且所有固定的对象长时间保持固定,会产生大量的自由空间,如下图所示:
|---------|
|free |
|_________|
|Pinned 1 |
|_________|
|Object A |
|_________|
| ... |
|---------|
|free |
|_________|
|Pinned 2 |
|_________|
| ... |
|_________|
|Pinned 1 |
|_________|
|Object A |
|_________|
| ... |
|_________|
|Pinned n |
|_________|
| ... |
|_________|
|Pinned 2 |
|_________|
| ... |
|_________|
|Pinned 1 |
|_________|
|Object A |
|_________|
| ... |
- GC发生了,因为无法重新定位固定的对象,所以堆中仍有可用空间
|_________|
|Pinned n |
|_________|
| free |
|_________|
|Pinned 2 |
|_________|
| free |
|_________|
|Pinned 1 |
|_________|
| free |
|_________|
| ... |
这样的进程可以创建一个具有大量空闲插槽的GC堆。这些空闲槽被部分重用用于分配,但是当它们太小或者剩余的空间太小时,只要对象被固定住,GC就不能使用它们。这将阻止GC有效地使用堆,并可能最终导致OOM。
有一点使情况更糟的是,尽管开发人员可能不直接使用固定对象,但有些.Net库在幕后使用它们,比如异步IO。例如在V1.0和V1.0中传递到缓冲区接受器Socket被库固定,以便非托管代码可以访问缓冲区。考虑一个socket服务器应用程序,它每秒处理数千个socket请求,由于连接速度慢,每个请求可能需要几分钟的时间;GC堆可能会因大量的固定对象和较长的生存期而碎片化,有些对象被钉住;那么OOM就可能发生。
如何诊断问题
要确定GC堆是否碎片化,SOS是最好的工具。Sos.dll是.NET framework附带的调试器扩展,它可以检查CLR中的某些基础数据结构。例如,“DumpHeap”可以遍历GC堆并转储堆中的每个对象,如下所示:
0:000>!dumpheap
Address MT Size
00a71000 0015cde812Free
00a7100c 0015cde812Free
00a71018 0015cde812Free
00a71024 5ba583286800a71068 5ba583806800a710ac 5ba584306800a710f0 5ba5dba468...
00a91000 5ba88bd8206400a91810 0019fe482032Free
00a92000 5ba88bd8409600a93000 0019fe488192Free
00a95000 5ba88bd84096...
total1892objects
Statistics:
MT Count TotalSize Class Name
5ba7607c1 12System.Security.Permissions.HostProtectionResource
5ba75d541 12System.Security.Permissions.SecurityPermissionFlag
5ba61f181 12System.Collections.CaseInsensitiveComparer
...
0015cde86 10260Free
5ba57bf8318 18136System.String
...
了解对象在gc堆中的布局是很有用的。在垃圾收集期间,有效的对象是通过递归访问对象来标记的,这些对象从堆栈的根和句柄开始。但是,从每个堆段的开始到结束,对象的位置以一种有组织的方式排列也很重要。!DumpHeap命令依赖于这个逻辑组织来正确地遍历堆,如果它报告了一个错误,可以打赌您的堆出了问题。
下面是代码
usingSystem;usingSystem.Reflection;usingSystem.Reflection.Emit;usingSystem.Threading;usingSystem.Runtime.InteropServices;public classEmitHelloWorld
{
[DllImport("kernel32")]public static extern voidDebugBreak();static void Main(string[] args)
{//create a dynamic assembly and module
AssemblyName assemblyName = newAssemblyName();
assemblyName.Name= "HelloWorld";
AssemblyBuilder assemblyBuilder=Thread.GetDomain().DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndSave);
ModuleBuilder module;
module= assemblyBuilder.DefineDynamicModule("HelloWorld.exe");//create a new type to hold our Main method
TypeBuilder typeBuilder = module.DefineType("HelloWorldType", TypeAttributes.Public |TypeAttributes.Class);//create the Main(string[] args) method
MethodBuilder methodbuilder = typeBuilder.DefineMethod("Main", MethodAttributes.HideBySig | MethodAttributes.Static | MethodAttributes.Public, typeof(void), new Type[] { typeof(string[]) });//generate the IL for the Main method
ILGenerator ilGenerator =methodbuilder.GetILGenerator();
ilGenerator.EmitWriteLine("hello, world");
ilGenerator.Emit(OpCodes.Ret);//bake it
Type helloWorldType =typeBuilder.CreateType();//run it
helloWorldType.GetMethod("Main").Invoke(null, new string[] {null});
DebugBreak();//set the entry point for the application and save it
assemblyBuilder.SetEntryPoint(methodbuilder, PEFileKinds.ConsoleApplication);
assemblyBuilder.Save("HelloWorld.exe");
}
}
下面这个程序有一个bug,试试看你能不能抓住它。
Test.cs (编译成 DelegateExample.exe):
usingSystem;usingSystem.Threading;usingSystem.Runtime.InteropServices;classTest
{delegate uintThreadProc (IntPtr arg);private uintm;public Test (uintn)
{
m=n;
}uintReflect (IntPtr arg)
{
Console.WriteLine (m);returnm;
}static voidMain ()
{
Test t= new Test (1);
ThreadProc tp= newThreadProc (t.Reflect);
NewThread (tp);
Thread.Sleep (1000);
}
[DllImport("UsingCallback")]static extern voidNewThread (ThreadProc proc);
}
这并不是说它很重要,而是因为它是我的一个大麻烦,我想区分真正的内存泄漏和高内存使用率。
内存泄漏是指您使用了一些内存并且丢失了指向分配的指针,因此您无法再取消分配该内存。如果你仍然有一个指向它的指针,你的内存使用率很高,这可能与进程发生的情况一样糟糕,但仍然不同。为了简单起见,我将从现在开始对这两个问题使用术语内存泄漏…
我喜欢考虑解决性能和内存泄漏问题,比如剥洋葱皮。从最明显的一个层开始,一次去掉一个层,然后定义一个限制,确定问题已解决。
有两种类型的内存泄漏,一种是逐渐的内存泄漏(内存以大致相同的速率持续增长)和突然的内存跳跃。您可以用大致相同的方式对它们进行故障排除,但在后一种情况下,您还可以尝试确定在跳转时是否发生了异常情况,例如服务器上的极端负载等。
进程的内存空间包含一些不同类型的“对象”,如线程、管理的对象堆、托管加载程序堆、本机堆、dll和虚拟分配,因此,一个好的开始是运行带有以下计数器的性能监视器日志。
- Process/Virtual Bytes
- Process/Private Bytes
- .net CLR Memory/# Bytes in all Heaps
- .net CLR Memory/% Time in GC
- .net CLR Memory/Large Object Heap size
- .net CLR Loading/Bytes in Loader Heap
- .net CLR Loading/Current Assemblies
主要要寻找的是私有字节的增长率是否与虚拟字节的增长率大致相同,以及所有堆中的字节是否都遵循相同的曲线。
如果私有字节持续增加,但是所有堆中的字节没有增加,那么很可能会看到本机内存泄漏(即,在COM组件或类似组件中泄漏),但如果所有堆中的字节以与私有字节相同的速率增加,则托管代码中可能会出现泄漏。
同样,如果您看到虚拟字节稳步增加,但是您的私有字节保持相当稳定,那么您的应用程序可能存在一个问题,即它保留了大量未使用的虚拟内存。
一旦进程启动并且加载了所有应用程序域,加载程序堆和当前程序集中的字节应该保持相当恒定。如果此值持续增加,则很可能存在总成泄漏。
所以现在你大概知道你在哪里泄露了,下一步就是找出原因
- « 前一页
- 1
- ...
- 1446
- 1447
- 1448
- 1449
- 1450
- 1451
- 1452
- ...
- 1514
- 后一页 »