为了回答这个问题,我们需要讨论几个概念。
在32位系统上工作时,可以寻址4GB内存,其中2GB通常保留给操作系统,每个用户模式进程(如w3)允许有2GB内存wp.exe文件(asp.net)例如。这个内存称为虚拟内存,2GB是2GB,与系统中添加的RAM数量无关。RAM的数量只是决定了你需要做多少分页和交换,也就是说内存访问的速度。
当一个进程分配内存时,它首先保留虚拟内存,然后从这个块中提交内存(这是实际使用的内存)。提交的内存称为私有字节。虚拟地址空间用于处理过程中的许多不同项目,例如:

  • Dll’s
  • Native heaps (non .net heaps)
  • Threads (each thread reserves 1 MB for the stack)
  • .net heaps (for managed variables)
  • .net loader heap (for assemblies and related structures)
  • Virtual allocations made by com components

虚拟内存分配不一定(或很少)在内存中很好地排列。例如,dll具有首选的加载地址,因此它们之间留有间隙,而已取消分配的虚拟分配也将留下间隙。这意味着,即使您可以寻址2GB的虚拟内存,但您可能无法全部使用,因为当使用了足够的内存时,内存将看起来有点像瑞士奶酪,并且您的插头可能没有一个足够大的孔来容纳。
这是当您得到一个OutOfMemory异常时发生的情况。
在.net框架中,垃圾收集器(我们的内存管理器)以堆的形式保留虚拟内存。一旦创建了这些堆,并且我们创建了一个.net对象的新实例,这个对象就被存储在这些堆段中,并提交了内存。
在.NETFramework中,常规的.net堆是在64MB段中创建的。(如果您有8个以上的处理器或手动更改了设置,则会发生变化,但我暂时忽略这一点,并讨论常见情况)
这些64MB的段需要在一个大的块中分配,你不能在这里分配32 MB,在那里分配32 MB,所以当你填满了其中一个64 MB的段并且需要创建一个新的段时,你需要在2GB内存空间中找到一个64 MB或更多的空白。如果不存在这样的间隙,则会出现内存不足异常。
这些OutOfMemory异常通常不是致命的,但仍然很糟糕,因为它们使进程处于不稳定状态。但是,当垃圾回收器需要进行收集时,它需要内部结构的空间才能进行此收集,并且使用的内存和对象越多,这些结构就越大。如果垃圾回收器没有足够的空间进行收集,则会出现致命的内存不足异常,进程将停止。
保留内存(性能监视器中的虚拟字节)将比提交的字节(性能监视器中的专用字节)大得多。通常,当内存使用率很高时,差异大约在500 MB左右,而在大约1.2-1.4 GB左右的虚拟字节时,很难找到这些64MB的漏洞,这意味着一个正常的.net进程将在800 MB左右开始脱离内存异常。
同样,这个值会因应用程序、加载的dll和本机com组件的内存分配模式等而异。
现在你知道为什么即使你有很多内存,你也能摆脱内存异常,下一个任务就是找出为什么你要使用这么多内存。

众所周知,在CLR中,内存管理是由垃圾收集器(GC)完成的。当GC在新对象的预分配内存块(GC堆)中找不到内存,并且无法从操作系统预订足够的内存来扩展GC堆时,它抛出OutOfMemoryException(OOM)。

问题

我不时听到关于OOM的抱怨——人们分析代码并监控内存使用情况,发现有时当有足够的可用内存时,他们的.NET应用程序会抛出OOM。在我见过的大多数情况下,问题是:

  1. 操作系统的虚拟地址空间是碎片。这通常是由应用程序中的某些非托管组件引起的。这个问题在非托管世界中存在很长一段时间,但它可能会对GC造成严重影响。GC堆以段为单位进行管理,在V1.0和V1.1中,工作站版本的GC堆大小为16MB,服务器版本为32MB。这意味着当CLR需要扩展GC堆时,它必须为服务器应用程序找到32MB连续的可用虚拟内存。在用户模式下,对于2GB地址空间的系统,这通常不是问题。但是,如果应用程序中有一些非托管dll不小心操作虚拟内存,则虚拟地址空间可能会被划分为可用内存和保留内存的小块。因此,GC将无法找到足够大的空闲内存,尽管总的空闲内存足够。这种问题可以通过查看整个虚拟地址空间来找出哪个块被哪个组件保留。
  2. 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实际上在对堆进行碎片整理方面做得很好。可用空间始终位于堆的顶部,可用于新的分配。但在实际生产中,空闲空间可以驻留在分配的对象之间。这是因为:

  1. 有时,GC可以选择在不必要时不压缩堆的一部分。由于重新定位所有对象的成本可能很高,因此在某些情况下,GC可能会避免这样做。在这种情况下,GC将在堆中保留一个可用空间列表,以备将来压缩。这不会导致堆碎片化,因为GC完全控制可用空间。GC可以在以后任何时候在必要时填充这些块。
  2. 固定对象不能移动。因此,如果一个固定对象在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 0015cde8
12Free

00a7100c 0015cde8
12Free

00a71018 0015cde8
12Free

00a71024 5ba58328
6800a71068 5ba583806800a710ac 5ba584306800a710f0 5ba5dba468...

00a91000 5ba88bd8
206400a91810 0019fe482032Free

00a92000 5ba88bd8
409600a93000 0019fe488192Free

00a95000 5ba88bd8
4096...

total
1892objects



Statistics:

MT Count TotalSize Class Name

5ba7607c
1 12System.Security.Permissions.HostProtectionResource

5ba75d54
1 12System.Security.Permissions.SecurityPermissionFlag

5ba61f18
1 12System.Collections.CaseInsensitiveComparer

...

0015cde8
6 10260Free

5ba57bf8
318 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组件或类似组件中泄漏),但如果所有堆中的字节以与私有字节相同的速率增加,则托管代码中可能会出现泄漏。
同样,如果您看到虚拟字节稳步增加,但是您的私有字节保持相当稳定,那么您的应用程序可能存在一个问题,即它保留了大量未使用的虚拟内存。
一旦进程启动并且加载了所有应用程序域,加载程序堆和当前程序集中的字节应该保持相当恒定。如果此值持续增加,则很可能存在总成泄漏。

所以现在你大概知道你在哪里泄露了,下一步就是找出原因