写点什么

一张图带你了解.NET 终结 (Finalize) 流程

  • 2024-10-12
    福建
  • 本文字数:3325 字

    阅读完需:约 11 分钟

简介


"终结"一般被分为确定性终结(显示清除)与非确定性终结(隐式清除)


1、确定性终结主要

提供给开发人员一个显式清理的方法,比如 try-finally,using。


2、非确定性终结主要

提供一个注册的入口,只知道会执行,但不清楚什么时候执行。比如 IDisposable,析构函数。


为什么需要终结机制?


首先纠正一个观念,终结机制不等于垃圾回收。它只是代表当某个对象不再需要时,我们顺带要执行一些操作。更加像是附加了一种 event 事件。所以网络上有一种说法,IDisposable 是为了释放内存。这个观念并不准确。应该形容为一种兜底更为贴切。如果是一个完全使用托管代码的场景,整个对象图由 GC 管理,那确实不需要。在托管环境中,终结机制主要用于处理对象所持有的,不被 GC 和 runtime 管理的资源。比如 HttpClient,如果没有终结机制,那么当对象被释放时,GC 并不知道该对象持有了非托管资源(句柄),导致底层了 socket 连接永远不会被释放。


如前所述,终结器不一定非得跟非托管资源相关。它的本质是”对象不可到达后的 do something“.比如你想收集对象的创建与删除,可以将记录代码写在构造函数与终结器中


终结机制的源码


源码

namespace Example_12_1_3{    internal class Program    {        static void Main(string[] args)        {            TestFinalize();
Console.WriteLine("GC is start. "); GC.Collect(); Console.WriteLine("GC is end. "); Debugger.Break();
Console.ReadLine(); Console.WriteLine("GC2 is start. "); GC.Collect(); Console.WriteLine("GC2 is end. "); Debugger.Break(); Console.ReadLine();
} static void TestFinalize() { var list = new List<Person>(1000); for (int i = 0; i < 1000; i++) { list.Add(new Person()); }
var personNoFinalize = new Person2(); Console.WriteLine("person/personNoFinalize分配完成");
Debugger.Break(); } } public class Person { ~Person() { Console.WriteLine("this is finalize"); Thread.Sleep(1000); } } public class Person2 {
}}
复制代码


IL

	// Methods	.method family hidebysig virtual 		instance void Finalize () cil managed 	{		.override method instance void [mscorlib]System.Object::Finalize()		// Method begins at RVA 0x2090		// Header size: 12		// Code size: 30 (0x1e)		.maxstack 1
IL_0000: nop .try { // { IL_0001: nop // Console.WriteLine("this is finalize"); IL_0002: ldstr "this is finalize" IL_0007: call void [mscorlib]System.Console::WriteLine(string) // Console.ReadLine(); IL_000c: nop IL_000d: call string [mscorlib]System.Console::ReadLine() IL_0012: pop // } IL_0013: leave.s IL_001d } // end .try finally { // (no C# code) IL_0015: ldarg.0 IL_0016: call instance void [mscorlib]System.Object::Finalize() IL_001b: nop IL_001c: endfinally } // end handler
IL_001d: ret } // end of method Person::Finalize
复制代码


汇编

0199097B  nop  0199097C  mov         ecx,dword ptr ds:[4402430h]  01990982  call        System.Console.WriteLine(System.String) (72CB2FA8h)  01990987  nop  01990988  call        System.Console.ReadLine() (733BD9C0h)  0199098D  mov         dword ptr [ebp-40h],eax  01990990  nop  01990991  nop  01990992  mov         dword ptr [ebp-20h],offset Example_12_1_3.Person.Finalize()+045h (00h)  01990999  mov         dword ptr [ebp-1Ch],0FCh  019909A0  push        offset Example_12_1_3.Person.Finalize()+06Ch (019909BCh)  019909A5  jmp         Example_12_1_3.Person.Finalize()+057h (019909A7h)  
复制代码


可以看到,C#的析构函数只是一种语法糖。IL 重写了 System.Object.Finalize 方法。在底层的汇编中,直接调用的就是 Finalize()


终结的流程



