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

推荐订阅源

U
Unit 42
Blog — PlanetScale
Blog — PlanetScale
H
Help Net Security
The GitHub Blog
The GitHub Blog
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
Recent Announcements
Recent Announcements
量子位
aimingoo的专栏
aimingoo的专栏
大猫的无限游戏
大猫的无限游戏
博客园 - 叶小钗
Microsoft Azure Blog
Microsoft Azure Blog
Martin Fowler
Martin Fowler
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
A
About on SuperTechFans
T
Tailwind CSS Blog
V
V2EX
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
B
Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
SegmentFault 最新的问题
G
Google Developers Blog
M
MIT News - Artificial intelligence

博客园 - 一线码农

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

一:背景

1. 讲故事

前面两篇我们讲的都是通过挂引用根的方式导致的内存暴涨,在快速检测台上能够一眼就看出是什么类型的Type导致的,分析难度稍微较低,在真实的dump分析场景下,也会存在对象偏小而内存暴涨的情况,一般的新手会被这种场景搞懵逼,这篇就来分享这种奇葩的情况。

二:内存暴涨分析

1. 问题代码

为了方便演示,我们做这样的一个案例,现在的 .NET8 的SOH一个segment是 4M,所以我故意这么设计,分配3M的临时对象,然后再分配一个 50k 的Pinned对象,由于 Pinned 解封之前是GC不可移动对象,最终会导致 堆碎片化 现象,参考代码如下:


    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 内部增加日志,参考代码如下:


public static GCHandle Alloc(object? value, GCHandleType type)
{
    // prefix: todo...

    return new GCHandle(value, type);
    
    // postfix:todo...
}

在 postfix 中我们记录下调用 Alloc 方法的调用栈,这样是不是就真相大白了,完整的参考代码如下:


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

图片名称