一:背景
1. 讲故事
前面两篇我们讲的都是通过挂引用根的方式导致的内存暴涨,在快速检测台上能够一眼就看出是什么类型的Type导致的,分析难度稍微较低,在真实的dump分析场景下,也会存在对象偏小而内存暴涨的情况,一般的新手会被这种场景搞懵逼,这篇就来分享这种奇葩的情况。
二:内存暴涨分析
1. 问题代码
为了方便演示,我们做这样的一个案例,现在的 .NET8 的SOH一个segment是 4M,所以我故意这么设计,分配3M的临时对象,然后再分配一个 50k 的Pinned对象,由于 Pinned 解封之前是GC不可移动对象,最终会导致 堆碎片化 现象,参考代码如下:
C#
internal class Program
{
static void Main(string[] args)
{
var harmony = new Harmony("com.example.gchandleallchook");
harmony.PatchAll();
ProcessData();
Console.ReadLine();
}
static void ProcessData()
{
for (int i = 1; i <= 1000; i++)
{
Allocate_Bytes(i);
Allocate_Pinned(i);
Console.WriteLine($"i={i} 次执行,3M byte[] 分配完毕,50k byte[] 分配完毕");
}
GC.Collect();
Console.WriteLine("碎片化已形成,已强制执行GC,请观察托管堆!");
}
static void Allocate_Bytes(int i)
{
//1k * 1024 * 3 = 3M (1个region)
for (int j = 0; j < 1024 * 3; j++)
{
var bytes = new byte[1024]; // 分配 3096 个 1k 的 byte[]
}
}
static void Allocate_Pinned(int i)
{
GCHandle.Alloc(new byte[1024 * 50], GCHandleType.Pinned); // 50k 的 pinned byte[]
}
}
代码有了之后,接下来就是用 dotMemory 把程序给跑起来,内存走势图如下所示。

从卦中可以看到,内存总计为 1.9G,其中 gen2 就独吃 1.8G,很显然这是托管内存泄露,接下来的操作就是采一个 snapshot,打开快速检测台,截图如下:

从检测台上看并没有看到哪一个类型的对象有占用过大的情况,这是不是让人匪夷所思呢?
2. 为什么对象占用不大
虽然对象占用不大,但内存确确实实被托管堆的gen2所吃,所以必须调转枪头直接观测检测台的尾部 Heap Fragmentation 区域,截图如下:

哈哈,一下子就发现了 gen2 区域的奇观,即使看不懂的话也会觉得奇奇怪怪的,接下来我就简单分析下这里面的几个指标吧。
- heap: 表示当前有 810 个 segment 内存段
- total: 表示当前 gen2 吃了 1.77G 内存。
- used(pinned):表示 1.77G 内存中,pinned 对象占了 48.8M 内存。
- used(unpinned): 表示 1.77G 内存中未固定对象吃了 46.8k 内存。
- free: 表示当前空闲块吃了 1.73G。
上面几个指标合起来就是说 gen2 用 1.77G 内存只装近 50M 的对象,这种奇葩现象就是所谓的 堆碎片化。
接下来就是要寻找这些 pinned 对象,他们到底是什么,为什么让 GC 痛苦不堪,可以选择 Generations 选项卡,双击其中任一个segment,截图如下:

打开面板之后发现都是 Byte[] 数组,通过 Similar retention 选项卡发现都是 Pinning handle ,即通过 GCHandleType.Pinned 固定的,截图如下

接下来的问题是这些 Byte[] 数组到底是被谁固定的?为什么不解开呢?
2. byte[] 是谁创建的
如果把这个问题搞定了,那所有的真相就会大白,那怎么做呢?一般来说有两种做法,第一种就是 full 采集模式,然后观察 byte[] 的调用栈即可,还有一种方式使用 harmony 注入的方式记录调用栈。这里都给大家介绍一下吧。
- full 采集模式
首先要说的是 full 采集模式在真实环境下很难实行,因为它对程序的性能伤害太大了,这个在官方文档中也有所说明,截图如下:

最后选择 Start 按钮开始采集,按照前面所述的方式找到 byte[] 数组再选择 Back Traces 选项卡,可以清楚的看到是 Allocate_Pinned() 方法创建的。

刚才是通过 type 为依据寻找的调用栈,也可以找到具体的 byte[] 实例观察其 Create Stack Trace 选项,同样也能看到,截图如下:

刚才也说了,这种方式虽然可行,但不是第一手段,更合适做万不得已的备份方案,万一程序能受得了这么重的暴击呢?
- harmony 注入
第二种方式就是脱离 dotmemory,采用一种 IL 注入的方式,原理非常简单,就是在 SDK 的 GCHandle.Alloc 内部增加日志,参考代码如下:
C#
public static GCHandle Alloc(object? value, GCHandleType type)
{
// prefix: todo...
return new GCHandle(value, type);
// postfix:todo...
}
在 postfix 中我们记录下调用 Alloc 方法的调用栈,这样是不是就真相大白了,完整的参考代码如下:
C#
internal class Program
{
static void Main(string[] args)
{
var harmony = new Harmony("com.example.gchandleallchook");
harmony.PatchAll();
ProcessData();
Console.ReadLine();
}
static void ProcessData()
{
for (int i = 1; i <= 1000; i++)
{
Allocate_Bytes(i);
Allocate_Pinned(i);
Console.WriteLine($"i={i} 次执行,3M byte[] 分配完毕,50k byte[] 分配完毕");
}
GC.Collect();
Console.WriteLine("碎片化已形成,已强制执行GC,请观察托管堆!");
}
static void Allocate_Bytes(int i)
{
//1k * 1024 * 3 = 3M (1个region)
for (int j = 0; j < 1024 * 3; j++)
{
var bytes = new byte[1024]; // 分配 3096 个 1k 的 byte[]
}
}
static void Allocate_Pinned(int i)
{
GCHandle.Alloc(new byte[1024 * 50], GCHandleType.Pinned); // 50k 的 pinned byte[]
}
}
[HarmonyPatch(typeof(GCHandle), "Alloc", new Type[] { typeof(object), typeof(GCHandleType) })]
public class GCHandleAllocHook
{
public static void Postfix(GCHandle __result, GCHandleType type)
{
if (type == GCHandleType.Pinned)
{
Console.WriteLine($" - 句柄指针: 0x{GCHandle.ToIntPtr(__result).ToInt64():X}");
Console.WriteLine($" - 句柄类型: {type}");
Console.WriteLine(Environment.StackTrace);
}
}
}
最后运行程序,观察日志输出即可,截图如下:

从卦中日志看是不是轻松的就找到了 Allocate_Pinned() 方法,在真实场景中还是建议大家写到 Nlog 这样的日志框架中。
三:总结
DotMemory 在可视化方面做的还是蛮强大的,感觉特别适合作为 技术支持工程师 的首选工具,希望本篇能给你带来一些帮助。
