【新手慎入】秘境探索之一个.NET对象从内存分配到内存回收

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战」。

  • 📢欢迎点赞 :👍 收藏 ⭐留言 📝 如有错误敬请指正,赐人玫瑰,手留余香!
  • 📢本文作者:由webmote 原创,首发于 【掘金】
  • 📢作者格言: 生活在于折腾,当你不折腾生活时,生活就开始折腾你,让我们一起加油!💪💪💪

前方高能预警,新手慎入!不听劝阻者,轻则郁闷堆积,重则生死看淡,对编程失去了念想,对生活失去了幻想!好了,心理强大到NB的可以忽略前方若干警示。为了探索.NET对象的内存分配和回收销毁,您可能需要准备一些调试的基本知识,比如上篇的<利用SOS扩展库进入高阶.NET6程序的调试>.以下例子来自.net 6技术支持。

1. 我们的第一个对象

我们的第一个对象,不是你初中暗恋的古灵精怪的小女孩,更不是你高中的神秘御姐范的初恋女友,她是地地道道的Object。

不信,我Show给你看。

public static int Main()
{
    MaoniType o = new MaoniType(128, 256);
    Console.ReadLine();
    // 其它乱七八糟的代码
    return 0;
}
复制代码

掀开她神秘的盖头,她也只不是千千万万普通对象中的一员,非要说她有什么不同的话,那可能就是你想驯服她,并且你花费了你的宝贵时间,在她身上。

public class MaoniType
{
        public MaoniType(int a, int b)
        {
            A = a;
            B = b;
        }

        public int A { get; set; }
        public int B { get; set; }
}
复制代码

2. 正确的打开她

美丽总是隐藏在朦胧之中,隔纱看美人,越看越迷人。

不过我们需要的不是肤浅的撩骚,让我们利用高级窥探工具,更加深入到灵魂的探索她。

当然,最最简单的探索工具,就是Windbg + SoS 扩展了。

至于工具的使用,不是重点,在这里就略过了,如果你还不会的话,那么就移步<利用SOS扩展库进入高阶.NET6程序的调试>瞧瞧,那里已经给你备好了下酒好菜。

闲话少叙,让我们直接打开工具,键入神秘指令,来个一指入魂吧。

0:007> .load C:\Users\webmote.dotnet\sos\sos.dll
0:007> !dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffc77c37598        1           24 System.IO.SyncTextReader
00007ffc77c33478        1           24 System.Threading.Tasks.Task+<>c
00007ffc77c1ca70        1           24 System.IO.Stream+NullStream
00007ffc77c13798        1           24 ConsoleApp6.MaoniType
...
[omitted]

00007ffc77bd7f48       28         1160 System.SByte[]
00007ffc77bd8410        4         3596 System.Int32[]
00007ffc77c1d3c8        3         4178 System.Byte[]
00007ffc77b2b578        8        18216 System.Object[]
00007ffc77c33898        3        33356 System.Char[]
00007ffc77bdd698       82        35610 System.String
Total 208 objects
复制代码

没错,找到 ConsoleApp6.MaoniType 这个类名,这就是你心心念的 对象 No 1.

3. 深入内存

既然已经被你定位到了,那么就让我们继续深入吧, 现在只需要点她的牌牌就可以了。

0:007> !DumpHeap /d -mt 00007ffc77c13798
         Address               MT     Size
000002470000c0c8 00007ffc77c13798       24     

Statistics:
              MT    Count    TotalSize Class Name
00007ffc77c13798        1           24 ConsoleApp6.MaoniType
Total 1 objects
复制代码

现在,有了她第一手的资讯:

姓名: Maoni/莫妮
尺寸: 24
起点:c0c8 [000002470000c0c8]
个数:1个
表索引:[00007ffc77c13798]
复制代码

4. 继续深入——内存布局调查

让我们来看看GC地址空间的情况:

0:007> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x0000024700001030
generation 1 starts at 0x0000024700001018
generation 2 starts at 0x0000024700001000
ephemeral segment allocation context: none
         segment             begin         allocated         committed    allocated size    committed size
0000024700000000  0000024700001000  00000247000173C8  0000024700022000  0x163c8(91080)  0x21000(135168)
Large object heap starts at 0x0000024710001000
         segment             begin         allocated         committed    allocated size    committed size
0000024710000000  0000024710001000  0000024710001018  0000024710002000  0x18(24)  0x1000(4096)
Pinned object heap starts at 0x0000024718001000
0000024718000000  0000024718001000  0000024718005420  0000024718012000  0x4420(17440)  0x11000(69632)
Total Allocated Size:              Size: 0x1a800 (108544) bytes.
Total Committed Size:              Size: 0x22000 (139264) bytes.
------------------------------
GC Allocated Heap Size:    Size: 0x1a800 (108544) bytes.
GC Committed Heap Size:    Size: 0x22000 (139264) bytes.
复制代码

你应该没忘记我们对象的地址吧?

莫妮的地址是 000002470000c0c8,而新生段的分配信息我们也可以清晰的看到。

当然有关,估计仍然需要一大章节才能说明白吧,这里仅仅简单介绍下。

它是GC从操作系统采集内存的一个单位,实际内存申请和分配以及释放以segment(段)为单位;

例如: workstation GC模式segment大小为16M,server GC模式segment大小为64M。

Gen 0Gen 1 heap总是位于同一个段中,叫做ephemeral segment(新生段),
Gen 2 heap由0个或多个segments组成,LOH由1个或多个segments组成

.NET程序启动时CLR为heap创建2个segment,一个作为ephemeral segment,另一个用于LOH

Full GC后完全空闲的segments将被释放掉,内存返回给操作系统

再次深入前,让我们来点小甜点,放松一下,看看四周的风景。

