惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
The Blog of Author Tim Ferriss
IT之家
IT之家
Engineering at Meta
Engineering at Meta
WordPress大学
WordPress大学
博客园 - 三生石上(FineUI控件)
博客园 - 聂微东
C
Check Point Blog
T
Tailwind CSS Blog
博客园 - Franky
H
Help Net Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Google DeepMind News
Google DeepMind News
博客园 - 叶小钗
J
Java Code Geeks
腾讯CDC
罗磊的独立博客
爱范儿
爱范儿
阮一峰的网络日志
阮一峰的网络日志
Martin Fowler
Martin Fowler
酷 壳 – CoolShell
酷 壳 – CoolShell
I
InfoQ
B
Blog
V
Visual Studio Blog
F
Fortinet All Blogs

博客园 - 一线码农

记一次 .NET 某智慧医保云服务Linux 非托管泄露分析 .NET 高级调试技术:超越基础 Dump 分析 .NET 生产环境调试实战指南 记一次 .NET 某光电成像后端解析系统 内存暴涨分析 记一次 .NET 某电力后台监控系统 内存暴涨分析 记一次 .NET 某注塑模具系统 CPU爆高分析 记一次 .NET 某集群管理软件 内存暴涨分析 记一次 .NET 某智慧工厂视觉程序 崩溃分析 记一次 .NET 某低代码开发框架 内存暴涨分析 记一次 .NET 某MES上位机拍照系统 内存暴涨分析 记一次 .NET 某RFID标签打印客户端 崩溃分析 对 .NET FileSystemWatcher引发内存碎片化的 反思 DotMemory系列:5. 如何实现自动化抓取和应用自托管 DotMemory系列:4. 如何分析进程的转储文件 DotMemory系列:3. 堆碎片化引发的内存暴涨分析 DotMemory系列:2. 事件泄露引发的内存暴涨分析 记一次 .NET 某理财管理客户端 OOM溢出分析 记一次 .NET 某医联体管理系统 崩溃分析 记一次 .NET 某光放测试系统 崩溃分析 记一次 .NET 某药品缺陷高速检测系统 卡慢分析 聊一聊 .NET超高内存故障分析方法 的反思 记一次 .NET 某企业ECM内容管理系统 内存暴涨分析 记一次 .NET 某跨境物流系统 内存暴涨分析 记一次 .NET 某中医药附属医院门诊系统 崩溃分析 聊一聊 .NET 中的 CompositeChangeToken 聊一聊 .NET 中的 CancellationTokenSource 记一次 .NET 某CRM物流行业管理系统 崩溃分析
DotMemory系列:1. 终结队列积压引发的内存暴涨分析
一线码农 · 2025-11-14 · via 博客园 - 一线码农

一:背景

1. 讲故事

说实话本来是不想写这个系列的,因为我潜意识里觉得这款工具就像美图秀秀一样,拉低专业人士的档次,但奈何在训练营里我需要用到 dottrace 这款工具,而我向官方申请再续了一年免费的Pack套件也给我通过了,所以我觉得要对得起他们,得要写点什么,截图如下:

这几天我也仔细看了下DotMemory的文档,发现还是有一些可圈可点的地方,毕竟美图秀秀也有美图秀秀的闪光点,在某些场景下完全可以用 DotMemory 作为WinDbg出场的第一套关卡,想来想去我决定还是写5篇托管内存故障来演示下DotMemory的使用,也确实它的可视化做的非常好,那这篇就先从 终结队列积压 导致的内存暴涨开始吧。

二:内存暴涨分析

1. 问题代码

为了演示 终结队列积压 引发的内存暴涨,我故意让 终结器线程 处理的慢一些,这样就会存在不断的囤积情况,参考代码如下:


    internal class Program
    {
        static void Main(string[] args)
        {
            for (int i = 1; i < 500000; i++)
            {
                NewPerson(i);
            }
            Console.WriteLine("50w 个对象插入完毕!");
            Console.ReadLine();
        }

        static void NewPerson(int i)
        {
            var person = new Person()
            {
                ID = i + 1,
                Name = string.Join(",", Enumerable.Range(0, 1000))
            };
        }
    }

    public class Person
    {
        public int ID { get; set; }

        public string Name { get; set; }

        ~Person()
        {
            Thread.Sleep(1000);
            Console.WriteLine($"析构函数 {ID}: 执行完毕...");
        }
    }

2. DotMemory 分析

这里我用的是 DotMemory 2025.1 版本,用 dotmemory 开启子进程的方式启动,大概三步走就行了,截图如下:

这里一定要选择 Sampled 采样模式,如果选择 Full 模式那几乎是无法跑的,因为都是基于 ETW 的,所以和 perfview 的 .NET SampleAlloc 模式是一模一样的。

点击 Start 后,会有一个内存用量的动态图,在内存出现暴涨后,使用 Get Snapshot 采一个快照下来,截图如下:

打开图中左下角的 Snapshot #1 快照,映入眼帘的就是 Inspections 视图,翻译过来用 检测台 比较合适,截图如下:

稍微熟悉 DotMemory 的朋友,看到快速通览之后肯定会发现问题所在,我就单独开一节来说吧!

3. 问题浮现

  1. Largest Size 环形图

这个图是告诉大家某一类对象的浅层大小,即不包含他们的孩子节点,用 windbg 的话术就是直接取Person自身的 Size=32byte,很显然这 32byte 是不包含 Person.<Name>k__BackingFieldSize=7800byte 的,输出如下:


0:015> !dumpobj /d 2e9693a2fa0
Name:        Example_20_1_1.Person
MethodTable: 00007ffa0b5fa898
EEClass:     00007ffa0b6046a8
Tracked Type: false
Size:        32(0x20) bytes
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffa0b4b1188  4000001       10         System.Int32  1 instance            23906 <ID>k__BackingField
00007ffa0b52ec08  4000002        8        System.String  0 instance 000002e9693a3000 <Name>k__BackingField

0:015> !DumpObj /d 000002e9693a3000
Name:        System.String
MethodTable: 00007ffa0b52ec08
EEClass:     00007ffa0b50a5d8
Tracked Type: false
Size:        7800(0x1e78) bytes
String:      0,1,2,3,...
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffa0b4b1188  400033b        8         System.Int32  1 instance             3889 _stringLength
00007ffa0b4bb538  400033c        c          System.Char  1 instance               30 _firstChar
00007ffa0b52ec08  400033a       c8        System.String  0   static 000002e900000008 Empty

有了上面的思路之后,你应该就知道这个程序中吃的最多的就是String类型,总计 3.63G,对 String 产生重大怀疑之后,接下来就是看第二个环形图。

  1. Largest Retained Size 环形图

如果说刚才的图是不包含孩子节点的,那这张图就是切切实实的包含孩子节点,有些人可能要问,既然是包含关系,那包含的起点在哪里呢?熟悉 gc标记阶段的朋友应该知道,这个起点应该就是 root 根。

有了这个基础之后,你就应该能明白为什么 Person 类型的总量是排在第一位的,刚才的 windbg 输出已经告诉了我们,看样子 Person.<Name>k__BackingField 正是我们的问题所在。

  1. String duplicates 问题

在内功修炼训练营里跟大家分享过 驻留池 的底层原理,其实这个就是和 驻留池 有关,从卦中可以看到由 49.9w 的字符串理应都要进池子,结果都是以副本的形式存在于托管堆中,所以这里有了 Wasted=3.63G 一说,哈哈,到这里又看到了一处非常不合理的地方,也就说如果把这 49.9w 的string全部进池子,那么内存一下子就下去了,等一会我们来验证吧。

  1. Finalizable Objects 问题

这里有一个异常的信号,即 红色感叹号,说明这里可能存在一个大问题,从列表中可以看到 Person=49.9w,截图如下:

这里的 49.9w 表示什么呢? 熟悉clr终结队列的朋友应该知道,这个 queued 其实就是 freachable queue 区域,即 终结器线程 提取对象的地方。

如果有些朋友还是搞不清楚,在我的训练营里有详细的画图说明,其中的 深绿色区域 就是所谓的提取区域,截图如下:

如果一定要在 dotmemory 上验证,那就双击呗,观察 Similar Retention 选项即可,截图如下:

言归正传,接下来的问题就来了,为什么 终结器队列 中有那么多的囤积?

4. 寻求问题之道

由于是采样模式,直接观察 CallTreeBack Traces 选项卡会不准,所以就直接观察 Person 的源代码,为什么 析构函数 这么不给力,很快就发现有不对的地方,这里居然有慢处理 Thread.Sleep(1000),参考如下:


        ~Person()
        {
            Thread.Sleep(1000);
            Console.WriteLine($"析构函数 {ID}: 执行完毕...");
        }

这里稍微提醒一下,在真实场景中,一般会用 windbg 去观察此时的 终结器线程 的调用栈,但无奈 dotmemory 不具备观察线程的调用栈能力。

所以解决办法就比较简单了,将 Thread.Sleep(1000); 注释掉即可。

最后再说一种办法,也就是刚才说到了 wasted,如果全部送到驻留池,其实也是治标不治本的方法,但在这种场景下可以绝对的延迟OOM的时间,即用 string.Intern 给包起来,参考代码如下:


        static void NewPerson(int i)
        {
            var person = new Person()
            {
                ID = i + 1,
                Name = string.Intern(string.Join(",", Enumerable.Range(0, 1000)))
            };
        }

从卦中可以看到,其实送入了 50w 的超大 string,因为内存中只保有一份,所以再怎么大也大不起来,从检测台上也能看到那玩意在 String duplicates 列表中消失了,截图如下:

三:总结

DotMemory虽为美图秀秀,但秀秀也有秀秀的场景,在进一步深度分析之前,它是一款很好的快速通览利器。

图片名称