摘要
本文记录了一次对 .NET 应用程序发生的“内存不足”(OOM)异常进行的深度源码级调查。问题始于一个看似矛盾的现象:诊断工具显示有足够大的空闲内存块(约 50MB),但垃圾回收(GC)过程却在尝试预留较小内存段(约 16MB)时失败。
为了探究根源,笔者逆向追踪了诊断工具(如SOS)输出的数据链路:从高层诊断命令(AnalyzeOOMCommand)入手,逐步深入Microsoft.Diagnostics.Runtime (CLRMD)库、托管辅助类、Dac 接口,最终直达 .NET Runtime(CoreCLR) 底层的 GC 相关 Native 代码(如 ClrDataAccess、gc_heap)。
虽然最终并没能定位问题的真实原因,但是理清了 GC 在 virtual_alloc过程中因内存限制检查、地址空间布局考量等因素导致预留失败的具体逻辑,并对 .NET 源码有了一定的认识,还是非常值得记录分享的。
缘起
前些日子,在 .NET 调试群里有位网友的 .NET 程序触发了 OOM (Out Of Memory) 异常,他在群里发了一些截图,询问大家是什么原因导致的。其中一张分析结果图如下:

看着是 gc 过程中发生了内存不足的问题。大概率是要分配 16MB 左右的内存空间时失败了,但是另外一张截图显示,最大空闲块还有 50MB,如下图:

由于没看过 .net 源码,只能根据自己的认知进行了回复,这种心里没底的感觉很不爽。正好最近想多研究下 .net,而且有源代码可查,为啥不看看呢?
下载源码
可以通过 git clone https://github.com/dotnet/runtime.git 命令把 .NET runtime 源码克隆到本地。还可以下载对应的诊断工具源码,包括但不限于 SOS。可以通过 git clone https://github.com/dotnet/diagnostics.git 克隆到本地。
说明: 最开始的想法是编译一份进行调试,折腾了半天,还遇到一些编译错误,这里就不展开了,后面会有一篇文章单独总结。
下载的版本与我我本地的运行时版本(
8.0.7)不匹配,切换到对应的版本(git tag --list然后git checkout v8.0.7)。
如何开始呢?截图中的关键信息描述就是入手点,当然从搜索入手了。
追踪 OOM 来源
打开文件内容搜索神器 FileLocator,搜索 Failed to reserve memory,在 AnalyzeOOMCommand.cs 中发现了匹配项。

