我们提供安全,免费的手游软件下载!

安卓手机游戏下载_安卓手机软件下载_安卓手机应用免费下载-先锋下载

当前位置: 主页 > 软件教程 > 软件教程

Advanced .Net Debugging 7:托管堆与垃圾收集

来源:网络 更新时间:2024-04-23 11:31:53


一、简介

这是我的《 Advanced .Net Debugging 》这个系列的第七篇文章。这篇文章的内容是原书的第二部分的【调试实战】的第五章,这一章主要讲的是从根本上认识托管堆和垃圾回收。软件系统的内存管理方式有两种,第一种是手动管理内存,这种方式容易产生一些问题产生,比如:悬空指针、重复释放,或者内存泄漏等;第二种是自动内存管理,比如:java 平台、.NET 平台。尽管 GC 能帮助开发人员简化开发工作,让他们更关注系统的业务功能实现。如果我们对 GC 运作原理了解更深入一些,也可以让我们避免在垃圾回收环境中出现的问题。高级调试会涉及很多方面的内容,你对 .NET 基础知识掌握越全面、细节越底层,调试成功的几率越大,当我们遇到各种奇葩问题的时候才不会手足无措。
如果在没有说明的情况下,所有代码的测试环境都是 Net 8.0,如果有变动,我会在项目章节里进行说明。好了,废话不多说,开始我们今天的调试工作。

调试环境我需要进行说明,以防大家不清楚,具体情况我已经罗列出来。
操作系统:Windows Professional 10
调试工具:Windbg Preview(Debugger Client:1.2306.1401.0,Debugger engine:10.0.25877.1004)和 NTSD(10.0.22621.2428 AMD64)
下载地址:可以去Microsoft Store 去下载
开发工具:Microsoft Visual Studio Community 2022 (64 位) - Current版本 17.8.3
Net 版本:.Net 8.0
CoreCLR源码: 源码下载

在此说明:我使用了两种 调试工具 ,第一种:Windbg Preivew,图形界面,使用方便,操作顺手,不用担心干扰;第二种是:NTSD,该调试器是命令行的,命令使用上和 Windbg 没有任何区别,之所以增加了它的调试过程,不过是我的个人爱好,想多了解一些,看看他们有什么区别,为了学习而使用的。如果在工作中,我推荐使用 Windbg Preview,更好用,更方便,也不会出现奇怪问题(我在使用 NTSD 调试断点的时候,不能断住,提示内存不可读,Windbg preview 就没有任何问题)。
如果大家想了解调试过程,二选一即可,当然,我推荐查看【Windbg Preview 调试】。

二、调试源码
废话不多说,本节是调试的源码部分,没有代码,当然就谈不上测试了,调试必须有载体。
2.1、ExampleCore_5_1


2.2、ExampleCore_5_2


2.3、ExampleCore_5_3


2.4、ExampleCore_5_4


2.5、ExampleCore_5_5


2.7、ExampleCore_5_6


2.8、ExampleCore_5_7


2.9、ExampleCore_5_8(C++)


2.10、ExampleCore_5_9


2.11、ExampleCore_5_10


三、基础知识
3.1、Windows 内存架构简介

A、基础知识
我们先上一张图,来了解一下 Windows 的内存架构。

在用户态(User Mode)中运行的进程通常会使用一个或者多个堆管理器。最常见的堆管理器有两个:Windows 堆管理器和 CLR 堆管理器。
Windows 堆管理器 :它负责满足大多数的内存分配/回收的请求,它从 Windows 虚拟内存管理器中分配大块内存空间(称为内存段(Segment)),并且通过维持特定的记录数据(旁氏列表(Look Aside List)和 空闲列表(Free List)),以一种高效的方式将大块内存空间分割为许多更小的内存块来满足进程的分配需求。
CLR 堆管理器 :和 Windows 堆管理器的功能类似,它为托管进程中所有的内存分配/回收请求提供服务。它同样也是从 Windows 虚拟内存管理器中分配大块内存(也称为内存段),使用这些内存段满足托管系统的内存分配/回收的请求。
这两种堆管理器的差别就是维持堆完整性时使用的记录数据的结构是不同的。

CLR 堆管理器有两种运作模式:第一种是 工作站模式 ,第二种是 服务器模式
服务器模式的特点:它有多个堆,堆得数量是和处理器的数量是一致的,并且堆中内存段的大小是比工作站的内存段的大小要大的。
从 GC 角度来看,两种工作模式是有 GC 线程模型的差异,在服务器模式下,有一个专门的线程管理所有的 GC 的操作,而工作站模式,在执行内存分配的线程上执行 GC 操作。

堆按对象大小分类:大对象堆(LOH:Large Object Heap)和 小对象堆(SOH:Small Object Heap)。
大对象堆(LOH) :对象的大小等于或者大于 85000 byte 都会分配在大对象堆,>=85000,它有一个初始内存段,大小是16 MB。
小对象堆(SOH) :对象的大小是小于 85000 byte 都会分配在小对象堆上,<85000,它有一个初始内存段,大小是 16 MB。

CLR 堆管理器运作模式和大小对象堆之间的关系如图:
工作站模式:

服务器模式:

当小对象堆中的内存耗尽的时候,CLR 堆管理器就会触发 GC,如果内存空间仍然不足时,将对堆进行扩展。如果大对象堆中的内存耗尽时,堆管理器将创建一个新的内存段来提供内存。

如果我们想使用调试器在托管堆中找到指定对象的分配的内存,可以使用【!DumpHeap】命令。默认情况下,该命令会列出在托管堆中的所有对象以及它们的地址、方法表和大小。

【!DumpHeap】命令有一些开关选项,对于我们过滤数据很有帮助。如图:


内存分配

内存分配的过程很简单,当然,这只是一个示意图,示意图很简单,我也就不多说了,我相信大家也能很容易看得懂。如图:

CLR 堆管理器和 Window 堆管理器的内存分配是有差别的,CLR 堆管理器内存分配一定是紧跟在内存段中最后一个已分配的对象的后面,以此类推。Windows 堆管理器是不会这样管理对象之间的位置的。在Windows 堆管理器中,当有一个内存分配的请求进来的时候,可以使用内存段中任何一块空闲内存来满足这个请求。
有一点需要注意的是,当达到内存阈值的时候,将触发 GC 来执行垃圾回收的动作。在这种情况下,会首先执行 GC,然后在尝试满足内存分配的请求。
最后还有一点要注意,要判断对象是否是可终结的,如果对象是可终结的,那么将在 GC 中记录这个对象,以便正确的管理对象的生命周期。


B、眼见为实
调试源码:ExampleCore_5_1
调试任务:【!DumpHeap】命令的使用
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.4】,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_1\bin\Debug\net8.0\ExampleCore_5_1.exe】,打开调试器。
我们进入调试器后,继续使用【g】命令,运行调试器,直到调试器输出如图:

此时,我们按【ctrl+c】组合键进入中断模式。
我们可以直接执行【!DumpHeap】命令,输出内容不少。

输出包含两个部分,第一部分输出了在当前托管堆中所有的对象,包括对象的地址、方法表和大小。我们有了对象的地址,就可以使用【!DumpObj】命令查看对象的详情。

【!DumpObj】命令还有一个变体就是【!do】,输出结果是一样的。

【!DumpHeap】命令输出的第二部分包含了有关托管堆行为的统计信息,其中相关的对象被分为一组,给出了这组对象的方法表地址、实例个数、总体大小和对象的类型名称。
【!DumpHeap】命令不跟任何参数,输出内容太多,我们可以使用它的命令开关进行过滤,比如:-type、-mt 还有很多,可以自己去尝试。-type 可以在托管堆上查找指定的类型名,-mt 可以查找指定的方法表。
我们使用【!DumpHeap -type ExampleCore_5_1.Name】命令,查找 Name 类型。

什么也没输出,因为在托管堆上还没有该对象。
此时,我们继续运行【g】调试器,直到调试器输出如图:

我们继续按【ctrl+c】组合键,进入中断模式,再次执行【!DumpHeap -type ExampleCore_5_1.Name】命令,这次输出不同了。

输出的结果和【!DumpHeap】命令的默认输出类似,首相给出特定实例的特定数据(地址、方法表和大小),最后就是统计信息,在托管堆上只有一个这样的实例、大小、类型名和方法表的地址。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的项目文件:ExampleCore_5_1.exe。进入调试器,我们使用【g】命令,继续运行调试器,直到我们的控制台程序输出“Press any key to allocate memory”这样的文字,点击调试器中【break】按钮,中断调试器的执行。
此时,我们就可以执行【!DumpHeap】命令,查看一下托管堆上有什么东西,内容还是不少的。

