众所周知,在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 0015cde8 12 Free 00a7100c 0015cde8 12 Free 00a71018 0015cde8 12 Free 00a71024 5ba58328 68 00a71068 5ba58380 68 00a710ac 5ba58430 68 00a710f0 5ba5dba4 68 ... 00a91000 5ba88bd8 2064 00a91810 0019fe48 2032 Free 00a92000 5ba88bd8 4096 00a93000 0019fe48 8192 Free 00a95000 5ba88bd8 4096 ... total 1892 objects Statistics: MT Count TotalSize Class Name 5ba7607c 1 12 System.Security.Permissions.HostProtectionResource 5ba75d54 1 12 System.Security.Permissions.SecurityPermissionFlag 5ba61f18 1 12 System.Collections.CaseInsensitiveComparer ... 0015cde8 6 10260 Free 5ba57bf8 318 18136 System.String ...
0:000>!dumpobj 00a92000 Name: System.Byte[] MethodTable 0x00992c3c EEClass 0x00992bc4 Size 4096(0x1000) bytes Array: Rank 1, Type System.Byte Element Type: System.Byte 0:000>!gcroot 00a92000 Scan Thread 0 (728) Scan Thread 1 (730) ESP:88cf548:Root:05066b48(System.IO.MemoryStream)->00a92000 (System.Byte[]) ESP:88cf568:Root:05066b48(System.IO.MemoryStream)->00a92000 (System.Byte[]) ... Scan HandleTable 9b130 Scan HandleTable 9ff18 HANDLE(Pinned):d41250:Root: 00a92000 (System.Byte[])
这表明地址00a92000的对象是一个字节数组,它的根是线程1中的局部变量(准确地说!GCRoot在堆栈中的根的输出不可信任)和一个固定句柄。
命令“ObjSize”列出所有句柄,包括固定的手柄:
0:000>!objsize ... HANDLE(Pinned):d41250: sizeof(00a92000) = 4096 ( 0x1000) bytes (System.Byte[]) HANDLE(Pinned):d41254: sizeof(00a95000) = 4096 ( 0x1000) bytes (System.Byte[]) HANDLE(Pinned):d41258: sizeof(00ac8b5b0) = 16 ( 0x10) bytes (System.Byte[]) ...
使用这些Sos命令,您可以清楚地了解堆是否碎片化以及如何碎片化。
解决方案
GC做了很多工作来识别由钉扎引起的碎片并缓解这种情况,但是除了平台的改变,用户代码也可以做一些事情来避免这个问题。从以上分析可以看出:
- 如果锁定对象在同一时间分配,则每两个对象之间的空闲槽将更小,情况会更好。
- 如果锁定发生在较旧的对象上,则会导致较少的问题。因为旧对象位于堆的底部,但大部分可用空间是在堆的顶部生成的。
- 对象被锁定得越短,GC就越容易压缩堆
因此,如果锁定成为导致.NET应用程序OOM的问题,开发人员可以考虑预先分配要锁定的对象并重用它们,而不是每次都创建新的锁定对象。这样,这些对象将在GC堆的旧部分中彼此靠近,堆就不会被分割得太多。例如,如果应用程序一直固定1K缓冲区(考虑socket server的情况),我们可以使用这样的缓冲池来获取缓冲区:
public class BufferPool { private const int INITIAL_POOL_SIZE = 512; // initial size of the pool private const int BUFFER_SIZE = 1024; // size of the buffers // pool of buffers private Queue m_FreeBuffers; // singleton instance private static BufferPool m_Instance = new BufferPool (); // Singleton attribute public static BufferPool Instance { get { return m_Instance; } } protected BufferPool() { m_FreeBuffers = new Queue (INITIAL_POOL_SIZE); for (int i = 0; i < INITIAL_POOL_SIZE; i++) { m_FreeBuffers.Enqueue (new byte[BUFFER_SIZE]); } } // check out a buffer public byte[] Checkout (uint size) { if (m_FreeBuffers.Count > 0) { lock (m_FreeBuffers) { if (m_FreeBuffers.Count > 0) return (byte[])m_FreeBuffers.Dequeue (); } } // instead of creating new buffer, // blocking waiting or refusing request may be better return new byte [BUFFER_SIZE]; } // check in a buffer public void Checkin (byte[] buffer) { lock (m_FreeBuffers) { m_FreeBuffers.Enqueue (buffer); } } }