关键类摘录如下:
1 | public class AnalyzeOOMCommand : ClrRuntimeCommandBase |
从代码看,与网友截图中的输出高度匹配,看样子是找对地方了。从代码逻辑可知,AnalyzeOOMCommand 命令会遍历堆,输出每个堆上的 ClrOutOfMemoryInfo 信息。关键代码如下:
foreach (ClrOutOfMemoryInfo oom in Runtime.Heap.SubHeaps.Select(h => h.OomInfo).Where(oom => oom != null))
至此可推断,发生 OOM 异常时,runtime 会把相关信息保存到堆上。SOS 等插件直接从对上取出对应信息,展示出来即可。看完这个类的实现,感觉我又行了,也能写一个类似的插件了,哈哈哈。不废话了,回到正题。单击 h.OomInfo 跳转到 OomInfo 的实现,可以发现其是类 ClrSubHeap 的一个属性字段。
说明:
ClrSubHeap并没有实现在diagnostics工程中,而是实现在Microsoft.Diagnostics.Runtime中,可以通过git clone https://github.com/microsoft/clrmd.git下载。
打开 Microsoft.Diagnostics.Runtime.sln,可以搜到 OomInfo 的实现,如下:
public ClrOutOfMemoryInfo? OomInfo => Heap.Helpers.GetOOMInfo(Address, out OomInfo oomInfo) ? new(oomInfo) : null;
OomInfo 来自 Heap.Helpers.GetOOMInfo() 函数。而 Heap 是 ClrSubHeap 的一个字段,在 ClrSubHeap 构造的时候传进来。
1 | public class ClrSubHeap : IClrSubHeap |
跳转到 ClrHeap 的实现看下 Helpers 的来源
1 | public sealed class ClrHeap : IClrHeap |
可以发现 Helpers 是在 ClrHeap 构造的时候通过参数传进来的,而且 SubHeaps 也会在 ClrHeap 构造的时候被创建出来。
再看下 ClrHeap 是怎么被构造出来的。在 ClrHeap 的构造函数上方点击引用数量,跳转到对应的位置。可以发现其来自 ClrRuntime 的 Heap 属性。
1 | public sealed class ClrRuntime : IClrRuntime |
可以发现 heapHelpers 参数来自 IAbstractHeap? heapHelpers = GetService<IAbstractHeap>();
跳转到 GetService() 的实现,如下:
1 | internal T? GetService<T>() where T: class => (T?)_services.GetService(typeof(T)); |
可以发现,GetService<T>() 是通过 _services.GetService(typeof(T)) 实现的,再看下 _services 的来源。
1 | public sealed class ClrRuntime : IClrRuntime |
发现 _services 是在 ClrRuntime 构造的时候传进来的。点击 ClrRuntime 的构造函数上方的引用计数,可以发现 ClrRuntime 在ClrInfo 的 CreateRuntimeWorker() 函数中被创建,services 参数来自 ClrInfoProvider.GetDacServices()。
1 | public sealed class ClrInfo : IClrInfo |
再看下 ClrInfoProvider 的来源,发现其是 ClrInfo 的属性成员,在 ClrInfo 构造的时候被初始化。
1 | public sealed class ClrInfo : IClrInfo |
再看看 ClrInfo 是在哪里被创建的,点击 ClrInfo 的构造函数上方的引用计数,可以发现其来自 DotNetClrInfoProvider 的 CreateClrInfo() 函数。
1 | internal class DotNetClrInfoProvider : IClrInfoProvider |
由以上代码可知,ClrInfo 构造函数的最后一个参数是 this,所以 ClrInfo 中的 ClrInfoProvider 是 DotNetClrInfoProvider 类型的对象。再来查看一下 DotNetClrInfoProvider::GetDacServices() 函数。
1 | public IServiceProvider GetDacServices(ClrInfo clrInfo, string? providedPath, bool ignoreMismatch, bool verifySignature) |
会返回 DacServiceProvider 类型的对象,所以 ClrRuntime._services 实际是 DacServiceProvider 类型的对象。ClrRuntime 的 Heap 属性中调用的 IAbstractHeap? heapHelpers = GetService<IAbstractHeap>() 就相当于调用的是 DacServiceProvider.GetService(IAbstractHeap)。
看看 DacServiceProvider.GetService(Type) 的实现,如下:
1 | internal class DacServiceProvider : IServiceProvider, IDisposable, IAbstractDacController |
所以 ClrHeap 中的 Helpers 成员的类型是 DacHeap,看看其 GetOOMInfo() 的实现。
1 | internal sealed class DacHeap : IAbstractHeap |
调用了 _sos.GetOOMData(out oomData)。_sos 是 DacHeap 的成员变量,来自 DacHeap 构造函数的第一个参数。
1 | internal sealed class DacHeap : IAbstractHeap |
而 DacHeap 又是在 DacServiceProvider.GetService(Type) 中创建的,关键代码是
return _heapHelper = new DacHeap(_sos, _sos8, _sos12, _sos16, _dataReader, data, mts);
传递给 DacHeap 的第一个参数是 DacServiceProvider 的成员变量 _sos。该成员变量是在 DacServiceProvider 的构造函数中初始化的。构造函数如下:
1 | internal class DacServiceProvider : IServiceProvider, IDisposable, IAbstractDacController |
_sos 是由 _process.CreateSOSDacInterface() 创建的,而 _process 的类型是 ClrDataProcess,看一下 _process.CreateSOSDacInterface() 的实现,如下:
1 | internal sealed unsafe class ClrDataProcess : CallableCOMWrapper |
该函数会返回 SOSDac 类型的对象,该类型构造函数的第二个参数是通过 QueryInterface(SOSDac.IID_ISOSDac) 得到的,SOSDac.IID_ISOSDac 的值是 436f00f2-b42a-4b9f-870c-e73db66ae930,是 SOSDac 类的静态变量,SOSDac 的定义如下:
1 | internal sealed unsafe class SOSDac : CallableCOMWrapper |
此类什么有用的事情都没做,都是调用 VTable 中的实现,而且其基类是 CallableCOMWrapper,可以大胆猜测此类是一个 COM 调用类,真正的实现在 native 层。是不是呢?到 native 层搜搜就知道了。
查看 clr runtime 实现
在 native 代码中搜索 436f00f2-b42a-4b9f-870c-e73db66ae930,可以在 sospriv.h 头文件中搜到。
1 | MIDL_INTERFACE("436f00f2-b42a-4b9f-870c-e73db66ae930") //<--- |
继续搜索 ISOSDacInterface,可以在 daccess.cpp 中找到使用的地方,对应的实现类是 ClrDataAccess。
说明: 对应的声明文件在
D:\dotnet\runtime\src\coreclr\debug\daccess\dacimpl.h
1 | //D:\dotnet\runtime\src\coreclr\debug\daccess\daccess.cpp |
可以查看 ClrDataAccess::GetOOMData() 的具体实现,如下:
1 | //D:\dotnet\runtime\src\coreclr\debug\daccess\request.cpp |
ClrDataAccess::ServerOomData() 的实现如下:
1 | //D:\dotnet\runtime\src\coreclr\debug\daccess\request_svr.cpp |
由以上代码可知,oomData 来自 pHeap->oom_info,看下 oom_info 的定义,如下
1 | //D:\dotnet\runtime\src\coreclr\gc\gcpriv.h |
其类型是 oom_history,查看定义,如下:
1 | //D:\dotnet\runtime\src\coreclr\gc\gcinterface.dac.h |
看到以上定义就太亲切了。根据目前了解到的信息,这个结构体应该是当发生 OOM 时,runtime 设置的结构体。可以在代码中搜索使用 fgm_reserve_segment 的地方,一共就搜到两处,一处是其定义的地方,一处是使用的地方,使用的代码如下:
1 | heap_segment* |
可以发现,当 virtual_alloc (size) 的返回值是空时,会设置 fgm_reserve_segment。再看看 virtual_alloc 的实现,如下:
1 |
|
以上代码,一共有三个地方会导致返回空,第一处代码如下:
1 | if ((gc_heap::reserved_memory_limit - gc_heap::reserved_memory) < requested_size) |
大概逻辑是,如果保留内存限值(gc_heap::reserved_memory_limit)- 已保留的内存(gc_heap::reserved_memory)小于 请求字节数(requested_size),就调用 GCScan::AskForMoreReservedMemory() 请求保留更多内存,该函数会返回新的限值。如果 新限值 - 已保留的内存 还是小于 请求字节数 就返回空。
第二处代码如下:
1 | void* prgmem = use_large_pages_p ? |
由于,use_large_pages_p 是 false,会调用 GCToOSInterface::VirtualReserve(),该函数底层又会直接调用 VirtualAlloc()。
第三处代码如下:
1 | if (prgmem) |
MAX_PTR 为最大的无符号整数,end_mem 是此次分配的内存段的结束位置,如果结束位置后面的空间不能容纳大对象堆,也返回空。
至此,本次折腾就告一段路了,第一张图片中的报错信息,基本上是 virtual_alloc 失败导致的问题。为什么 virtual_alloc 会失败,我到现在也没想明白。因为只尝试保留内存空间,并没有进行提交,按理说在有足够大的空闲内存空间时,不应该失败才对。什么情况下 VirtualAlloc() 会失败,还望各位大牛不吝赐教!
总结
- 再次强烈推荐一下
FileLocator文件内容搜索神器,你值得拥有
参考资料
.net源码