需要注意的是,为了清晰起见,本专栏中显示的示例代码仅适用于单线程应用程序。用于 .NET 资源管理的可重用线程安全代码可从 CodePlex 上的 LifeTimeScope.net 项目中获得(参见 codeplex.com/lifetimescope)。
在 .NET 编程中,对象是用 new 关键字创建的,并且分配在托管堆上。对象一直存活,直到垃圾收集器发现从一个强的根引用(通过强引用的路径)再也无法到达该对象为止。每个程序都有一个 main 方法和一个关联类,还有一些静态类,这些类都可能包含局部变量、静态成员甚至还有事件。这些静态或本地引用可视为程序内的根引用(参见图 1)。普通的 .NET Framework 引用称为强引用。对象生存期由强引用的路径确定,该路径从根引用指向对象。
在某些情况下(比如在对象图中构建一个快速缓存或哈希时),如果对象有一些并不改变对象生存期的附加引用,可能会比较有意思。出于这一目的,.NET Framework 提供一个名为 WeakReference 的附加类,它允许您实现对象间的弱引用。在确定对象的生存期时,将会忽略此弱引用。
事件也可以是强的根引用,其本身可以帮助形成强引用路径,从而影响对象的生存期。公共语言运行时 (CLR) 2.0 中的普通事件是事件源与侦听器之间的双向强引用,因此可以使原本应该死亡的对象(源或侦听器)保持存活。这就是在 .NET Framework 3.0 中增加 WeakEvent 类的原因。熟悉 WeakEvent 模式很重要,虽然还不是很知名,但它是成功实现 Observer 模式所必需的。WeakEvent 模式已经用于 Windows® Presentation Foundation (WPF) 的数据绑定实现,以防止因数据绑定而造成的泄漏。
让我们看一下 CLR 如何确定一个对象是否仍然存活,如果未存活将发生什么情况。在 2000 年 11 月刊的《MSDN® 杂志》(msdn.microsoft.com/msdnmag/issues/1100/GCI) 中,Jeffrey Richter 阐述了该过程。每个 .NET 进程都运行一个由垃圾收集器使用的独立线程。当垃圾收集器运行时,所有其他线程都暂停运行。垃圾收集器分析内存结构和引用,以找出哪些对象处于死亡状态。然后,这些对象排队等待一个称为 finalization 的进程。在第二阶段中,finalization 进程在由垃圾收集器使用的独立线程上执行,并对每个对象调用 Finalizer 方法。
此过程中有两个需要注意的要点。首先,Finalizer 是从其他线程中调用的,而不是创建对象的线程。其次,Finalizer 是在某一时间点上调用的,但不一定是接近对象实际死亡的时间点(当系统工作强度很大但内存空闲时,可以发现这种情况)。
垃圾收集器在静态 System.GC 类中公开。通过调用 GC.Collect,您可以强制垃圾收集器运行,让它识别死亡对象并将它们排入队列等待终结。您还可以调用 GC.WaitForPendingFinalizers,对所有排队的对象强制执行终结。不过,调用 WaitForPendingFinalizers 有两个问题:这是一个耗时的过程,而且 Finalizer 是从另一个线程调用的。这可能会导致性能和线程问题,譬如说,如果您需要释放一个指向系统资源的引用,并计划在 Finalizer 中这么做 — 某些非托管资源是依赖于线程的。(在这种情况下,请继续阅读并了解 Dispose 模式)。
由于 .NET Framework 团队已认识到及时析构对象的必要性以及允许析构代码在特定线程上执行的必要性,他们提出一个叫做 Dispose 模式的解决方案。有关详情,请参阅《MSDN 杂志》2007 年 7 月刊的“CLR 完全介绍”专栏,网址是:msdn.microsoft.com/msdnmag/issues/07/07/CLRInsideOut。
Dispose 模式是每一个拥有资源并需要及时析构的对象都应该实现的一种约定。它的履行方法是实现 IDisposable 接口并在该接口公开的 Dispose 方法实现中释放资源。然而,正像在非托管领域中一样,确保在客户端代码中适时调用 Dispose 方法仍然是程序员的负担。Brian Harry 在他的公共论坛中有一个帖子解释了为什么该团队提出这一解决方案,为什么不能成功地应用其他有意思的解决方案(请参阅 discuss.develop.com/archives/wa.exe?A2=ind0010A&L=DOTNET&P=R28572)。必须为所有拥有资源的对象正确实现 Dispose 模式。
在 COM 领域中,生存期管理通过引用计数工作。每个 COM 对象都对指向它的客户端引用进行内部计数。客户端开发人员通过调用 IUnknown::AddRef 和 IUnknown::Release 对这些计数器进行增减。注意,AddRef 使计数器值增加,应该在设置对组件的引用时调用;Release 使计数器值减少,应该在引用销毁时调用。当计数器回到 0 时,便可销毁对象。
对 AddRef 和 Release 的调用可通过使用活动模板库 (ATL) 中的 SmartPointers 来简化,也可由编译器/运行时(比如 Visual Basic® 6.0)生成所需的代码来简化。事件仅在一个方向上是强指针,而在另一个方向上则是弱指针。
引用计数的两大主要问题是性能损失和可能无法检测到某些循环引用情形下的死亡对象。性能损失在 COM 中一般是可以接受的,因为它通常用作一种组合更大型组件的技术。不过,.NET Framework 是在类级别上使用的,因此,如果将引用计数添加到 CLR,则性能影响会过于严重。
如果您从托管代码中使用 COM 组件,则使用 tlbimp.exe 生成一个所谓的运行时可调用包装 (RCW),它会向托管领域公开 COM 组件的功能。注意,当您在 Visual Studio® 中引用 COM 组件时,tlbimp.exe 将在后台执行。识别 RCW 很容易,因为它们在调试器中显示为 System.__ComObject。它们是 .NET 类型,持有一个指向 COM 对象的非托管指针并调用该对象的 AddRef 和 Release 方法。组件创建后会调用 AddRef,RCW 终结时会调用 Release。
但是 RCW 何时终结呢?您不知道答案,所以需要一个方法在 RCW 上调用 Dispose。遗憾的是,生成的 RCW 没有实现 IDisposable。目前,与调用缺失的 Dispose 方法同等的方法是进行如下调用:
System.Runtime.InteropServices.Marshal.ReleaseComObject(MyCOMObject)
在许多情况下,您需要确保尽快释放所用的资源。理想方式是使用 C# 中的 using 关键字来确保程序流离开大括号圈定的范围时在该对象上调用 Dispose 方法:
using (FileStream theFileStream = File.Create("C://hello.txt")) { string s = theFileStream.Name; }
另外,值得注意的是,C++/CLI 在退出方法时会自动调用局部变量的析构函数。在这些情形中,一般可以假定,在代码块或方法的末尾会自动调用 Dispose 方法。
不过,当资源的使用范围不是局部时,这些方法便不能正常工作。假设在上述代码中调用另一个方法,使用 FileStream 对象作为输入参数。该方法可能不会调用 Dispose 或使用 using 关键字。对于在线程之间共享资源的多线程服务器环境而言,这种情况可能会变得更加复杂。
遗憾的是,C# 未提供等价于 using 关键字并且可跨范围或线程使用的简单构造。这使重用代码和例程变得很困难,因为调用 Dispose 的责任必须在组件之间进行协商,以确保它在适当的时间只执行一次。
注意:如果您没有显式调用 Dispose,那么 Finalizer 肯定会调用它(如果对象正确实现了 Dispose 模式)。但是,由于您不知道何时会调用 Finalizer,因此有在高负载情况下耗尽资源的风险。当从另一个线程而不是从创建对象的线程调用 Finalizer 时,还存在 Finalizer 失败的风险。如果您试图在 Finalizer 中释放非托管资源或 COM 资源时,这可能会成为问题。
一个可能的解决方案是,围绕任何实现 IDisposable 的类型构建一个通用包装类。该包装类返回一个 IDisposable 接口,但在将调用委托给组件之前在内部设置一个引用计数器。图 2 中所示的代码使用一个名为 AddRef 的新扩展方法来获得相应的包装对象,该对象包含一个针对特定对象的内部引用计数器。
当调用 AddRef 时,代码将检查一张哈希表,以确定该对象是否已有包装。如果是,它就返回该包装,并增加计数器的值;否则,它就创建一个新包装,并将其添加到哈希表中。当程序到达 using 块的末尾时,就会自动调用 Dispose,并减少计数器的值。如果计数器回到零,Dispose 调用就会传播给基本对象。
扩展方法和包装的实现如图 3 所示。为了清晰起见,此代码没有设计成线程安全。
请确保 Finalizer 确实运行到终点。.NET Framework 运行时提供一个称为“受约束的执行区域”的概念和 CriticalFinalizerObject,可以帮助您实现此目标。
我们来看一下从 C# 中使用 COM 对象模型的情形。通过创建对 COM 组件已注册类型库的引用,便可以轻松地使用任意 COM 组件。假定您有一个公开 telephone API (TAPI) 的 COM 组件,并在下列代码中使用它,看看您是否能在其中找到一个与生命期管理相关的 bug:
void foo (CTAPIApplication TAPIApplication){ string s = TAPIApplication.Ports[0].SpeechListener.GetName(); }
其中的 bug 是,Ports 集合是从对象模型中获取的,但没有调用其 ReleaseComObject 方法。SpeechListener 同样如此。您可能会争辩,假定 foo 应该释放 ComObject 是不安全的,您说得没错;从以上的代码还不清楚应该由谁释放对象以及何时释放。那您能做些什么呢?
解决方案依然是使用 AddRef 方法,但您需要修改 RefCounted 类,懂得它如果是一个 ComObject,就必须调用 ReleaseComObject。图 4 显示增强的 FinalDispose 方法。此外,其中的 T :IDisposable 已从对 AddRef
public static RefCountedAddRef (this T resource)
现在,您可以将调用重新写入 telephone API,如图 5 所示。以上代码仍然不太便利,但它非常安全,因为 using 语句可确保 ReleaseComObject 确实得到调用(即使在某一点会引发异常)。添加另一个名为 LifeTimeScope 的帮助器类并更改 AddRef 的返回类型,使代码变得更易读:
void foo (CTAPIApplication TAPIApplication) { using (new LifeTimeScope()) { string s = TAPIApplication.Ports.AddRef()[0].AddRef() .SpeechListener.AddRef().GetName(); } }
图 6 中显示的 LifeTimeScope 类充当一个帮助器,对 AddRef 在 using 块内接触的所有对象包装调用 Dispose。AddRef 也得到了一些更改(参见图 7)。虽然这些都是不错的更改,您仍需亲自调用 AddRef。
您遇到的最常见问题是应用程序由于事件订阅而泄漏内存。例如,您可能编写一个 Windows 窗体用户控件,在其构造函数中订阅 NetworkChange 事件,如下所示:
NetworkChange.NetworkAvailabilityChanged += new NetworkAvailabilityChangedEventHandler(ctrl_NetAvailChangedHandler);
用户控件和 NetworkAvailabilityChanged 静态类相互持有双向强引用。因为 NetworkAvailabilityChanged 是一个静态根引用,所以在事件处理程序被删除之前用户控件不会从内存中得到释放。
如果不从超过其引用对象生存期(此处为 NetworkAvailabilityChanged 和它引用的用户控件)的对象取消订阅事件,就会自动造成内存泄漏。这是一个常见的问题,并且适用于各种各样的 Observer 模式情形,包括数据绑定。
.NET Framework 2.0 文档中指出,您应该在对象的 Dispose 方法中取消订阅事件。确保 Dispose 得到调用也是一个不错的想法。这是因为在 .NET Framework 2.0 环境中,事件是需要您关注的资源。不过,在 .NET Framework 3.0 中,您可以使用一个基于 WeakEvent 模式的解决方案。
最后,另一种确保析构非托管资源的方法是使用 HandleCollector 类,它帮助您不定时地运行 GC.Collect。图 8 提供了一个创建 HandleCollector 实例的示例,其中提供三个参数:Handle Name (string)、Initial Threshold (int) 和 Maximum Threshold (int)。起始阈值是垃圾收集器可以开始执行垃圾收集的点。最大阈值是垃圾收集器必须执行垃圾收集的点。
这是对 .NET 或 COM 类中生存期管理的一个简要概括。希望您现在能够认识和解决这些问题。有关详细信息,请访问“生存期管理资源”侧栏中的链接。