补充一个细节,实际上 f-reachable queue 内部还分为 Critical/Normal 两个区间,其区别在于是否继承自 CriticalFinalizerObject。目的是为了保证,即使在 AppDomain 或线程被强行中断的情况下,也一定会执行。一般也很少直接继承 CriticalFinalizerObject,更常见是选择继承 SafeHandle.不过在.net core 中区别不大,因为.net core 不支持终止线程,也不支持卸载 AppDomain。


眼见为实


使用 windbg 看一下底层。1. 创建 Person 对象,是否自动进入 finalize queue?



可以看到,当 new obj 时,finalize queue 中已经有了 Person 对象的析构函数


2. GC 开始后,是否移动到 F-Reachable queue?



可以看到代码中创建的 1000 个 Person 的析构函数已经进入了 F-Reachable queue


sosex !finq/!frq 指令同样可以输出


3. 析构对象是否被"复活"?GC 发生前,在 TestFinalize 方法中创建了两个变量,person=0x02a724c0,personNoFinalize=0x02a724cc。




可以看到所属代都为 0,且托管堆中都能找到它们。


GC 发生后





可以看到,Person2 对象因为被回收而在托管堆中找不到了,Person 对象因为还未执行析构函数,所以还存在 gcroot 。因此并未被回收,且内存代从 0 代提升到 1 代


4. 终结线程是否执行,是否被移出 F-Reachable queue




在 GC 将托管线程从挂起到恢复正常后,且 F-Reachable queue 有值时,终结线程将乱序执行。并将它们移出队列



5. 析构函数的对象是否在第二次 GC 中释放?


等到第二次 GC 发生后,由于对象析构函数已经被执行,不再拥有 gcroot,所以托管堆最终释放了该对象,



6. 析构函数如果没有及时执行完成,又触发了一次 GC。会不会再次升代?



答案是肯定的


Finaze Queue/F-Reachable Queue 底层结构



眼见为实



每个不同的代,维护在不同的内存地址中,但彼此之间的内存地址又紧密联系在一起。



与 GC 代优点细微区别的是,没有 LOH 概念,大对象分配在 0 代中。Person3 对象是一个 new byte[8500000]。 其他行为与 GC 代保持一致


终结的开销


1、如果一个类型具有终结器,将使用慢速分支执行分配操作

且在分配时还需要额外进入 finalize queue 而引入的额外开销


2、终结器对象至少要经历 2 次 GC 才能够被真正释放

至少两次,可能更多。终结线程不一定能在两次 GC 之间处理完所有析构函数。此时对象从 1 代升级到 2 代,2 代对象触发 GC 的频率更低。导致对象不能及时被释放(析构函数已经执行完毕,但是对象本身等了很久才被释放)。


3、对象升代/降代时,finalize queue 也要重复调整

与 GC 分代一样,也分为 3 个代和 LOH。当一个对象在 GC 代中移动时,对象地址也需要也需要在 finalization queue 移动到对应的代中.

由于 finalize queue 与 f-reachable queue 底层由同一个数组管理,且元素之间并没有留空。所以升代/降代时,与 GC 代不同,GC 代可以见缝插针的安置对象,而 finalize 则是在对应的代末尾插入,并将后面所有对象右移一个位置


眼见为实


    public class BenchmarkTester    {        [Benchmark]        public void ConsumeNonFinalizeClass()        {            for (int i = 0; i < 1000; i++)            {                var obj = new NonFinalizeClass();                obj.Age = i;
} } [Benchmark] public void ConsumeFinalizeClass() { for (int i = 0; i < 1000; i++) { var obj = new FinalizeClass(); obj.Age = i;
} } }
复制代码



非常明显的差距,无需解释。


总结


使用终结器是比较棘手且不完全可靠。因此最好避免使用它。只有当开发人员没有其他办法(IDisposable)来释放资源时,才应该把终结器作为最后的兜底


文章转载自:叫我安不理

原文链接:https://www.cnblogs.com/lmy5215006/p/18456380

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
一张图带你了解.NET终结(Finalize)流程_.net_不在线第一只蜗牛_InfoQ写作社区