为了让大家看的更清楚,我没有省略,如果是第一次查看,完全的更好的,能有一个更直观的感受。
【!DumpHeap】命令的输出分为两个部分。第一个部分包含了位于当前托管堆中的所有对象。对于任何一个对象,都可以使用【!DumpObj】命令或者【!do】查看对象的详情。
第二部分包含了托管堆行为的统计信息,其中相关的对象会被分为一组,并给出了这组对象的方法表、对象数量、总体大小和对象的类型名。如图:

表示对象是 System.Collections.Generic.GenericEqualityComparer 类型,方法表位于 7ff80668ad28 ,在托管堆中共有1个实例,总大小为 24 个字节。
在分析一个很大的托管堆以及需要找出哪些对象导致了堆空间的增长时,这些统计信息非常有用。

【!DumpHeap】命令不跟任何参数,输出的内容太多了,如果我们想找一些特定的信息就比较难。该命令提供了许多开关选项可以帮助我们。-type、-mt 这两个开关可以帮助我们在托管堆中查找指定的类型名或者方法表的地址。

我们执行该命令,没有任何输出,因为在当前的托管堆中没有分配该类型的对象。我们【g】继续执行调试器,直到我们的控制台程序输出“Press any key to Exit”时,再次点击【break】按钮,中断调试器的执行,再次执行【!DumpHeap -type ExampleCore_5_1.Name】命令。

有了输出内容了。这个结果和【!DumpHeap】命令的默认输出是一致的。首先给出这实例的特定数据(地址、方法表和大小),然后是统计信息,指出在托管堆中只有一个这种类型的实例。


3.2、垃圾收集器内部工作机制
CLR 的 GC 是一个高效的、可伸缩的以及可靠的自动内存管理器。在设计和实现 GC 之前,是遵循一些假设的。
I、如果没有特殊声明,所有对象都是垃圾。这意味着,除非特别声明,否则 GC 会收集托管堆上所有的对象。从本质来看,它为系统中所有活跃的对象都实现了一种引用跟踪的模式,如果一个对象没有任何引用,就可以认为是垃圾,就可以被回收。
II、假设托管堆上的所有对象的活跃时间都是很短暂的。这种假设基于:如果一个对象活跃了一段时间了,那么它很可能在更长一段时间内也是活跃的,因此不需要再次收集这个对象。
III、通过代的概念跟踪对象的持续时间。活跃时间短的对象归为 0 代,而活跃时间更长的对象则归为第 1 代和第 2 代。对象的活跃时间增长,其相应的代也会递增。
基于以上,我们可以得出一个定义:GC 是一个基于 引用跟踪 的垃圾收集器。

3.2.1、代
A、基础知识
CLR GC 定义了 3 个级别的代,分别是:0 代、1代、2代。一个对象可以从某一代移到下一代,并且每个代的回收频率也是不一样的,0 代回收的最频繁,2代回收的最少。我们最新创建的对象,一般都会保存到 0 代。
我们先上一个图,说一下 GC 垃圾回收算法是怎么回事。



每代都有预定义的空间容量。当新的内存分配请求到来的时候,并且第 0 代已无法再容纳新的对象时,也就是超过了第 0 代预定义的空间容量,就会启动 GC,执行垃圾回收的操作 。GC 就会回收掉没有任何根引用的对象(也就是垃圾对象),并且将所有带有根引用的对象升级到第 1 代。如果将第 0 代保留下来的对象提升到第 1 代时,超过了第 1 代预定空间容量,那么 GC 将在第 1 代回收没有根引用的对象,并将有根引用的对象升级到第 2 代。如果将对象从第 1 代升级到第 2 代,导致第 2 代的预定空间容量不足,此时 CLR 堆管理器就会尝试分配另一个内存段来容纳第 2 代中的对象。如果在创建新的内存段失败了,就会抛出一个 OutOfMemoryException 异常。
如果内存段不再使用,CLR 堆管理器将释放它们。
如果我们想理解对象具体在哪个代,那我们必须理解托管堆的内存段和代之间的关系。每个托管堆都包含了一个或者多个内存段用来容纳对象。而且,在这些内存段中有一部分空间是专门用来存储指定的代。来一张图说明一下托管堆的内存段是怎么回事。

在这张图中,托管堆的内存段被划分为 3 个部分,分别存放不同代的对象,其中每个部分都有自己的起始地址,这个地址由 CLR 堆管理器来管理。第 0 代和第 1 代属于同一个内存段,这个内存段被称为临时内存段(ephemeral segment),它保存短暂活跃的对象。
由于 GC 假设大多数对象都是短暂活跃的,因此GC 认为大多数对象的活跃时间都不会超过第 0 代,最多不超过第 1 代。位于第 2 代的对象是存活时间最长的对象,他们被收集的频率也会最低。当然,第 2 代对象也有可能保存在临时内存段中。通过查看对象的地址和了解存放每代对象的地址范围,我们就会很容易找到指定对象属于哪一代。
如果我们想查看代的信息,可以使用 【!eeheap】命令,这个命令可以输出与 GC 和加载器相关的全部信息,如果我们只是想输出 GC 的信息,可以使用【!eeheap -gc】命令。

注意1:由于我们是 .NET 8.0 的环境,不能使用 SOSEX 扩展里面的命令,如果是 .NET Framework ,我们就可以使用【!dumpgen】命令查看和代有关的对象,特别方便。【!dumpgen 0】表示查看第 0 代的所有对象,以此类推。

注意2:GC.Collect()作用是什么?
GC.Collect()本身所实现的功能比其字面意思表示的功能要多得多。它能强制触发一次垃圾回收的操作,而不管实际上是否需要垃圾收集。这句话的后半部分很重要,“。。。而不管实际上是否需要垃圾收集”。在应用程序执行期间,GC 可以不断的自我调节,以确保在应用程序的环境中表现出最优的行为。然而,通过 GC.Collect() 强制执行垃圾收集,有可能会破坏 GC 的自我微调算法。因此,在通常情况下,我们强烈建议不使用这个 API。

B、眼见为实
调试源码:ExampleCore_5_2
调试任务:证明 GC 通过代管理对象
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.4】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_2\bin\Debug\net8.0\ExampleCore_5_2.exe】,打开【NTSD】调试器。
我们直接【g】运行调试器,直到调试器输出“Press any key to invoke GC”内容,如图:

我们按【ctrl+c】组合键,进入中断模式。由于是我们主动中断的,在执行相关栈操作,必须先切换到托管主线程。

我们使用【!ClrStack -a】命令查看托管线程的调用栈,并显示局部变量和参数。

这两个对象在托管堆上的地址分别是 0x00000238e5409640 0x00000238e5409660 ,这两个地址,我们可以使用【!DumpObj】或者【!do】命令,后跟对象的地址,就可以查看它们的详情。

有了对象的地址,我们再使用【!eeheap -gc】命令,查看 GC 的详情,包含每个代具体的信息,包括每个代的起始地址。

两个局部变量的地址分别是:0x0000 0238e5409640 和 0x0000 0238e5409660 ,有了对象的地址,我们再和每一代的起始地址进行比较,就可以得到答案。只需比较地址高位地址的前6-8位就可以。第 0 代地址前面部分都是 00000238E54 ,我们对象的地址也是以 0000 0238e54 开头的,其他的就可以不用看了,说明 n1 和 n2 都在第 0 代。
我们继续【g】运行调试器,直到调试器输出“Press any key to invoke GC”字样,如图:

继续按组合键【ctrl+c】进入中断模式,此时,也需要切换到托管主线程。

继续执行【!ClrStack -a】查看托管线程调用栈。

我们继续使用【!eeheap -gc】命令查看一下 GC 的详情。

我们已经执行了一次垃圾回收,代的起始地址也发生了变化。n1 地址变成了 0 ,表示被回收了,现在只有 n2 了,它的地址是:0x000001363cc09660,再次和每个代的起始地址比较吧,只是比较高位地址部分就可以了。n2 地址前缀是:000001363cc,第 1 代起始地址的前缀是: 000001363CC ,很明显,是一致的,其他的就可以不用看了,当然,看看理解更好点。现在 n2 在第 1 代了。
我们继续【g】,直到调试器输出“Press any key to Exit”字样,效果如图:

我们继续按组合键【ctrl+c】进入到调试器的中断模式,再次切换到托管线程上下文中。

使用【!clrstack -a】命令,查看一下托管线程的调用栈。

n2 对象在托管堆上的地址是: 0x000001363cc09660 ,我们使用【!eeheap -gc】查看 GC 的详情。

n2 对象的地址:0x000001363cc09660,我们在第 2 代里,发现有2个内存段,第 2 个内存段的起始地址是: 000001363CC00028 ,n2 对象的地址正好在第 2 代的第 2 个内存段的地址里。

