问题

C#里的析构方法什么时候才会调用?

回答
C 的析构方法,也就是大家常说的“析构函数”(虽然技术上 C 没有传统意义上的析构函数,而是 destructor),它的调用时机确实是很多人容易混淆的地方。它不是像构造函数那样在对象创建时立即执行,而是与垃圾回收(Garbage Collection, GC)紧密关联。

要理解析构方法什么时候调用,我们得先聊聊 C 的内存管理机制。

C 的自动内存管理:垃圾回收的功劳

C 使用的是一种托管执行环境(Managed Execution Environment),也就是我们常说的 .NET CLR(Common Language Runtime)。CLR 的一个核心功能就是自动内存管理。当你在 C 中创建一个对象时,CLR 会在托管堆(Managed Heap)上为这个对象分配内存。

通常情况下,当你创建的对象不再被任何活动的代码引用时,它就被认为是“不可达”的了。这意味着,从你的程序开始点(例如 `Main` 方法)出发,你再也无法通过任何变量或方法调用访问到这个对象。

这时,垃圾回收器(Garbage Collector, GC)就会派上用场了。GC 是一个后台进程,它会周期性地扫描托管堆,寻找那些不再被引用的对象。一旦 GC 发现一个对象是不可达的,它就会认为这个对象占用的内存可以被回收了。

析构方法(Finalizer)的出场时机

析构方法(在 C 中是以 `~ClassName()` 的语法表示)的真正调用时机,是在 GC 决定回收一个包含析构方法的对象时。

这里需要强调一个关键点:GC 并不保证析构方法会立即执行。GC 的工作方式是这样的:

1. 标记(Marking): GC 首先会遍历所有可达的对象,并在它们旁边打上“活着的”标记。
2. 清扫(Sweeping): GC 会接着扫描整个托管堆。所有没有被标记为“活着的”那些对象,GC 就会认为它们是可以被回收的。
3. 终结(Finalization): 如果一个即将被回收的对象定义了析构方法,GC 不会立即将它占用的内存释放掉。相反,它会把这个对象放入一个叫做终结队列(Finalization Queue)的地方。GC 的一个专门的线程会负责处理这个终结队列,逐个调用队列中对象的析构方法。

为什么 GC 不会立即释放内存,而是先调用析构方法?

析构方法的主要用途是释放那些非托管资源(Unmanaged Resources)。这些资源包括但不限于:

文件句柄 (File Handles)
网络连接 (Network Connections)
数据库连接 (Database Connections)
图形设备接口 (GDI) 对象
Windows 系统句柄 (Windows Handles)

托管堆上的内存是由 GC 来管理的,GC 会自动回收。但是,非托管资源通常不由 GC 管理,它们需要显式地释放。如果你在一个对象中持有了这些非托管资源,并且你的对象拥有一个析构方法,那么析构方法就是你最后的机会去确保这些非托管资源被正确释放。

析构方法的调用需要经过 GC 的两次扫描(通常)

这可能是最容易让人感到困惑的地方。一个对象,如果它有析构方法,GC 的回收过程会比没有析构方法的对象复杂一些:

第一次 GC 扫描: GC 发现一个对象是不可达的,并且它有析构方法。GC 不会立即回收它,而是将它加入终结队列,并设置一个标志。
终结线程执行析构方法: 当终结线程处理终结队列时,它会调用这个对象的析构方法,从而释放非托管资源。
第二次 GC 扫描: 在析构方法被调用之后,对象占用的内存实际上还是在托管堆中。当 GC 进行下一次扫描时,它会再次遇到这个对象。这一次,因为它的析构方法已经被执行过了,GC 就可以安全地回收它占用的内存了。

所以,如果一个对象有析构方法,它可能需要 GC 经历两次才能最终被销毁并释放内存。这也就是为什么过度依赖析构方法来释放资源会影响程序的性能和响应能力。

何时真正不希望依赖析构方法?

资源清理不确定性: GC 的触发时机是不确定的。你无法控制 GC 何时运行,也就无法控制析构方法何时执行。这意味着你的非托管资源可能在很长一段时间内都未被释放,这可能导致系统资源耗尽。
性能开销: 如上所述,有析构方法的对象需要 GC 进行两次扫描才能回收,这会增加 GC 的负担,进而影响程序的整体性能。

更优的资源管理方式:`IDisposable` 接口和 `using` 语句

为了解决析构方法的不确定性和性能问题,C 引入了 `IDisposable` 接口。

`IDisposable` 接口: 这个接口只有一个方法:`Dispose()`。
`Dispose()` 方法: 这个方法用于显式地释放非托管资源。当你的对象实现了 `IDisposable` 接口后,你就可以在不再需要对象时主动调用 `Dispose()` 方法。
`using` 语句: C 提供了 `using` 语句,它能够非常方便地确保 `IDisposable` 对象的 `Dispose()` 方法在代码块结束时被自动调用,即使在代码块中发生了异常。

为什么 C 还需要析构方法?

尽管 `IDisposable` 是推荐的资源管理方式,但析构方法仍然是必不可少的“后备保障”。设想一下,如果一个实现了 `IDisposable` 的对象,在使用过程中因为某些原因没有被显式调用 `Dispose()`(例如,忘记了或者发生了未处理的异常导致代码提前退出),那么当 GC 扫描到这个对象时,它会发现这个对象仍然持有非托管资源。

在这种情况下,GC 会调用对象的析构方法。虽然析构方法的调用可能延迟,但它至少能保证非托管资源最终有机会被释放,防止内存泄漏(或者更准确地说,资源泄漏)。

