一:背景
1. 讲故事
前几周分析了一个 40G+ 大内存的dump,这个程序平时最多不到30G,但不知道为啥最近会涨到接近60G,所以让我帮忙分析下怎么回事,像这种大内存dump,如果用传统的方式分析将会是一场灾难,这篇就来详细的说一说,从 windbg 的最佳分析实践来看,一个dump最好不要超过10G,否则就会遇到
- dump跨机器分发慢。
- dump命令处理反馈慢,一个sos命令可能需要数小时,这个是常人无法承受的。
- 分析过程中容易引发机器内存不足告警,毕竟这种dump远大于 开发者的机器内存。
对于80%的场景都适合本条建议,但也有一些例外,比如有一些程序会使用大量缓存,所以内存常常维持在40G的高位,一旦此体量下的程序又出现了内存意外泄露,这种污水混在净水里,很难将其精准的摘出来。
二:超高内存分析方法
1. 一个简单的案例
由于电脑内存有限,我就以常规的 4G
到 6G
来给大家做个演示,代码的意思非常简单,平时4G是因为有3个缓存实例 MemoryCache,后来不知道什么原因在 _staticCache 中灌入了2G数据,导致了非人意的场景发生,完整的参考代码如下:
C#
public class MemorySpikeSimulator
{
private static readonly MemoryCache _memoryCache1 = new MemoryCache("Cache1");
private static readonly MemoryCache _memoryCache2 = new MemoryCache("Cache2");
private static readonly MemoryCache _memoryCache3 = new MemoryCache("Cache3");
private static readonly List<byte[]> _staticCache = new List<byte[]>();
public static void Main()
{
Console.WriteLine("模拟内存增长测试...");
Console.WriteLine("初始内存: " + FormatBytes(GC.GetTotalMemory(false)));
// 模拟正常业务操作 - 三个MemoryCache共4GB
SimulateNormalOperations();
Console.WriteLine("正常操作后内存: " + FormatBytes(GC.GetTotalMemory(false)));
Console.WriteLine("按Enter进行 意外内存 分配阶段... ");
Console.ReadLine();
// 模拟意外的大内存分配 - _staticCache增加2GB
SimulateUnexpectedAllocation();
Console.WriteLine("意外分配后内存: " + FormatBytes(GC.GetTotalMemory(false)));
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("GC后内存: " + FormatBytes(GC.GetTotalMemory(true)));
Console.WriteLine("按任意键退出...");
Console.ReadKey();
}
private static void SimulateNormalOperations()
{
Console.WriteLine("开始正常内存分配 (三个MemoryCache共4GB)...");
// 三个MemoryCache实例共同分配约4GB
for (int i = 0; i < 350; i++) // 增加循环次数以达到4GB
{
var buffer1 = new byte[4 * 1024 * 1024]; // 4MB
_memoryCache1.Add($"cache1_key_{i}", buffer1, DateTimeOffset.Now.AddHours(1));
var buffer2 = new byte[4 * 1024 * 1024]; // 4MB
_memoryCache2.Add($"cache2_key_{i}", buffer2, DateTimeOffset.Now.AddHours(1));
var buffer3 = new byte[4 * 1024 * 1024]; // 4MB
_memoryCache3.Add($"cache3_key_{i}", buffer3, DateTimeOffset.Now.AddHours(1));
if (i % 50 == 0)
{
Console.WriteLine($"已分配: {(i + 1) * 12}MB");
}
Thread.Sleep(10); // 稍微减慢速度
}
}
private static void SimulateUnexpectedAllocation()
{
Console.WriteLine("开始意外内存分配 (_staticCache增加2GB)...");
// _staticCache意外增加约2GB
for (int i = 0; i < 200; i++)
{
var unexpectedData = new byte[10 * 1024 * 1024]; // 10MB
_staticCache.Add(unexpectedData);
if (i % 20 == 0)
{
Console.WriteLine($"已分配: {(i + 1) * 10}MB");
}
Thread.Sleep(1);
}
}
private static string FormatBytes(long bytes)
{
string[] suffixes = { "B", "KB", "MB", "GB", "TB" };
int counter = 0;
decimal number = bytes;
while (Math.Round(number / 1024) >= 1)
{
number /= 1024;
counter++;
}
return $"{number:n2} {suffixes[counter]}";
}
}

2. 分析方法简述
对于一个超大内存的dump,使用常规直接抓dump的方式不是最优方案,所以先需要用微软提供的 vmmap 观察进程的内存地址段布局,看下是托管内存,NTHeap 还是 VirtualAlloc 的泄露,不同的泄露有不同的应灾方案,截图如下:

从卦中可以清晰的看到,总计 6.6G
的内存,托管堆就吃了 6.3G
,所以这个问题就被定性为 托管内存泄露。
问题被定性之后,接下来在生产环境上 正常内存时段
和 异常内存时段
场景下各采1个dump,即 4G 和 6G 场景,这里稍微提醒下,采dump的方式相比dotmemory,perfview 附加进程方式的开销是最小的,dump采到之后,使用 perfview 的 Collect -> Take Heap Snapshot From Dump
或者将 dump 拖到 perfview 里,最终会构建出二个不到1M的 xxx.gcdump 文件,完整的截图如下:

文件有了之后接下来就是借助 perfview 的gcdump对比功能了,分别打开 xxx.dmp.gcdump
下的 Heap Stacks
子窗口,删除 GroupPats
框中的默认分组,接下来准备用 snapshot2
去对比 snapshot1
,选择 Diff -> With Baseline xxx
,截图如下:

在自动打开的新窗口中,可以很明显的看到增长的2G内存都是被 _staticCache
静态变量给吃掉了,截图如下:

到此真相大白,最后稍微提醒一下,如果发现是 ntheap 泄露,那就可以提前开启 ust 了。
三:总结
分析生产环境下的超大内存程序的故障,还是有一定的挑战的,大家也看到了这需要多工具的灵活运用,才能将不利影响降到最低。