2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】,依次点击【文件】---【Launch executable】,加载我们的项目文件:ExampleCore_5_2.exe,进入调试器。
进入到调试器,我们直接【g】运行调试器,直到我们的控制台程序输出“Press any key to invoke GC”字样。点击调试器的【break】按钮,中断调试器的执行。由于我们是手动中断,需要切换到托管线程的调用栈上,执行命令【~0s】。

继续使用【!clrstack -a】命令,查看托管线程栈的调用栈,打印出所有参数和局部变量。

红色标注的两项就是我们的局部变量,分别是:n1 和 n2。接下来,我们就看看这两个变量属于哪一代。
继续执行【!eeheap -gc】命令。

两个局部变量的地址分别是:0x0000 01eaf1009640 和 0x0000 01eaf1009660 ,第 0 代起始地址是 01eaf1000028,两个局部变量前8位都是一致的(看红色部分),用这个地址和每个代的开始地址比较很容易知道属于第 0 代。
我们继续【g】运行调试器,直到我们的控制台程序输出第二个“Press any key to invoke GC”字样,点击调试器【break】按钮,中断调试器的执行,又因为是我们手动中断,需要切换到托管线程上,执行命令【~0s】。

我们再次执行【!clrstack -a】命令,查看托管线程栈。

这里的内容大部分都是相同的,红色标注的注意看,一个值变成了 0 ,说明被(n1 = null;GC.Collect())回收了。 0x000001eaf1009660 这个值就是 n2,由于执行了垃圾会后,n2 没有被回收,肯定从第 0 代升级到第 1 代了,我们执行【!eeheap -gc】命令来验证。

对象的地址是 0x000001eaf1009660 ,开始比较,第 0 代的开始地址:01eaf0800028,肯定不是,第 1 代的开始地址是:01eaf1000028,这个地址是符合的,也就是说 0x000001eaf1009660 这地址在 01eaf1000028 这地址里面的,证明了我们的说法。
我们继续【g】运行调试器,直到我们的控制台程序输出“Press any key to Exit”字样,点击调试器的【break】按钮,中断调试器的执行,我们还必须切换到托管线程上,执行命令【~0s】。

再次执行【!clrstack -a】命令,查看托管线程栈。

有了对象的地址,我们继续执行【!eeheap -gc】命令查看 GC 的详情。

对象的地址是:0x0000 01eaf1009660 ,第 0 代的开始地址是:01eaf3400028,比较前6-8位即可,不符合,第 1 代开始地址是:01eaf0800028,也不符合,第 2 代的开始地址:01eaf1000028,我们看到了,地址前8位是一样的,所以也就证明了我们的说法,n2 已经提升到第 2 代了。


3.2.2、根对象
A、基础知识
C# 的引用跟踪回收算法,核心在于寻找【根对象】,凡是托管堆上的某个对象被【根对象】所引用,GC就不会回收这个对象的。
GC 本身并不会监测哪些对象仍然被引用,而是使用 CLR 中其他了解对象生命周期的组件。
通常3个地方有根对象。
I、线程栈
方法作用域下的引用类型,自然就是根对象。
II、终结器队列(Finalizer queues)
带有析构函数的对象自然会被加入到【终结器队列】中,终结线程会在对象成为垃圾对象后的某个时刻执行对象的析构函数。
III、句柄表(handle table)
CLR 为每个应用程序域提供一组句柄表,在这些句柄表中包含了指向托管堆上固定引用类型的指针。换句话说,凡是被 Strong、Pinned 标记的对象都会被放入到【句柄表】中,比如:static 对象。句柄表就是在 CLR 私有堆中具有一个字典类型的数据结构,用于存储被 Strong、Pinned 标记的对象。
句柄类型是一种值类型,如果想转储出句柄的内容,只能使用【!DumpVC】,【!DumpObj】命令是针对引用类型的。
IIII、 即时编译器 JIT
它负责将 IL 代码转换为机器码,因此它知道在任意时刻有哪些局部变量仍然被认为是活跃的。JIT 编译器将这些信息维护在一张表中,当 GC 要查询活跃对象的时候,会用到这张表。

B、眼见为实
调试源码:ExampleCore_5_3
调试任务:使用【!gcroot】命令查找对象的根引用。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_3\bin\Debug\net8.0\ExampleCore_5_3.exe】,打开【NTSD】调试器。
【g】直接运行调试器,直到调试器输出“Press any key to Exit”字样,效果如图:

按组合键【ctrl+c】进入中断模式,然后切换到托管线程上下文。

继续使用【!clrstack -a】命令查看一下托管线程调用栈的情况。

我们可以使用【!do 0x0000014c5f809660 】命令或者【!DumpObj 0x0000014c5f809660 】命令验证一下是否正确。

我们有了对象的地址,就可以使用【!gcroot 0x0000014c5f809660 】命令查看一下它的根引用。

我们看到了 ExampleCore_5_3.Name 类型在 2个线程和一个句柄表中有引用。我们可以使用【!t】命令或者【!threads】命令查看托管所有线程。

OSID 是 3a00 的线程是主线程,也就是我们当前操作的线程。我们可以去 OSID 是 3170 的线程去看看,首先,切换线程,执行命令【~~[3170]s】。
继续执行【!clrstack -a】命令,查看当前的托管线程调用栈。

我们看到在 ExampleCore_5_3.Program.Worker 方法里有引用了 ExampleCore_5_3.Name 类型。这就找到了 ExampleCore_5_3.Name 类型的所有根引用。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的项目文件:ExampleCore_5_3.exe,进入到调试器。
我们进入调试器后,直接【g】运行调试器,直到我们的控制台程序输出“Press any key to Exit”字样。效果如图:

我们在调试器上点击【break】按钮,进入中断模式。
由于手动中断,我们必须切换到托管线程上下文,因为目前是在调试器的线程上下文中。

我们必须先找到我们目标对象的地址,所以先使用【!clrstack -a】查看一下托管线程调用栈。

0x000001e689c09660 地址有了,我们还是要确定一下是不是 Name 类型的,可以使用【!DumpObj 0x000001e689c09660 】命令。

证明我们的说法,既然我们得到了对象的地址,我们就可以针对该地址使用【!gcroot 000001e689c09660 】命令,查看一下它的根引用。

我们可以使用【!t】或者【!threads】命令,列出所有的托管线程来查看上面的出现的两个线程。

在这里说明一下,这里【!gcroot】命令的输出内容和原著书上输出的内容是由很大区别的,平台不一样了。

我们在使用【!gcroot】命令输出的结果中,有两个线程的输出,我们可以切换到线程上查看详情。
首先我们先切换到 OSID 为 574 的线程上,执行命令【 ~~[574]s 】,查看一下具体情况。

其实OSID 是 574 的线程就是主线程,【!clrstack -a】命令和【!gcroot】命令输出结果和前面是一样的。我们在切换到 OSID 是 36e8 的线程上看看具体情况。

继续使用【!clrstack -a】命令查看托管线程调用栈的情况。

我们看到了在 ExampleCore_5_3.Program.Worker 方法的调用栈中有一个局部变量的地址是有点眼熟的, 0x0000017750409660 ,这个地址就是我们 Name 类型在托管堆上的地址。说明,在编号为 0x36e8 这个线程里也引用了我们的 Name 类型。

如果大家不信,可以使用【!do 0x0000017750409660 】命令或者【!DumpObj 0x0000017750409660 】命令证明一下。


3.2.3、终结操作
A、基础知识
当我们声明的类型使用一些非托管资源的时候,例如:文件句柄、数据库连接、互斥体等,就需要做特别的处理,GC 才能安全、可靠的回收该对象所占的资源。如果处理不当,虽然托管对象所占用的内存被回收了,但是对象所使用的非托管资源却不会被回收,因为 GC 并不知道这些非托管资源的存在。

为了提供合适的回收策略,CLR 引入了终结器的概念,当对象被回收时,它的终结器就会被执行。当这个类被编译为 IL 代码时,终结方法会被编译一个名为 Finalize 的函数。由于,垃圾收集器实际上是一个自动内存管理器,因此,在垃圾收集过程中,它需要执行终结代码。

当一个类型包含了终结器时,GC 的处理也会有所不同。为了记录哪些对象拥有终结器,GC 维护了一个终结队列(Finalization Queue)。如果在托管堆上创建的对象中包含终结器,那么在创建过程中将被自动放入终结队列中。 需要注意,终结队列并没有包含那些被认为是垃圾的对象,而是包含了所有带有终结器并在托管堆上处于活跃状态的对象