总结一下析构方法调用的关键点:

1. 只有当对象持有非托管资源时,才需要考虑定义析构方法。
2. 析构方法不是由你直接调用的,而是由 GC 在回收对象时隐式调用。
3. GC 触发时机是不确定的,所以析构方法的执行时机也是不确定的。
4. 有析构方法的对象,GC 在回收时通常需要两次扫描:一次是判断对象可回收并加入终结队列,另一次是在析构方法执行后真正回收内存。
5. 对于需要显式释放非托管资源的对象,优先使用 `IDisposable` 接口和 `using` 语句进行管理,将其作为析构方法的主要替代方案。
6. 析构方法作为一种“安全网”,用于确保即使 `Dispose()` 没有被调用时,非托管资源也能得到最终的释放。

理解了这些,你就能更清晰地知道析构方法在 C 中的作用以及它何时会被调用了。通常情况下,除非你的类直接操作底层系统资源,并且无法通过 `IDisposable` 模式来完美管理,否则你可能不需要手动编写析构方法。更多时候,你可能会遇到其他类库中定义的析构方法,了解它们的机制有助于你更好地理解这些库的资源管理行为。

网友意见

user avatar

析构方法的调用时机和GC一样是完全不可预测的,也不应当依赖于它被调用。甚至于它也不一定会被调用,和GC一样,不一定会发生。

析构函数是确保已分配的非托管资源总能被释放的一个补救措施。如果可能就不应当被调用,譬如说手动释放了非托管资源,此时应当通知GC取消对对象的析构函数的调用。


所以:

首先托管资源足够好用也够用,一般情况下用不到非托管资源。

其次非托管资源有丰富的安全的类库封装,一般情况下不需要自己分配。

最后,如果你一定要手动分配非托管资源,那么记住析构函数是保险丝,是最后的保障,不是常规的做法。

user avatar

1.C#中的finalization是类内的一个实例方法,当这个类型的实例不可达时会被调用。

2.不可达的含义是,在进行GC的标记阶段,该对象被认为是没有被任何根引用的对象。

3.GC必须知道所有的可终结对象,当它们不可达时调用它们的 finalizers。GC把这类对象记录在 finalization queue。换言之,finalization queue 在任何时刻包含着所有的存活的可终结对象。如果有许多对象处于 finalization queue,不意味着一些坏事会发生,仅仅表明当前存活的对象中有很多定义了 finalizer。

4.在GC过程的标记阶段完成时,GC检查 finalization queue 中有哪些对象不再可达。如有有些finalization queue 中的对象不可达,它们不会被立刻删除,因为它们的 finalizer 还未执行。因此,这些不可达的对象移动到 fReachable queue,fReachable 的意思是 finalizer reachable,虽然在标记阶段后这个对象不再被认为可达,但 finalizer 仍然可以访问它。只要 fReachable queue 中有对象,GC会指示 finalizer thread 来运行这些 finalizer 。

5.Finalizer thread 是另一种由.NET运行时创建的线程,它会一个接一个的移除 fReachable queue 中的对象,并调用其 finalizer 。 finalizer thread 运行时可能在 finalizer 的代码中分配对象,因此,在垃圾处理完成后,用户线程开始执行的时候, finalizer thread 才会开始执行。在 fReachable queue 中移除对象后,这个对象在GC看来变得不可达,会在下次对这个对象所在的代回收时回收这个对象。

6.进一步的, fReachable queue 在标记阶段视为根,因为 finalizer thread 不一定会在本次GC结束,下次GC开始前完成工作。这使得可终结对象更容易暴露在中间代危机中,一个在G0回收的可终结对象本应在G1被回收,但由于 finalizer thread 处理不及时,被升代至G2.

7.可以通过 GC.WaitForPendingFinalizers 挂起调用线程,直到 fReachable queue 清空。作为副作用,所有的 fReachable queue 变得不可达,可以在对应代GC时清理。

使用如下方式可以避免中间代危机,可以精确的清理内存。

GC.Collect();

GC.WaitForPendingFinalizers();

GC.Collect();

这种方式首先进行 full-blocking GC 发现所有的 fReachable objects,然后让当前线程等待 fReachable queue 清空,让这些对象不可达,最后再次进行 full-blocking GC,回收 fReachable objects 的内存。

很明显,如果其他线程在这个线程调用 GC.WaitForPendingFinalizers 的时候分配了可终结对象,那么这些可终结对象会升至G1,再次调用 GC.Collect 时会升至G2, fReachable queue 可能添加新的项。这使得你无法在不暂停其他线程的情况下清空所有的内存垃圾。

8.有两个重要的 finalization API 暴露在 GC 类中:

a.GC.ReRegisterForFinalize(object),允许已经注册过的对象再次注册到 finalization queues 。这个方法所作的事情调用与分配器分配可终结对象注册在 finalization queues 使用同一个内部方法。它通常出现在 finalizer 代码中,它的开销并不重要。(用来构建对象池)

b.GC.SuppressFinalize(object),这个方法请求运行时不要调用指定对象的终结器。它广泛的使用与 Disposable 模式,被高度优化。调用这个方法不会操作 finalization queues,而是对 对象头置位。这样,这些置位的对象就不会在 finalizer thread 上调用 finalizer 。(广泛使用)

9.实现IDisposable 接口实现显式资源释放。(不展开讲)

10.使用 SafeHandle 包装句柄。(不展开讲)

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有