DotMemory系列:3. 堆碎片化引发的内存暴涨分析

一:背景

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 区域的奇观,即使看不懂的话也会觉得奇奇怪怪的,接下来我就简单分析下这里面的几个指标吧。

  1. heap: 表示当前有 810 个 segment 内存段
  2. total: 表示当前 gen2 吃了 1.77G 内存。
  3. used(pinned):表示 1.77G 内存中,pinned 对象占了 48.8M 内存。
  4. used(unpinned): 表示 1.77G 内存中未固定对象吃了 46.8k 内存。
  5. free: 表示当前空闲块吃了 1.73G。

上面几个指标合起来就是说 gen2 用 1.77G 内存只装近 50M 的对象,这种奇葩现象就是所谓的 堆碎片化

接下来就是要寻找这些 pinned 对象,他们到底是什么,为什么让 GC 痛苦不堪,可以选择 Generations 选项卡,双击其中任一个segment,截图如下:

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

接下来的问题是这些 Byte[] 数组到底是被谁固定的?为什么不解开呢?

2. byte[] 是谁创建的

如果把这个问题搞定了,那所有的真相就会大白,那怎么做呢?一般来说有两种做法,第一种就是 full 采集模式,然后观察 byte[] 的调用栈即可,还有一种方式使用 harmony 注入的方式记录调用栈。这里都给大家介绍一下吧。

  1. full 采集模式

首先要说的是 full 采集模式在真实环境下很难实行,因为它对程序的性能伤害太大了,这个在官方文档中也有所说明,截图如下:

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

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

刚才也说了,这种方式虽然可行,但不是第一手段,更合适做万不得已的备份方案,万一程序能受得了这么重的暴击呢?

  1. 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 在可视化方面做的还是蛮强大的,感觉特别适合作为 技术支持工程师 的首选工具,希望本篇能给你带来一些帮助。