如果某个带有终结器的对象不存在任何根引用了,并且启动了垃圾回收的过程,那么 GC 会把这个对象放入到另外一个队列中,即 F-Reachable 队列(终结可达队列)。这个队列包含了所有带有终结器并且被作为垃圾的对象,这些对象的终结器都将被执行。在 F-Reachable 队列上的所有对象都被视为仍然存在根引用。 需要注意的是:在垃圾收集过程中,并不会执行 F-Reachable 队列中每个对象的终结器的代码,这些代码将在一个特殊的线程中执行,它就是每个 .NET 进程的终结线程(Finalization Thread) 。在收到 GC 的请求时,终结线程会启动并且查看 F-Reachable 队列的状态。如果在 F-Reachable 队列上有任何的对象存在,那么它会依次执行这些对象的终结方法。

在垃圾收集过程结束后,带有终结器的对象会出现在 F-Reachable 队列中(根对象引用存在且是活跃的),直到终结线程执行他们的 Finalize 方法。此时,对象将从 F-Reachable 队列中移走,并且这些对象也被认为不存在根对象引用了,从而真正的被垃圾收集器回收了。

咱们来一张图举例说明一下,就很容易理解了。

在上图的 步骤1 中分配对象D 和对象E,它们各自带有一个 Finalize 方法。在分配过程中,这些对象除了被放在托管堆上,还被放在终结队列中,表示这些对象不被使用时需要执行终结操作。在 步骤2 中,当垃圾收集过程启动时,对象D 和对象E 都不存在根对象引用。此时,这两个对象将从终结队列中移动到 F-Reachable 队列中,表示可以执行它们的 Finalize 方法了。在接下来的某个时刻, 步骤3 会被执行,终结线程也会启动,并开始执行这两个对象的 Finalize 方法。即使在终结器执行完成后,这两个对象仍然存在于 F-Reachable 队列中。最后在 步骤4 中再次启动了垃圾回收过程,这些对象会被移出 F-Reachable 队列(不再有根对象引用),然后又垃圾收集器从托管堆上回收。

需要注意的是,虽然有一个专门的线程执行 Finalize 方法,但是 CLR 并不能保证这些线程将在何时启动执行。 由于在对象中包含了一些资源和在等待资源被回收时需要的时间过长,微软又提出了一种明确的清楚模式,例如:IDisposable 模式或者 Close 模式。当使用终结类型时,背后要做大量的事情,CLR 不仅需要额外的数据结构(终结队列和 F-Reachable 队列),还需要一个专门的线程执行对象的 Finalize 方法。具有终结器的类型,无法仅通过一次垃圾回收操作就被回收,而是需要两次。这些对象会被提升到第 1 代,从而使它成为一种开销较高的对象。

B、眼见为实
调试源码:ExampleCore_5_4
调试任务:通过调试器观察带有终结器的对象是如何被回收的。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_4\bin\Debug\net8.0\ExampleCore_5_4.exe】打开调试器。
进入调试器后,直接【g】运行程序,直到调试器输出“Press any key to GC1!”字样。

此时,我们按【ctrl+c】组合键,中断调试器的执行,开始我们的调试。
直接执行【!FinalizeQueue】命令,查看终结队列的详情。

此时,我们可以看到第 0 代、第 1 代和第 2 代都没有任何可终结的对象。因为我们还没有执行第一次的【垃圾回收】,但是此时【Ready for finalization 13 objects 】说明已经有13个对象可以执行【终结】方法的操作了,这一点和原著是有区别的。

我们可以使用【dp 0000013587E3E0E0】命令,查看改地址的保存的数据,其实就是13个要执行 Finalize 方法的对象。

00000135 `8c409658 这地址应该就是我们定义的 ExampleCore_5_4_1.NativeEvent 类型。栈地址是有由高到低的分配,我们最早声明 ExampleCore_5_4_1.NativeEvent 类型,它的地址肯定是最高的。执行【!do 00000135 `8c409658 】命令验证一下。

接下来,我们【g】继续恢复调试器的执行,完成第一次【垃圾回收】。效果如图:

此时,我们在【ctrl+c】组合键进入中断模式,继续执行【!FinalizeQueue】查看终结队列的情况。

此时,已经经历了第一次的【垃圾回收】,在第 0 代有 13 个可以终结的对象。
我们【g】继续恢复调试器的执行,直到调试器输出“Press any key to Exit!”字样。效果如图:

此时,已经已经完成第二次的【垃圾回收】了,按【ctrl+c】组合键进入中断模式,我们继续执行【!FinalizeQueue】命令。

我们看到了结果,对象的生命周期提升了,从第 0 代提升到第 1 代了,也就说明了,局部终结器方法的对象一次是没办法得到回收的,生命周期变长了。
我们可以看看所有线程的调用栈,执行命令【~*kn】。

红色标注的就是终结器线程,负责执行带有 Finalize 方法。

2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】加载我们的项目文件:ExampleCore_5_4.exe。进入调试器后,我们使用【g】命令,继续运行调试器,直到我们的控制台应用程序输出“Press any key to GC1!”字样,如图:

此时,我们点击调试器的【break】按钮,中断调试器的执行。
首先我们使用【!FinalizeQueue】命令查看一下进程中可终结对象的状态。

在输出的结果中,首先:给出了每一代的终结队列,并给出了每个终结队列本身的地址范围,比如:第 0 代终结队列的起始地址是 23681038090 ,结束地址是 23681038090。由于没有任何元素,开始和结束地址是一样的。我们可以使用【dp 23681038090 】查看它的内容,其实就是【 Statistics 】统计的内容,有多少个对象实例,就存储了多少项。

我们在【 Statistics 】项的【 Count 】列的值总和是 13,我红色标注的也是 13 项目,最后3项不算。针对每个地址,我们可以使用【!do】命令或者【!DumpObj】命令来验证。我只输出标红的最后一个(栈的是从高地址到地地址分配,定义越早,地址越大),它是栈顶的地址,这项就是我们定义的 ExampleCore_5_4.NativeEvent

从【!FinalizeQueue】命令输出,我们可以看到第 0 代、第 1 代和第 2 代没有任何可终结的对象。

【!FinalizeQueue】命令输出中另一个有用的信息就是 F-Reachable 队列。如下所示:

这个信息表示此时没有任何对象需要执行终结操作。这是正确的,因为垃圾收集器还没有启动。

【!FinalizeQueue】命令输出中最后一部分是一些统计信息,其中包括在终结队列中或者 F-Reachable 队列中所有的对象。

如果我们想知道进程中包含的所有线程的栈回溯,包括终结线程,我们可以使用【~*kn】命令。

红色标注的就是终结线程调用栈,这个线程正在等待终结器事件( coreclr!FinalizerThread::WaitForFinalizerEvent )。 coreclr!FinalizerThread::FinalizerThreadWorker 这个方法的返回地址是 00007ffa`a91e4abd ,我们在这个方法上设置一个断点,当终结线程执行工作时就会触发这个断点。我们使用【bp 00007ffa`a9198d73 】命令,设置断点。

使用【g】命令恢复程序的执行,在控制台程序中按任意键触发 第一次垃圾回收 。直到输出“Press any key to GC2!”字样。

触发了断点。我们再次执行【!FinalizeQueue】命令。

第 0 代有 13 个可终结的对象,第 1 代和第 2 代没有任何可终结的对象。
当我们继续【g】恢复程序的执行,在控制台程序中按任意键触发 第二次垃圾回收 。一直到控制台输出“Press any key to Exit!”字样。

回到调试器,点击【break】按钮继续进入到中断模式,继续执行【!FinalizeQueue】命令。

我们看到经过第 2 次垃圾回收,那些对象升级到第 1 代了。此时,我们可以使用【!dumpheap -type NativeEvent】找到 NativeEvent 类型的内存地址,然后再使用【!gcroot】命令,还可以找到 NativeEvent类型是有根引用的。

0179a9409658 这个地址就是 ExampleCore_5_4.NativeEvent 类型,我看看还有没有根引用。

说明不能回收。

我们继续【g】运行调试器。在控制台程序按任意键。我们再次运行【 !gcroot 0179a9409658 】。

没有任何引用了,可以执行垃圾回收操作了,终结线程就会在合适时机执行。


3.2.4、回收 GC 内存
如果在第 0 代和第 1 代上执行的垃圾回收操作会引起托管堆上出现内存空间缝隙,那么 GC 将执行对所有的活跃对象的紧缩操作,这就可以使对象地址相邻,并把托管堆上所有的空闲空间合并成一个更大的内存空间,这个新的内存空间会紧跟在最后一个活跃对象之后。
以下是一个示意图:

在上图中,在托管堆上的初始状态中包含了 5 个存在根引用的对象(依次A 到 E)。在执行过程的某个时刻,对象 B 和对象 D 没有任何根引用了,将在下一次执行垃圾回收的时候被清理掉。当垃圾操作执行时,会回收对象 B 和对象 D 的空间,这将导致托管堆上出现空间碎片。为了消除空间碎片,GC 会把剩下的所有活跃的对象(对象A、C 和 E)进行紧缩,并将多个空闲的内存块(保存对象B和 D 的内存)合并成一个大的空闲块。最后,根据对象的紧缩和合并的结果来更新当前内存分配指针。
注意:
由于达到了所有 3 个代的阈值而使的所有代中的对象都被收集就是【完全垃圾收集】,尽在第 0 代或者第 0 代和第 1 代进行垃圾收集就是【部分垃圾收集】。由于执行紧缩操作的开销和对象的大小成正比(对象越大,紧缩操作的开销越高),所以在托管堆上,有大对象堆和小对象堆之分。


3.2.5、大对象堆
A、基础知识
大对象堆(LOH)包含的对象通常大于或者等于 85 000 个字节。将这种大小的对象单独放入一个堆是有原因的:在垃圾收集的紧缩阶段,在对某个对象执行紧缩操作时的开销是与对象的大小成正比的。因此,没有将大对象放在标准的堆上,而是创建了 LOH。LOH 最好被视为第 2 代内存空间的扩展,并对 LOH 中的对象的收集操作只有在收集完第 2 代中的对象之后才会进行,这也就意味着对 LOH 中对象的收集操作只会在【完全垃圾收集】中进行。
因为大对象进行压缩操作的开销十分大,所以 GC 会避免在 LOH 上进行紧缩操作,取而代之的就是执行【标记清除】。在这个操作中会维持一个空闲链表,用于跟踪 LOH 内存段中可用内存。如图:

虽然 LOH 没有执行任何紧缩操作,但是它会合并相邻的空闲内存块,并把它添加到空闲链表中。

总结:LOH堆也就是大对象堆,既没有代的机制,也没有压缩的机制,只有“标记清除”,即:GC 触发时,只会将一个对象标记成 Free 对象。这种 Free 可供后续分配的对象,可以说,以后有新对象产生,会首先存放在 Free 块中。

当我们通过调试器扩展命令查看 LOH 的时候,发现这个对象会有很多大小小于 85000 的对象,这些对象是有 CLR 堆管理器放在 LOH 上的,有着特殊的作用。通常来说,你可以看到一些由 GC 专门使用的并且小于 85000 字节的对象。

B、眼见为实
调试源码:ExampleCore_5_5
调试任务:查看大对象如何在大对象堆上分配和回收
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_5\bin\Debug\net8.0\ExampleCore_5_5.exe】打开调试器。
进入调试器后,我们直接使用【g】命令运行调试器,直到调试器输出“1、对象已经分配,请查看托管堆!”,并且调试进入中断模式,效果如图:

此时,说明内存已经分配了,我们首先使用【!eeheap -gc】命令查看 GC 的详情,主要关注 LOH(大对象堆)。

红色标注的就是 LOH,从此我们就能得到 LOH 的开始地址和结束地址,分别是: 00000142C1000028-00000142C10D0CA8 ,有了这个地址范围,我们就可以查看 LOH 有什么对象,使用命令【!DumpHeap 00000142C1000028 00000142C10D0CA8 】查看这个地址范围内的所有对象。

红色标注的就是我们在 Test() 方法内部初始化的 3 个变量,它们的大小都是大于 85000 字节的,所以都会分配在 LOH 上。
我们【g】继续运行调试器,直到调试器输出“ 2、GC 已经触发,请查看托管堆中的 byte2 ”,执行垃圾回收,byte2 变量没有根引用,所以会被回收,其它两个变量保存。

此时,我们再次运行【!DumpHeap 00000142C1000028 00000142C10D0CA8】命令查看 LOH 的情况。

上面写的很清楚了,byte2 变量已经被回收了,它剩下的空间标记为“ Free ”,如果此时,我们声明一个对象的大小小于 285088 这数值,这块空间就会直接被使用。
【g】命令继续运行调试器,直到调试器输出“ 3、已分配 byte4,查看是否 Free 块中 ”字样,我们可以再次查看 LOH 的情况。

再次执行【!DumpHeap 00000142C1000028 00000142C10D0CA8】命令,看看 LOH 的情况。

上面的内容很好的证明了我们的说法,红色标注的已经说明了问题,分配的 byte4 对象大小正好在 Free 块中,所以就把 byte4 直接存储了。

2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的项目文件:ExampleCore_5_5.exe,进入我们的调试器。
进入调试器后,我们直接使用【g】命令运行调试器,调试器会自己中断,并且,我们得控制台程序会输出“1、对象已经分配,请查看托管堆!”字样。
我们使用【!eeheap -gc】查找 LOH 的地址,有了地址才可以才看堆上的对象。

红色标注的就是 LOH 区域,由于我们的对象最小都是 85000,所以其他的堆都不用关心,直接查看 LOH。
我们看到 LOH 的起始地址是 028f4d800028 ,我们就可以使用【!DumpHeap 028f4d800028 】命令,查看LOH 有什么对象了。

这个命令输出的内容很多,当然,我们可以使用【!DumpHeap 028f4d800028 028f4d8d0ca8 】命令,查看我们关注的东西。

因为,我们定义的对象大小最小就是 185000,所以,我们可以使用【!DumpHeap -min 185000】命令查看比 185000 大的对象。

我们【g】运行调试器。控制台应用程序输出“2、GC 已经触发,请查看托管堆中的 byte2”字样,调试器自动中断执行。
我们再次运行【! DumpHeap 028f4d800028 028f4d8d0ca8 】命令,查看 LOH 的变化。

我们再次运行【g】调试器,控制台程序输出“3、已分配 byte4,查看是否 Free 块中”字样,调试器会自己中断执行。
我们再次运行【! DumpHeap 028f4d800028 028f4d8d0ca8】命令,查看 LOH 的变化。

红色标注的已经说明了问题,分配的 byte4 对象大小正好在 Free 块中,所以就把 byte4 直接存储了。


3.2.6、固定
A、基础知识
垃圾收集器采用了一种紧缩技术来减少 GC 堆上的碎片。当 GC 要执行垃圾收集的操作时,空间碎片会进行合并形成一块更大的可用空间,在托管堆上的活跃的对象也要发生移动,对象的地址会发生变化,针对该对象的所有引用都会被更新,所有活跃的对象相邻存放,合并后的空间会紧跟在最后一个活跃对象的后边。如果针对移动过的对象的所有引用都包含在 CLR 中,那是没有问题的。

如果 .NET 程序需要通过互用性服务(例如平台调用或者COM 互用性)在 CLR 范围之外工作,对象的移动就会产生问题。如果某个托管对象的引用被传递给一个底层的非托管 API ,那么当非托管的 API 正在读取或者写入的内存同时移动了对象,就会导致严重的问题。

我们举例说明一下,先上一张图,然后具体说明。

在上图中,在托管堆上起初包含了 5 个对象( A、B、C、D、E),对象 A 的地址 0x02000000,对象 C 的地址是 0x02000090。在某个特定时刻,通过平台调用来调用了一个异步的非托管的 API ,同时将对象 C 的地址(0x02000090)传递给 API。在调用这个非托管的异步 API 的时候发生了一次垃圾回收的操作,使得对象 A 和对象 B 被回收。此时,托管堆上出现了空闲对象造成的缝隙,因而 垃圾收集器会通过紧缩托管堆来解决这个问题,因此,对象 C 移动到了地址 0x02000000 处,此地址以前是对象 A 的。此外,还合并了这两块空闲内存,并将它们放在堆的末尾。在完成了垃圾收集后,之前进行的异步 API 调用决定写入到最初传递给它的地址(0x02000090),因为当初保存的是对象 C。此时,再看,已经是物是人非了,被写入的内存不再由对象 C 占用,就会导致系统产生问题,排查问题也挺麻烦的。

如何解决在 GC 执行紧缩时仍能安全的调用非托管代码,有一种解决方案就是固定。是指将托管堆上的某个对象固定住,垃圾收集器回收内存时就不会移动该对象,直到解除对这个对象的固定为止。

虽然通过固定对象的地址解决了在非托管代码调用期间的对象移动问题,但是也带来了另一个问题,内存碎片化,因为内存不能紧缩并合并导致而成。如果太托管堆上存在大量的固定对象,那么就会出现没有足够大而连续的空闲空间的情况,导致内存分配失败。 在举例说明,如图:

如上图所示,有数个空闲的小内存块与活跃的对象交错存放,如果执行一次垃圾收集,那么托管堆的内存布局将保持不变。由于活跃的对象都被固定住了,无法移动,垃圾收集器也就无法执行紧缩操作,又由于这些内存块不是相邻的,所以也不能合并。很容易导致内存分配失败。