4.1 我们怎么用DRAM

不管怎么分配,我们都需要涉及到物理内存。

当然,我们并不支持使用物理内存!

我们使用虚拟内存(VM),这块有操作系统的哦VMM(虚拟内存管理器)提供。

操作系统引入了虚拟内存概念,使得我们能够:

  • 每个进程都认为它有自己的内存空间,就好象国家的廉租房制度一样,让每个人都体验到家的温馨。
  • 你可以请求更多的内存,甚至超过了物理内存大小,而管理器只会占用真正使用的物理内存;
  • 重要的是,不需要VM分配为连续的了,实现了即抛即用。

VM的实现也很有意思,由操作系统提供页的支持:

  • 由MMU(内存管理单元)实现
  • 内存被分割为页(一般是4K)
  • 虚拟内存到物理内存由页映射使用页表进行管理
  • 无法映射到物理内存,会导致页失败错误
  • 操作系统控制页映射转换

有很多技术实现更快的转换,比如页表缓存、TLB(Translation Lookaside Buffer)技术等。

image.png

4.2 物理页是怎么组织的?

image.png

  1. 当计算机启动后,Windows操作系统把来自DRAM的物理页整理为一个列表;
  2. 当有进程需要物理页分配时,它转变为WS(Working Set)的一部分
  3. 当一个物理页从WS移除后,它通过软件页故障或硬页故障返回到列表
  4. 硬页故障是非常耗时的,因此我们需要避免它

5.为了避免硬页故障,我们不能增加大于物理内存的堆栈(可以观察物理内存负载信息)

4.3 GC怎么从VM采集内存

  • 保留内存

    由于需要分页的原因,因此我们可以请求稍后可能使用的范围地址,它被称作保留内存(VirtualAlloc 使用 MEM_RESERVE)。当然保留内存不能保存任何数据。

  • 提交内存

    当我们需要在页存储上存储数据时,我们告诉操作系统,这叫提交内存。(VirtualAlloc使用MEM_COMMIT),提交操作成功后,保证你不会得到OOM异常。

保留内存操作是非常快的,当然你仍然需要增加一次用户态<--> 内核态的操作;提交内存也是非常快的.... 当然,知道你真正的保存数据。 而恰恰这个时候,有可能引起分配页故障,导致OOM。

  • 保存数据

一切都oK了,我们呢就可以轻松保存数据了。

5. 再次深入内存布局调查

让我们回到从前,一如第一次初见。

5.1 初见

image.png

假设上图就是我们的段(segment)内存的保留内存(Reserve memory)区域,那么你想到了什么?

是的,首先她是一个空荡荡的巨大空间!

当然,这里面也没有任何东西。

5.2 相识

现在,我们想要在段内存中保存一些东东,该怎么办呢?

image.png

是的,我们得混个脸熟!

好了,首先我们需要保存段的头信息,那让我们先提交个申请(通常是64K)。

有了第一次后,我们对这个操作流程应该熟门熟路了,所以,谁也抵挡不住我们前进的脚步。

image.png

再次提交存储对象的空间请求(通常是64K),当然,GC通常不会仅仅为一个对象申请内存.

5.3 行动

它通常先申请一个分配上下文,当然这个时候并没有对象被构造。

image.png

然后动用物理内存页,保存数据,查看存储信息如下:

0:007> dq 000002470000c0c8-8 l3
00000247`0000c0c0  00000000`00000000 00007ffc`77c13798
00000247`0000c0d0  00000100`00000080
复制代码

其内部大致的流程如下(精简版):

image.png

注意:缓存是非常快的,以下是来自Intel的数据。

  • L1 缓存: 4 cpu周期
  • L2 缓存: 12 cpu周期
  • L3 缓存: 44 cpu周期

DRAM的读取大约 60ns ~ 100ns之间。

5.4 小结下

经过前面不断的深入探索,对象的内存分布已经在你面前完全展开。那么,让我们再总结下。

image.png

GC的分配如下:

image.png

6. 清扫战场

经过上面让人目眩神秘的命令和图片,你学废了吗?

最后,让我们打扫下战场,看看GC这位小宝贝。

image.png

6.1 GC怎么决定收集

如下代码,让我们看看它能有多智能?

public static int Main()
{
    MaoniType o = new MaoniType(128, 256);
    GCHandle h = GCHandle.Alloc(o, GCHandleType.Weak);
    GC.Collect();
    Console.WriteLine("Collect called, h.Target is {0}",
            (h.Target == null) ? "collected" : "not collected");
    return 0;
}

复制代码

发生了什么? 输出是:

Output - Collect called, h.Target is not collected

是的,你没有看错,GC.Collect()收集整个堆栈,这意味着GC不能决定对象的生命周期。
如果一个对象还活着,那么GC会被告知,在这个例子中,JIT(User Roots)告诉GC,对象还活着。因此GC无法回收对象。

6.2 开始收集

好了,让我们来个真正的回收。

[MethodImpl(MethodImplOptions.NoInlining)]
    public static void TestLifeTime()
    {
        MaoniType o = new MaoniType(128, 256);
        h = GCHandle.Alloc(o, GCHandleType.Weak);
    }

    public static int Main()
    {
        TestLifeTime();
        GC.Collect();
        Console.WriteLine("Collect called, h.Target is {0}",
                (h.Target == null) ? "collected" : "not collected");

        return 0;
    }

复制代码

输出结果:

Output: Collect called, h.Target is collected

再次观察GC:

image.png

是的,GC摧毁了对象,内存回收了。

image.png

7. 小结

经过本次的多次深入刨析,你对你的对象是不是更加了解了?

👓都看到这了,还在乎点个赞吗?

👓都点赞了,还在乎一个收藏吗?

👓都收藏了,还在乎一个评论吗?