记得你的冬季 發表於 2025-11-16 09:52:00

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

<h2 id="一背景">一:背景</h2>
<h3 id="1-讲故事">1. 讲故事</h3>
<p>前面两篇我们讲的都是通过<code>挂引用根</code>的方式导致的内存暴涨,在快速检测台上能够一眼就看出是什么类型的Type导致的,分析难度稍微较低,在真实的dump分析场景下,也会存在对象偏小而内存暴涨的情况,一般的新手会被这种场景搞懵逼,这篇就来分享这种奇葩的情况。</p>
<h2 id="二内存暴涨分析">二:内存暴涨分析</h2>
<h3 id="1-问题代码">1. 问题代码</h3>
<p>为了方便演示,我们做这样的一个案例,现在的 .NET8 的SOH一个segment是 4M,所以我故意这么设计,分配3M的临时对象,然后再分配一个 50k 的Pinned对象,由于 Pinned 解封之前是GC不可移动对象,最终会导致 堆碎片化 现象,参考代码如下:</p>
<pre><code class="language-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 &lt;= 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 &lt; 1024 * 3; j++)
            {
                var bytes = new byte; // 分配 3096 个 1k 的 byte[]
            }
      }

      static void Allocate_Pinned(int i)
      {
            GCHandle.Alloc(new byte, GCHandleType.Pinned); // 50k 的 pinned byte[]
      }
    }

</code></pre>
<p>代码有了之后,接下来就是用 dotMemory 把程序给跑起来,内存走势图如下所示。</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203715-1696845005.png" alt="" loading="lazy"></p>
<p>从卦中可以看到,内存总计为 <code>1.9G</code>,其中 gen2 就独吃 1.8G,很显然这是托管内存泄露,接下来的操作就是采一个 snapshot,打开快速检测台,截图如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203722-1252300451.png" alt="" loading="lazy"></p>
<p>从检测台上看并没有看到哪一个类型的对象有占用过大的情况,这是不是让人匪夷所思呢?</p>
<h3 id="2-为什么对象占用不大">2. 为什么对象占用不大</h3>
<p>虽然对象占用不大,但内存确确实实被托管堆的gen2所吃,所以必须调转枪头直接观测检测台的尾部 <code>Heap Fragmentation</code> 区域,截图如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203750-2052818650.png" alt="" loading="lazy"></p>
<p>哈哈,一下子就发现了 gen2 区域的奇观,即使看不懂的话也会觉得奇奇怪怪的,接下来我就简单分析下这里面的几个指标吧。</p>
<ol>
<li>heap:表示当前有 810 个 segment 内存段</li>
<li>total: 表示当前 gen2 吃了 1.77G 内存。</li>
<li>used(pinned):表示 1.77G 内存中,pinned 对象占了 48.8M 内存。</li>
<li>used(unpinned): 表示 1.77G 内存中未固定对象吃了 46.8k 内存。</li>
<li>free: 表示当前空闲块吃了 1.73G。</li>
</ol>
<p>上面几个指标合起来就是说 gen2 用 1.77G 内存只装近 50M 的对象,这种奇葩现象就是所谓的 <code>堆碎片化</code>。</p>
<p>接下来就是要寻找这些 pinned 对象,他们到底是什么,为什么让 GC 痛苦不堪,可以选择 <code>Generations</code> 选项卡,双击其中任一个segment,截图如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203833-465756347.png" alt="" loading="lazy"></p>
<p>打开面板之后发现都是 <code>Byte[]</code> 数组,通过 <code>Similar retention</code> 选项卡发现都是 <code>Pinning handle</code> ,即通过 <code>GCHandleType.Pinned</code> 固定的,截图如下</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203842-1104513092.png" alt="" loading="lazy"></p>
<p>接下来的问题是这些 <code>Byte[]</code> 数组到底是被谁固定的?为什么不解开呢?</p>
<h3 id="2-byte-是谁创建的">2. byte[] 是谁创建的</h3>
<p>如果把这个问题搞定了,那所有的真相就会大白,那怎么做呢?一般来说有两种做法,第一种就是 full 采集模式,然后观察 byte[] 的调用栈即可,还有一种方式使用 harmony 注入的方式记录调用栈。这里都给大家介绍一下吧。</p>
<ol>
<li>full 采集模式</li>
</ol>
<p>首先要说的是 full 采集模式在真实环境下很难实行,因为它对程序的性能伤害太大了,这个在官方文档中也有所说明,截图如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203725-1555643511.png" alt="" loading="lazy"></p>
<p>最后选择 Start 按钮开始采集,按照前面所述的方式找到 byte[] 数组再选择 <code>Back Traces</code> 选项卡,可以清楚的看到是 <code>Allocate_Pinned()</code> 方法创建的。</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203737-1445721987.png" alt="" loading="lazy"></p>
<p>刚才是通过 type 为依据寻找的调用栈,也可以找到具体的 byte[] 实例观察其 <code>Create Stack Trace</code> 选项,同样也能看到,截图如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203849-1101035142.png" alt="" loading="lazy"></p>
<p>刚才也说了,这种方式虽然可行,但不是第一手段,更合适做万不得已的备份方案,万一程序能受得了这么重的暴击呢?</p>
<ol start="2">
<li>harmony 注入</li>
</ol>
<p>第二种方式就是脱离 dotmemory,采用一种 IL 注入的方式,原理非常简单,就是在 SDK 的 GCHandle.Alloc 内部增加日志,参考代码如下:</p>
<pre><code class="language-C#">
public static GCHandle Alloc(object? value, GCHandleType type)
{
    // prefix: todo...

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

</code></pre>
<p>在 postfix 中我们记录下调用 <code>Alloc</code> 方法的调用栈,这样是不是就真相大白了,完整的参考代码如下:</p>
<pre><code class="language-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 &lt;= 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 &lt; 1024 * 3; j++)
            {
                var bytes = new byte; // 分配 3096 个 1k 的 byte[]
            }
      }

      static void Allocate_Pinned(int i)
      {
            GCHandle.Alloc(new byte, GCHandleType.Pinned); // 50k 的 pinned byte[]
      }
    }

    { 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);
            }
      }
    }

</code></pre>
<p>最后运行程序,观察日志输出即可,截图如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/214741/202511/214741-20251116095203875-186013678.png" alt="" loading="lazy"></p>
<p>从卦中日志看是不是轻松的就找到了 <code>Allocate_Pinned()</code> 方法,在真实场景中还是建议大家写到 Nlog 这样的日志框架中。</p>
<h2 id="三总结">三:总结</h2>
<p>DotMemory 在可视化方面做的还是蛮强大的,感觉特别适合作为 <code>技术支持工程师</code> 的首选工具,希望本篇能给你带来一些帮助。</p>
<img src="https://images.cnblogs.com/cnblogs_com/huangxincheng/345039/o_210929020104最新消息优惠促销公众号关注二维码.jpg" width="700" height="300" alt="图片名称" align="center"><br><br>
来源:https://www.cnblogs.com/huangxincheng/p/19227185
頁: [1]
查看完整版本: DotMemory系列:3. 堆碎片化引发的内存暴涨分析