LOH 中的碎片情况怎么样?
我们知道 LOH 采用“标记清除”,而不是紧缩的垃圾收集方式,这就意味着 LOH 上的对象永远不会移动,那我们是否就可以不用管 LOH 对象,放心使用呢?不是的,如果没有固定住 LOH 上的对象,也是一种很危险的假设。因为在在不同的 CLR 版本中,这种假设是会变化的,为了防止发生不可预测的问题,该固定的还是固定使用吧。

我们可以使用【!GCHandles】显示进程中的所有句柄。
B、眼见为实
调试源码:ExampleCore_5_6
调试任务:如何固定对象和释放对象
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_6\bin\Debug\net8.0\ExampleCore_5_6.exe】打开调试器。
进入调试器,【g】直接运行程序,直到调试器输出如图:

此时,内存已经分配,还没有执行垃圾回收,我们先看看当前进程中的所有句柄是否包含我们的数组对象。
按组合键【ctrl+c】组合键,进入调试器的中断模式,直接执行【!GCHandles】命令,查看所有的句柄信息。

我们能看到 10 个 Strong 类型的句柄,4 个 Pinned 类型的句柄(包含我们定义的 3 个)和 6 个 Weak Short 类型的句柄。
我们切换到托管线程上下文中去看看,切换线程【~0s】。

我们使用【!clrstack -a】命令查看一下托管调用栈。

红色标注的就是字节数组的变量(b1,b2,b3),蓝色的就是句柄变量(h1,h2,h3),都分配了空间。
我们可以使用【!do】或者【!DumpObj】命令查看一下数组变量。

【g】继续运行调试器,直到调试器输出“ Press any key to Free! ”,说明完成了第一次垃圾回收。如图:

按组合键【ctrl+c】进入中断模式,继续执行【!GCHandles】查看句柄详情。

没有任何变化,垃圾也没回收句柄和数组的内存。
我们继续执行【g】,直到调试器输出“ Press any key to GC2! ”,此时已经完成释放句柄,按组合键【ctrl+c】进入中断模式,我们可以使用【!GCHandles】命令确认一下。

我们可以切换到托管线程上下文中查看 Run 方法的栈帧来确定。

使用【!clrstack -a】查看 Run 方法的栈帧。

红色标注的说明句柄变量已经释放,数组变量还没有回收。
【g】继续运行,直到调试器输出“ Press any key to Exit! ”,此时,第二次垃圾回收已经完成,剩下的数组变量也应该被回收了。按组合键【ctrl+c】进入中断模式,我们直接切换线程,执行【!clrstack -a】命令就可以了。

执行【!clrstack -a】命令。

从输出中就可以看到,我们并没有看到 Run 方法的栈帧,只有 ExampleCore_5_6.Program.Main 栈帧了,说明已经回收了,验证了我们的说法。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的项目文件:ExampleCore_5_6.exe,进入到调试器。
进入到调试器,我们可以直接输入【g】命令,继续运行调试器,我们的控制台会输出“ Press any key to Alloc And Pinned! ”,如果此时,我们查找变量是一无所获的,因为还没有分配内存。我们继续在控制台程序中按任意键继续运行程序,直到程序输出“ Press any key to GC1! ”,效果如图:

此时,内存已经分配,我们回到调试器,点击【break】按钮,中断调试器的执行,开始我们的调试。
我们直接输入【 !GCHandles 】命令,查看当前进程中所有的句柄。

我们看到有 10 个 Strong 类型的句柄,4 个 Pinned 类型的句柄,6 个 Weak Short 类型的句柄。红色标注的就是我们声明的 b1,b2,b3 3个变量。
我们切换到托管线上的上下文中,去线程调用栈中看看能不能找到 b1、b2 和 b3 这三个变量。

继续使用【!ClrStack -a】命令显示线程调用栈的所有参数和局部变量。

我们主要关注 Run 方法的调用栈,标红的就是我们声明的 3 个局部变量,我们可以使用【!do】命令依次查看。

我们继续【g】运行调试器,控制台程序输出“ Press any key to Free! ”,回收内存,但是还没有取消固定,回到调试器,点击【break】按钮,中断调试器的执行。
我们先执行【 !GCHandles 】命令,看看句柄还在吗?

句柄还在,这是正确的,我们还没有释放句柄。我们再次切换到托管线程上下文,看看有没有释放内存(已经执行垃圾回收了)。

我们从输出结果中可以看到,对象依然存在,因为该对象被固定住了。
我们继续执行【g】,控制台程序输出“ Press any key to GC2! ”,说明已经释放句柄,将要执行第二次垃圾回收。回到调试器,点击【break】按钮,中断调试器的执行,开始调试。
继续执行【!GCHandles】命令,查看句柄的情况。

真没有了,Pinned 类型的句柄以前是 4 个,现在是 1 个,少了 3 个。切换到托管线程上下文,执行命令【!clrstack -a】看看调用栈,也发生了变化。

红色标注的就是发生变化的。3 个数组变量依然存在,3 个句柄变量已经释放了。
我们继续【g】执行调试器,控制台程序输出“ Press any key to Exit! ”,此时,已经成功执行第二垃圾回收,我们回到调试器,点击【break】按钮,进入到中断模式。此时,不用执行【!GCHandles】命令,句柄已经释放了。我们直接去托管线程上下文,查看调用栈,确认 3 个数组变量已经被回收了。

此时,我们看到,Run 方法的栈帧都没有了,说明内存被回收了。


3.2.7、垃圾收集模式
垃圾收集模式公有 3 种:非并发工作站、并发工作站、服务器。

服务器模式 :为每个处理器创建一个堆和一个 GC 线程,垃圾收集操作都是通过处理器上专门的 GC 线程来执行的。

非并发工作站模式 :在整个垃圾收集过程中会挂起所有的托管线程,只有在垃圾收集操作完成后,才会恢复进程中所有的托管线程的执行。在不需要快速相应的情况下,这种方式还好。在需要响应速度的情况下,比如:GUI程序,也就是 WinForm、WPF等就不合适了,半天没反应,客户是不答应的。

并发工作站模式 :在整个垃圾收集的过程中并不会一直挂起托管线程,而是会定期醒来,并且在执行一些必要的工作后再重新休眠。这增加了程序的响应速度,但是会使垃圾收集操作略微变慢。

3.3、调试托管堆的破坏问题
A、基础知识
堆破坏 是一种违背了堆完整性的错误,它会导致程序出现奇怪的行为。想要查找问题也是十分困难,因为在托管堆被破坏后表现出的行为各不相同。我们期望的是,在出现堆破坏的问题时,程序就立刻出现崩溃,这样就尽可能的保证发生崩溃的位置与发生破坏的位置相近,就不需要通过大量的栈回溯找出最初发生破坏的位置。
虽然造成堆破坏的原因有多种,但是一个非常常见的原因就是:没有正确的管理程序中的内存。 例如:释放内存后再次使用内存,悬空指针,缓冲区溢出等,都可能导致堆破坏。然而,CLR 通过高效的管理内存解决了很多问题。例如:一块内存释放后,将无法再次使用。缓冲区溢出也会被识别出来作为一个异常。想要这些起作用,必须保证代码运行在托管环境中才可以。

当我们通过互用性服务调用非托管 API 时,传递给非托管 API 的数据是不受 CLR 保护的。所以说,托管代码与非托管代码的交互是在托管环境中导致堆破坏的最主要原因之一。当然,在没有使用任何非托管代码的情况下也可能出现堆破坏的情况,但是,这样的情况极少,通常说 CLR 本身存在一个错误。

当托管堆被破坏了,我们可以使用【!VerifyHeap】命令来验证堆的完整性。该命令会遍历整个托管堆,验证每个对象,并且报告验证过程的结果。

如果还是在使用 .NET Framework 平台,那么使用 gcUnmanagedToManaged(包含 gcManagedToUnmanaged) 这个 MDA 就会很方便,但是,如果在跨平台的 NET 版本中就不可以了,切记。

B、眼见为实
调试源码:ExampleCore_5_7 和 ExampleCore_5_8(C++)
调试任务:通过 MDA 排查有关堆破坏的问题。
C++ 的项目需要说明一下,项目的属性【配置类型】是"动态库(.dll)",输出目录:..\ExampleCore_5_7\bin\Debug\net8.0\。
在这个项目里还有一个 mda 的配置文件,ExampleCore_5_7.exe.mda.config。

想要启动 MDA,需要提前做一些准备,我在”基础知识“中,贴出了网址,如果不熟悉的大家可以自己恶补。我的操作过程是通过三步完成的,第一步,配置环境变量,第二步:配置项目的 MDA 配置文件,第三步就可以执行测试了。
第一步:在我的电脑里配置环境变量( COMPLUS_MDA=1,启动 MDA. )。

第二步:我为我的程序配置了 mda 配置文件,文件名:Example_13_1_1.exe.mda.config。 配置详情如下:

