如何寻找 C# 托管内存泄漏

一:背景

前几篇我们聊的都是 非托管内存泄漏,这一篇我们再看下如何用 PerfView 来排查 托管内存泄漏 ,其实 托管内存泄漏 比较好排查,尤其是用 WinDbg,毕竟C#是带有丰富的元数据,不像C++下去就是二进制。

二:如何分析

PerfView 用的是权重占比来寻找可疑的问题函数,为了方便讲述,我们先上一段问题代码。.

    internal class Program
    {
        static void Main(string[] args)
        {
            Task.Run(Alloc1);
            Task.Run(Alloc2);
            Task.Run(Alloc3);

            Console.ReadLine();
        }

        static void Alloc1()
        {
            var list = new List<string>();

            for (int i = 0; i < 200000; i++)
            {
                list.Add(string.Join(",", Enumerable.Range(0, 1000)));
            }

            Console.WriteLine("Alloc1 处理完毕");
        }

        static void Alloc2()
        {
            var list = new List<string>();

            for (int i = 0; i < 100; i++)
            {
                list.Add(string.Join(",", Enumerable.Range(0, 1000)));
            }

            Console.WriteLine("Alloc2 处理完毕");
        }

        static void Alloc3()
        {
            var list = new List<string>();

            for (int i = 0; i < 100; i++)
            {
                list.Add(string.Join(",", Enumerable.Range(0, 1000)));
            }

            Console.WriteLine("Alloc3 处理完毕");
        }
    }

这段代码运行完成后会发现内存占用高达 1.5G,如下图所示:

如何寻找 C# 托管内存泄漏

在真实场景中,你根本不知道是谁占用了这么大的内存,在分析武器库中,用 WinDbg 肯定是最稳的,既然是介绍 PerfView 工具,得用它来分析。

二:PerfView 分析

1. 到底是哪里的泄漏

分析之前,还是要先搞清楚到底是哪里的泄漏,才好用 PerfView 追查下来,首先用 !eeheap -gc 查看下托管堆的占用大小。

0:005> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x0000000072D7AEC0
generation 1 starts at 0x0000000072B1B790
generation 2 starts at 0x0000000002841000
ephemeral segment allocation context: none
         segment             begin         allocated         committed    allocated size    committed size
0000000002840000  0000000002841000  000000001283FB10  0000000012840000  0xfffeb10(268430096)  0xffff000(268431360)
0000000023E80000  0000000023E81000  0000000033E7F0A8  0000000033E80000  0xfffe0a8(268427432)  0xffff000(268431360)
00000000347D0000  00000000347D1000  00000000447CFA98  00000000447D0000  0xfffea98(268429976)  0xffff000(268431360)
0000000045A60000  0000000045A61000  0000000055A5E2A0  0000000055A60000  0xfffd2a0(268423840)  0xffff000(268431360)
0000000055A60000  0000000055A61000  0000000065A5F7B8  0000000065A60000  0xfffe7b8(268429240)  0xffff000(268431360)
0000000065A60000  0000000065A61000  0000000073252ED8  00000000735F6000  0xd7f1ed8(226434776)  0xdb95000(230248448)
Large object heap starts at 0x0000000012841000
         segment             begin         allocated         committed    allocated size    committed size
0000000012840000  0000000012841000  0000000012C21130  0000000012C22000  0x3e0130(4063536)  0x3e1000(4067328)
Pinned object heap starts at 0x000000001A841000
000000001A840000  000000001A841000  000000001A845C38  000000001A852000  0x4c38(19512)  0x11000(69632)
Total Allocated Size:              Size: 0x5dbcdce8 (1572658408) bytes.
Total Committed Size:              Size: 0x5df71000 (1576472576) bytes.
------------------------------
GC Allocated Heap Size:    Size: 0x5dbcdce8 (1572658408) bytes.
GC Committed Heap Size:    Size: 0x5df71000 (1576472576) bytes.

从输出中可以看到,当前的 托管堆 占用 1.5G, 这就说明当前的泄漏确实是 托管堆 的泄漏,这就给继续分析指明了方向。

2. 使用 .NET Alloc 拦截

在 PerfView 中有一个 .NET Alloc 选项,它可以拦截每一次对象分配,然后记录下 线程调用栈,再根据分配量计算权重,知道原理后,接下来就可以开启 .NET Alloc 拦截。

如何寻找 C# 托管内存泄漏

需要注意的是,对于这个选项,需要先开启收集,再启动程序,等程序执行完毕后,点击 Stop Collection ,稍等片刻,会看到如下截图。

如何寻找 C# 托管内存泄漏

点击 GC Heap Net MEM (Coarse Sampling) Stack 列表,选择我们的进程,会看到当前的 System.String 权重占比最高,所以调查它的分配源就是当务之急了,截图如下:

如何寻找 C# 托管内存泄漏

接下来双击 System.String 行,查看它的 Callers,逐一往下翻,终于找到了 Program.Alloc1() 方法,截图如下:

如何寻找 C# 托管内存泄漏

到这里就找到了问题函数 Alloc1() ,接下来就是探究源码了哈。

3. 生产中可以用 .NET Alloc 吗

现在大家都知道 .NET Alloc 可以实现对象分配拦截,但是在生产场景中,每秒的分配量可能达到几十万,上百万,每一次分配都要拦截,会产生诸多的负面影响。

1) 程序速度变慢。

2) 产生非常大的 zip 文件。

如果你不在意的话,可以这么使用,如果在意,建议用 .NET SampAlloc 选项,它是一种采样的方式,每秒中的同类型分配最多只会采样 100 次,所以在 性能 和 zip文件 两个维度可以达到最优状态。

接下来勾选 .NET SampAlloc 项,其他操作步骤一致,截图如下:

如何寻找 C# 托管内存泄漏

有点意思的是,观察到的占比都是 43.7% ,哈!