第三步:我没有选择调试工具,直接运行 exe,并没有看到任何错误,结果输出了( 说明 MDA 在 NET 平台无效,只是针对 .NET Framework 平台 )。 效果如图:

我们发现程序并没有出现错误。我们通过调试器试试。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_7\bin\Debug\net8.0\ExampleCore_5_7.exe】打开调试器。
进入调试器后,我们直接【g】运行调试器,直到调试器输出如图:

按组合键【ctrl+c】进入中断模式,从输出结果中已经知道输出是有错误的,并且,我们调用了非托管的 API,所以上来第一步我们先验证一下托管堆有没有问题,执行命令【!VerifyHeap】。

我们一看就知道有错误了,那么多 Error 。虽然知道有了错误,我尝试了一些方法,都没办法继续下去了。我就到此为止了,Windbg Preview 测试的还是更好点,这也是我推荐在工作中使用 Windbg Preview 做调试的原因。


2)、Windbg Preview 调试
我们编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch Executable】,加载我们的项目文件:ExampleCore_5_7.exe,进入调试器后。我们直接【g】运行调试器,直到我们的控制台程序输出结果为止。
效果如图:

此时,我们回到调试器中,点击【break】按钮,中断调试器,开始调试。
我们输入【!VerifyHeap】命令,来验证一下托管堆是否有效。

我们看到了,虽然程序没崩溃,其实我们的托管堆是有问题的。位于地址 2142b409648 处的对象的方法表是无效的。我们可以很容易验证这一点,即将这个地址上的内容通过【dp】命令显示出来。

此时,我们就知道了堆被破坏了。
我们可以针对地址: 2142b409648 使用【 !listnearobj 2142b409648 】命令列出该地址附近的对象,看看具体情况。

我们可以使用【!do 02142b409628 】或者【!DumpObj 02142b409628 】命令来确认我们说的对不对。

内容是 3 个 a,以前是 a、b、c 三个元素,说明数据被修改了。我们也可以使用【dp 02142b409628 】命令直接输出结果。

00007ffa`f3ff9438 是方法表, 00000000`00000003 是元素的个数:3个, 0061 就是 十进制 97(英文字母 a)。
我们可以针对使用【!DumpArray 02142b409628 】命令输出这个数组元素,并使用【!DumpVC】命令输出每个元素值。

我们知道了字符数组有问题,就能推断错误在哪里。

3.4、调试托管堆的碎片问题
A、基础知识
在托管堆上,当空闲内存块和被占用内存块交错存放,就会出现内存碎片的问题,我们的应用程序也会出现各种问题,通常表现为抛出 OutOfMemoryException 异常。我们如何确定有堆碎片问题呢?有一个标准可以参考,我们可以使用【空闲块总大小与托管堆总大小的比值】,如果空闲块占据了大部分堆空间,那么堆碎片可能会是一个问题。
我们还有一个问题需要关注,堆碎片发生在哪个代。在第 0 代中通常没有碎片,因为 CLR 堆管理器可以使用任何空闲的内存块来进行分配。然而,在第 1 代和第 2代中,使用空闲块唯一的方式就是将对象提升到各自的代中。由于第 1 代是临时内存段的一部分,并且只有一个临时内存段,因此,在调试堆碎片的问题时,通常需要分析第 2 代的内存空间。
当我们使用句柄“固定”一个对象的时候,过多的或者过久的“固定”是造成托管堆上出现碎片的最常见原因之一。如果需要固定对象,那么我们必须保证固定的时间较短,从而不对垃圾收集器造成太多的影响。

如果在 Windows 虚拟内存管理器管理的内存中产生了碎片,CLR 不会通过增加堆容量(增加新的内存段)来满足分配需求,我们可以使用【address】命令系统虚拟内存状态的详细信息。

有哪些工具可以监视进程中内存的使用量呢?有多种选择,最基本的方式就是使用【任务管理器】,可以按下【ctrl+shift+esc】组合键就可以打开【任务管理器】,如图:

【任务管理器】只能大略的查看内存使用情况,如果我们想知道消耗的内存是位于非托管堆上还是托管堆上?是在堆上还是在其他什么地方,【任务管理器】就不能胜任了。 此时,我们就应该使用【Windows 性能监视器】,它可以用来分析系统的整个状态或者是每个进程的状态。它使用了不同的数据源,例如:性能计数器,跟踪日志以及配置信息等,但是,性能监视器是用于分析 .NET Framework 应用程序的最易用的工具,跨平台是不适合的。

以下标红的内容是针对 .NET Framework 平台的,我记录一下而已,如果想关注 .NET 8.0 此处可以略过。
如果我们想运行【性能监视器】,可以在【运行框】中输入【perfmon】命令,就可以打开【性能监视器】。效果如图:

性能监视器如图:

我们可以点击左侧的【性能监视器】,在右侧通常显示 Processor Time 计数器,如果要添加计数器,可以点击右键,选择【添加计数器(D)】菜单,打开添加计数器的菜单,按着自己需求操作就可以了。效果如图:

【添加计数器】由两部分组成,第一步部分就是【可用计数器】选项,它包含一个 计数器种类 的下拉列表,以及可用对象的实例,并且性能计数器将在这些实例上收集和现实数据。右侧面板会列出已经被添加的所有性能计数器。
我们必须清楚的知道每种性能计数器的用途,才知道如何选择,具体性能计数器的用途如图:
.NET CLR 数据(.NET CLR Data) :关于数据(例如 SOL)性能的运行时统计信息
.NET CLR 异常(.NET CLR Exceptions) :关于 CLR 异常处理的运行时统计信息,例如所抛出的异常数量
.NET CLR 互用性(.NET CLR Interop) :关于互用性服务的运行时统计,例如列集操作的次数
.NET CLR 即时编译器(.NET CLR Jit) :关于即时编译器的运行时统计,例如被即时编译器编译的方法数量
.NET CLR 加载过程(.NET CLR Loading) :关于CLR类/程序集加载器的运行时统计,例如加载器堆中的字节总数
.NET CLR 锁与线程(.NET CLR LocksAndThreads) :关于锁和线程的运行时统计,例如锁的竞争率
.NET CLR 内存(.NET CLR Memory) :关于托管堆和垃圾收集器的运行时统计,例如每一代中收集操作的次数
.NET CLR 网络(.NET CLRE Networking) :关于网络的运行时统计,例如已发送和已接收的数据报
.NET CLR 远程操作(.NET CLR Remoting) :关于远程行为的运行时统计,例如每秒钟发生的远程调用次数
.NET CLR 安全(.NET CLR Security) :关于安全性的运行时统计,例如运行时检查的总次数

B、眼见为实
B1、 调试源码:ExampleCore_5_9
调试任务:如何找到堆碎片问题并且分析发生问题的原因。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_5_9\bin\Debug\net8.0\ExampleCore_5_9.exe】打开调试器。
进入到调试器后,直接【g】运行调试器,我们的控制台程序输出“ ”字样,让我们输入分配内存的大小,我输入 50000 字节,回车。控制台继续输出“ ”字样,让我们输入分配内存的最大值,我输入 1000 MB(也就是 1 G MB)字节,回车。此时,我们的控制台程序输出“ Press any key to GC & promo to gen1 ”字样。效果如图:

按组合键【ctrl+c】进入中断模式,开始我们的调试。
我们先看看托管堆上的统计情况,执行【 !DumpHeap -stat】命令。

红色标注的已经说明的很清楚了,由于我们在关注对碎片,所以主要看标注【Free】的数据。我们可以看到内存空闲块有 19695 个,所在空间的大小是 1365104 字节,也就是 1.36 MB。有了空闲内存块的大小了,我们在看看 GC 堆总的大小是多少,执行【!eeheap -gc】命令。

我们看到 GC 堆分配了 1 GB 左右的空间,空闲块总大小是 1.36 MB,也就是堆碎片在系统的 0.1,这个比例不是很大,可以继续运行。其实,我们在使用【!eeheap -gc】命令的输出中,能看到第 2 代有很多的内存段,说明分配了很多新的内存。由于我们正在分配一块非常大的内存,因此,临时内存段会很快被填满,并开始创建新的第 2 代内存段。

我们可以使用【!DumpHeap -type Free】命令或者【 !dumpheap -mt 0000029481a7bed0】命令来找出堆上所有的空闲内存块。

另外【 !dumpheap -mt 】命令的结果类似。

我们【g】继续测试,恢复程序的执行,如果提示,就按任意键继续,直到控制台程序输出“ Press any key to Exit ”字样为止。
效果如图:

此时,我们点击【ctrl+c】组合键进入中断模式,输入命令【!DumpHeap -stat】统计一下托管堆的情况。

我们这次看到了内存空闲块发生了很大的变化,数量变化不大,以前是 19695,现在是 9846,数量变少了。内存空间变大了,以前是 1.36 MB,现在是 492 MB。
我们在把 GC 堆分配的内存输出出来,查看一下。执行命令【!eeheap -gc】。

GC 堆的大小是 1 GB 左右,空闲内存块占据将近 500 MB 大小,说明现在堆的碎片率是 50%了,可以使用【!DumpHeap】命令确认这一点。

我们主要关注标红的,它们是交错分布的,一个空闲对象,一个有对象,出现这种情况,一般来说,就是对象被固定了,不能移动,GC 无法执行紧缩和合并操作,我们可以使用【!GCHandles】命令,查看一下句柄的使用情况。

我们主要关注红色部分,其实我省略了很多。我们看到 Pinned 类型的句柄有 10001 个。上面的内容我注释的很清楚,就不过多的解释了。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的项目文件:ExampleCore_5_9.exe,进入到调试器。
进入到调试器后,直接【g】运行调试器,我们的控制台程序输出“ ”字样,让我们输入分配内存的大小,我输入 50000 字节,回车。控制台继续输出“ ”字样,让我们输入分配内存的最大值,我输入 1000 MB(也就是 1 G MB)字节,回车。此时,我们的控制台程序输出“ Press any key to GC & promo to gen1 ”字样。效果如图:

此时,我们回到调试器,点击【Break】按钮进入中断模式,开始我们的调试之旅。
我们先看看托管堆上的统计情况,执行【 !DumpHeap -stat 】命令。

由于我们在调试堆碎片的问题,所以要特别关注标注为【Free】的数据,我们看到了共有 19695 个空闲内存块,占用总大小为 1365104 字节,也就是 1.36 MB。

接下来,我们看看堆碎片在哪个代上,使用【!eeheap -gc】命令。

红色标注的指出,GC 堆的总大小刚好在 1G 左右。从输出我们注意到,有一个非常大的内存段列表。由于我们正在分配一块非常大的内存,因此,临时内存段会很快被填满,并开始创建新的第 2 代内存段。
我们可以使用【!DumpHeap -type Free】命令或者【 !dumpheap -mt 1e7e9acc5b0 】命令来找出堆上所有的空闲内存块。

两个命令输出差不多,

我们可以拿这些 Free 块的地址和第 2 代中的起始地址比较,就知道,这些空闲对象大部分都在第 2 代中。在堆中总共的空闲块大小为 1365104 字节,也就是 1.36 MB,共 19695 个,而堆的大小为 1 GB,因此,现在堆碎片占据的比例还是比较小的,不用担心碎片问题。

我们【g】继续测试,恢复程序的执行,如果提示,就按任意键继续,直到控制台程序输出“ Press any key to Exit ”字样为止。
效果如图:

此时,我们点击【Break】按钮进入中断模式,输入命令【!DumpHeap -stat】统计一下托管堆的情况。

此时,我们再看看【Free】块,现在数量有 9846,总大小四号 492996800 字节,也就是 492 MB,我们再使用【!eeheap -gc】找到 托管堆的大小。

我们看到 GC 堆总大小是 993 MB,空闲块总大小是 也就是 492 MB,可以认为堆中 50% 的碎片。可以使用【!DumpHeap】命令确认这一点。

红色标注的可以很清楚的看到,分配一个对象,空闲一个对象,交替出现。我们知道GC执行垃圾收集的时候会紧缩和合并,但是这里并没有执行紧缩和合并的操作,原因之一就是在堆上存在一些被固定住(不能移动的)的对象。

为了确定是否是有些对象被固定了,我们可以使用【!GCHandles】命令来看看进程中是否包含了“Pinned”类型的句柄。

进程中有 10001 个类型为“Pinned”的句柄,有 10000 个这样的句柄用于固定字节数组。


B2、 调试源码:ExampleCore_5_10
调试任务:如何调试 .NET 程序的内存泄漏
1)、NTSD 调试
编译项目,首先,把我们的控制台程序运行起来,然后再打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD -pn ExampleCore_5_10.exe】打开调试器。效果如图:

此时,调试器中断执行,如图:

在看看我们的控制台程序,执行情况如图:

准备就绪,开始我们的调试,先看看 GC 堆的情况,执行【!eeheap -gc】命令。

我看完了,感觉没啥异常情况,在看看我们的引用程序域有没有什么问题,执行命令【!DumpDomain】。

在【Domain 1】应用程序域中,加载了很多动态的程序集,太多了,不用过脑子,都知道有问题。
接下来,我我们看看这个模块有什么特别之处,执行命令【!DumpModule 00007ff913982df0 】,我们就随机的选择最后一个程序集。

特性一行是我们特别要关注的, Reflection 表示是反射的, IsDynamic 表示是动态创建的(不是程序员搞的), IsInMemory 直接在内存中的,3个特性就是告诉我们,这个模块是通过反射技术、由系统创在内存中直接创建的。

它既然有执行调用,肯定有调用栈,调用栈肯定是有我们的程序触发的,所以我们,必须切换到托管线程上下文,查看具体的调用栈。先执行【~0s】切换线程,然后再执行【!clrstack】命令,查看具体的调用栈。

继续执行。

到这里,我们就很清楚了, System.Xml.Serialization.XmlSerializer..ctor 类型创建了实例,在内部又调用了 System.Xml.Serialization.XmlSerializer.GenerateTempAssembly 创建临时的程序集,问题找到了,剩下的就简单了。

2)、Windbg Preview 调试
编译项目,运行我们的控制台程序:ExampleCore_5_10.exe,等输出的数字大于2000,输出内容如:已经序列化第【XXXX】个人了,XXXX 就是具体的数字,开始打开【Windbg Preview】调试器,依次点击【文件】---【Attach to process】,点击【Attach】附加我们的进程。此时调试器卡死,我们的控制台程序输出也停止了,我们回到调试器中,点击【Break】按钮,就可以调试我们的任务了。
此时的输出的内容可能太多,我们可以使用【.cls】命令清理一下调试器的输出。
我们先查看一下托管堆的情况,执行命令【 !eeheap 】。

当我看到这个结果的时候,我感觉没有什么异常,除了这应用程序域【Domain 1 】,它提示说: No unique loader heaps found. 没有找到唯一的加载堆,说明就是有多个,这个应用程序的地址是: 01be298ed190 ,我们针对这个地址查看一下这个应用程序域的详情,执行命令【!DumpDomain 01be298ed190 】。

我们看到了吧,红色的部分,我省略了很多,如果全部输出,太多了。从表面上看,也能猜到有问题。在我们应用程序域中加载了很多动态创建的程序集,就是这个东西导致的内存泄漏。
我们以最后一个为例,模块的类型也是【 Dynamic Module 】,地址是: 00007ff91423af50 ,我们再使用【!DumpModule 00007ff91423af50 】命令,查看一下这个模块的信息。

我们看到这个模块的特性【Attributes】的值是 Reflection IsDynamic IsInMemory Reflection 表示是反射的, IsDynamic 表示是动态的(不是我们手动创建的), IsInMemory 表示是在内存中创建生成的,这三个词就是说,这个模块式是通过反射技术在内存中动态创建的。
有一点,我们可以知道,就是这些程序集是我们的程序动态创建的,那我们去托管线程调用栈看看不就清楚了。
首先,我们要执行【~0s】命令切换到托管线程上下文中,然后,执行【!clrstack】命令。

我们看到了 System.Xml.Serialization.XmlSerializer..ctor 类型的实例化,在该类型内部又调用了 System.Xml.Serialization.XmlSerializer.GenerateTempAssembly 创建临时程序集,看来问题我们找到了,那就容易了。

我们再说一种情况,如果程序直接运行到崩溃,并报告一个 OutOfMemoryException 的异常怎么办?当然,这样又分两种情况,如果程序是自己的,可以通过调试器附近进程进行调试,如果程序是别人的,不能直接调试程序,就只能通过抓 Dump 来实现调试了。抓 Dump 有两种方式,一种是使用【任务管理器】,一种是使用【ProcessExplorer】工具。
这个调试就不演示了,大概过程就是,我们【g】继续,直到我们的程序崩溃,抛出内存溢出的异常为止,我们可以使用【kb】命令找到异常地址,再使用【!PrintException】打印异常信息,也可以找到具体的问题。


四、总结
这篇文章的终于写完了,这篇文章的内容相对来说,不是很多。写完一篇,就说明进步了一点点。Net 高级调试这条路,也刚刚起步,还有很多要学的地方。皇天不负有心人,努力,不辜负自己,我相信付出就有回报,再者说,学习的过程,有时候,虽然很痛苦,但是,学有所成,学有所懂,这个开心的感觉还是不可言喻的。不忘初心,继续努力。做自己喜欢做的,开心就好。