大学IT网 - 最懂大学生的IT学习网站! QQ资料交流群:367606806
当前位置:大学IT网 > C#技巧 > c#对象销毁和垃圾回收

c#对象销毁和垃圾回收

关键词:对象销毁垃圾回收c#  阅读(4276) 赞(16)

[摘要]本文是对c#之对象销毁和垃圾回收的讲解,对学习C#编程技术有所帮助,与大家分享。

有些对象需要显示地销毁代码来释放资源,比如打开的文件资源,锁,操作系统句柄和非托管对象。在.NET中,这就是所谓的对象销毁,它通过IDisposal接口来实现。不再使用的对象所占用的内存管理,必须在某个时候回收;这个被称为无用单元收集的功能由CLR执行。

对象销毁和垃圾回收的区别在于:对象销毁通常是明确的策动;而垃圾回收完全是自动地。换句话说,程序员负责释放文件句柄,锁,以及操作系统资源;而CLR负责释放内存。

本章将讨论对象销毁和垃圾回收,还描述了C#处理销毁的一个备选方案--Finalizer及其模式。最后,我们讨论垃圾回收器和其他内存管理选项的复杂性。

对象销毁 垃圾回收
1)IDisposal接口
2) Finalizer
垃圾回收
对象销毁用于释放非托管资源 垃圾回收用于自动释放不再被引用的对象所占用的内存;并且垃圾回收什么时候执行时不可预计的
为了弥补垃圾回收执行时间的不确定性,可以在对象销毁时释放托管对象占用的内存

IDisposal,Dispose和Close

image

.NET Framework定义了一个特定的接口,类型可以使用该接口实现对象的销毁。该接口的定义如下:

public interface IDisposable
{
void Dispose();
}

C#提供了鴘语法,可以便捷的调用实现了IDisposable的对象的Dispose方法。比如:

using (FileStream fs = new FileStream ("myFile.txt", FileMode.Open))
{
// ... Write to the file ...
}

编译后的代码与下面的代码是一样的:

FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
try
{
// ... Write to the file ...
}
finally
{
if (fs != null) ((IDisposable)fs).Dispose();
}

finally语句确保了Dispose方法的调用,及时发生了异常,或者代码在try语句中提前返回。

在简单的场景中,创建自定义的可销毁的类型值需要实现IDisposable接口即可

sealed class Demo : IDisposable
{
public void Dispose()
{
// Perform cleanup / tear-down.
...
}
}

请注意,对于sealed类,上述模式非常适合。在本章后面,我们会介绍另外一种销毁对象的模式。对于非sealed类,我们强烈建议时候后面的那种销毁对象模式,否则在非sealed类的子类中,也希望实现销毁时,会发生非常诡异的问题。

对象销毁的标准语法

Framework在销毁对象的逻辑方面遵循一套规则,这些规则并不限用于.NET Framework或C#语言;这些规则的目的是定义一套便于使用的协议。这些协议如下:

  • 一旦销毁,对象不可恢复。对象不能被再次激活,调用对象的方法或者属性抛出ObjectDisposedException异常
  • 重复地调用对象的Disposal方法会导致错误
  • 如果一个可销毁对象x包含,或包装,或处理另外一个可销毁对象y,那么x的Dispose方法自动调用x的Dispose方法,除非另有指令(不销毁y)

这些规则同样也适用于我们平常创建自定义类型,尽管它并不是强制性的。没有谁能阻止你编写一个不可销毁的方法;然而,这么做,你的同事也许会用高射炮攻击你。

对于第三条规则,一个容器对象自动销毁其子对象。最好的一个例子就是,windows容器对象比如Form对着Panel。一个容器对象可能包含多个子控件,那你也不需要显示地销毁每个字对象:关闭或销毁父容器会自动关闭其子对象。另外一个例子就是如果你在DeflateStream包装了FileStream,那么销毁DeflateStream时,FileStream也会被销毁--除非你在构造器中指定了其他的指令。

Close和Stop

有一些类型除了Dispose方法之外,还定义了Close方法。Framework对于Close方法并没有保持完全一致性,但在几乎所有情况下,它可以:

  • 要么在功能上与Dispose一致
  • 或只是Dispose的一部分功能

对于后者一个典型的例子就是IDbConnecton类型,一个Closed的连接可以再次被打开;而一个Disposed的连接对象则不能。另外一个例子就是Windows程序使用ShowDialog的激活某个窗口对象:Close方法隐藏该窗口;而Dispose释放窗口所使用的资源。

有一些类定义Stop方法(比如Timer或HttpListener)。与Dipose方法一样,Stop方法可能会释放非托管资源;但是与Dispose方法不同的是,它允许重新启动。

何时销毁对象

销毁对象应该遵循的规则是“如有疑问,就销毁”。一个可以被销毁的对象--如果它可以说话--那么将会说这些内容:

“如果你结束对我的使用,那么请让我知道。如果只是简单地抛弃我,我可能会影响其他实例对象、应用程序域、计算机、网络、或者数据库”

如果对象包装了非托管资源句柄,那么经常会要求销毁,以释放句柄。例子包括Windows Form控件、文件流或网络流、网络sockets,GDI+画笔、GDI+刷子,和bitmaps。与之相反,如果一个类型是可销毁的,那么它会经常(但不总是)直接或间接地引用非托管句柄。这是由于非托管句柄对操作系统资源,网络连接,以及数据库锁之外的世界提供了一个网关(出入口),这就意味着使用这些对象时,如果不正确的销毁,那么会对外面的世界代码麻烦。

但是,遇到下面三种情形时,不要销毁对象

  • 通过静态成员或属性获取一个共享的对象
  • 如果一个对象的Dispose方法与你的期望不一样
  • 从设计的角度看,如果一个对象的Dispose方法不必要,且销毁对象给程序添加了复杂度

第一种情况很少见。多数情形都可以在System.Drawing命名空间下找到:通过静态成员或属性获取的GDI+对象(比如Brushed.Blue)就不能销毁,这是因为该实现在程序的整个生命周期中都会用到。而通过构造器得到的对象实例,比如new SolidBrush,就应该销毁,这同样适用于通过静态方法获取的实例对象(比如Font.FromHdc)。

第二种情况就比较常见。下表以System.IO和System.Data命名空间下类型举例说明

类型 销毁功能 何时销毁
MemoryStream 防止对I/O继续操作 当你需要再次读读或写流
StreamReader,
StreamWriter
清空reader/writer,并关闭底层的流 当你希望底层流保持打开时(一旦完成,你必须改为调用StreamWriter的Flush方法)
IDbConnection 释放数据库连接,并清空连接字符串 如果你需要重新打开数据库连接,你需要调用Close方法而不是Dispose方法
DataContext
(LINQ to SQL)
防止继续使用 当你需要延迟评估连接到Context的查询

第三者情况包含了System.ComponentModel命名空间下的这几个类:WebClient, StringReader, StringWriter和BackgroundWorker。这些类型有一个共同点,它们之所以是可销毁的是源于它们的基类,而不是真正的需要进行必要的清理。如果你需要在一个方法中使用这样的类型,那么在using语句中实例化它们就可以了。但是,如果实例对象需要持续一段较长的时间,并记录何时不再使用它们以销毁它们,就会给程序带来不惜要的复杂度。在这样的情况下,那么你就应该忽略销毁对象。

选择性地销毁对象

正因为IDisposable实现类可以使用using语句来实例化,因而这可能很容易导致该实现类的Dispose方法延伸至不必要的行为。比如:

public sealed class HouseManager : IDisposable
{
public void Dispose()
{
CheckTheMail();
}
...
}

想法是该类的使用者可以选择避免不必要的清理--简单地说就是不调用Dispose方法。但是,这就需要调用者知道HouseManager类Dispose方法的实现细节。及时是后续添加了必要的清理行为也破坏了规则。

public void Dispose()
{
CheckTheMail(); // Nonessential
LockTheHouse(); // Essential
}

在这种情况下,就应该使用选择性销毁模式

public sealed class HouseManager : IDisposable
{
public readonly bool CheckMailOnDispose;
public Demo (bool checkMailOnDispose)
{
CheckMailOnDispose = checkMailOnDispose;
}
public void Dispose()
{
if (CheckMailOnDispose) CheckTheMail();
LockTheHouse();
}
...
}

这样,任何情况下,调用者都可以调用Dispose--上述实现不仅简单,而且避免了特定的文档或通过反射查看Dispose的细节。这种模式在.net中也有实现。System.IO.Compression空间下的DeflateStream类中,它的构造器如下

public DeflateStream (Stream stream, CompressionMode mode, bool leaveOpen)

非必要的行为就是在销毁对象时关闭内在的流(第一个参数)。有时候,你希望内部流保持打开的同时并销毁DeflateStream以执行必要的销毁行为(清空bufferred数据)

这种模式看起来简单,然后直到Framework 4.5,它才从StreamReader和StreamWriter中脱离出来。结果却是丑陋的:StreamWriter必须暴露另外一个方法(Flush)以执行必要的清理,而不是调用Dispose方法(Framework 4.5在这两个类上公开一个构造器,以允许你保持流处于打开状态)。System.Security.Cryptography命名空间下的CryptoStream类,也遭遇了同样的问题,当需要保持内部流处于打开时你要调用FlushFinalBlock销毁对象。

销毁对象时清除字段

在一般情况下,你不要在对象的Dispose方法中清除该对象的字段。然而,销毁对象时,应该取消该对象在生命周期内所有订阅的事件。退订这些事件避免了接收到非期望的通知--同时也避免了垃圾回收器继续对该对象保持监视。

设置一个字段用以指明对象是否销毁,以便在使用者在该对象销毁后访问该对象抛出一个ObjectDisposedException,这是非常值得做的。一个好的模式就是使用一个public的制度的属性:

public bool IsDisposed { get; private set; }

尽管技术上没有必要,但是在Dispose方法清除一个对象所拥有的事件句柄(把句柄设置为null)也是非常好的一种实践。这消除了在销毁对象期间这些事件被触发的可能性。

偶尔,一个对象拥有高度秘密,比如加密密钥。在这种情况下,那么在销毁对象时清除这样的字段就非常有意义(避免被非授权组件或恶意软件发现)。System.Security.Cryptography命令空间下的SymmetricAlgorithm类就属于这种情况,因此在销毁该对象时,调用Array.Clear方法以清除加密密钥。

自动垃圾回收机制

无论一个对象是否需要Dispose方法以实现销毁对象的逻辑,在某个时刻,该对象在堆上所占用的内存空间必须释放。这一切都是由CLR通过GC自动处理. 你不需要自己释放托管内存。我们首先来看下面的代码

public void Test()
{
byte[] myArray = new byte[1000];
}

当Test方法执行时,在内存的堆上分配1000字节的一个数组;该数组被变量myArray引用,这个变量存储在变量栈上。当方法退出后,局部变量myArray就失去了存在的范畴,这也意味着没有引用指向内存堆上的数组。那么该孤立的数组,就非常适合通过垃圾回收机制进行回收。

垃圾回收机制并不会在一个对象变成孤立的对象之后就立即执行。与大街上的垃圾收集不一样,.net垃圾回收是定期执行,尽享不是按照一个估计的计划。CLR决定何时进行垃圾回收,它取决于许多因素,比如,剩余内存,已经分配的内存,上一次垃圾回收的时间。这就意味着,在一个对象被孤立后到期占用的内存被释放之间,有一个不确定的时间延迟。该延迟的范围可以从几纳秒到数天。

垃圾回收和内存占用
垃圾收集试图在执行垃圾回收的时间与程序的内存占用之间建立一个平衡。因此,程序可以占用比它们实际需要更多的内存,尤其特现在程序创建的大的临时数组。
你可以通过Windows任务管理器监视某一个进程内存的占用,或者通过编程的方式查询性能计数器来监视内存占用:
// These types are in System.Diagnostics:
string procName = Process.GetCurrentProcess().ProcessName;
using (PerformanceCounter pc = new PerformanceCounter
("Process", "Private Bytes", procName))
Console.WriteLine (pc.NextValue());
上面的代码查询内部工作组,返回你当前程序的内存占用。尤其是,该结果包含了CLR内部释放,以及把这些资源让给操作系统以供其他的进程使用。

根就是指保持对象依然处于活着的事物。如果一个对象不再直接或间接地被一个根引用,那么该对象就适合于垃圾回收。

一个跟可以是:

  • 一个正在执行的方法的局部变量或参数(或者调用栈中任意方法的局部变量或参数)
  • 一个静态变量
  • 存贮在结束队列中的一个对象

正在执行的代码可能涉及到一个已经删除的对象,因此,如果一个实例方法正在执行,那么该实例方法的对象必然按照上述方式被引用。

请注意,一组相互引用的对象的循环被视作无根的引用。换一种方式,也就是说,对象不能通过下面的箭头指向(引用)而从根获取,这也就是引用无效,因此这些对象也将被垃圾回收器处理。

image

Finalizers

在一个对象从内存释放之前,如果对象包含finalizer,那么finalizer开始运行。一个finalizer的声明类似构造器函数,但是它使用~前缀符号

class Test
{
    ~Test()
    {
        // finalizer logic ...
    }
}

(尽管与构造器的声明相似,finalizer不能被声明为public或static,也不能有参数,还不能调用其基类)

Finalizer是可能的,因为垃圾收集工作在不同的时间段。首先,垃圾回收识别没有使用的对象以删除该对象。这些待删除的对象如果没有Finalizer那么就立即删除。而那些拥有finalizer的对象会被保持存活并存在放到一个特殊的队列中。

在这一点上,当你的程序在继续执行的时候,垃圾收集也是完整的。而Finalizer线程却在你程序运行时,自动启动并在另外一个线程中并发执行,收集拥有Finalizer的对象到特殊队列,然后执行它们的终止方法。在每个对象的finalizer方法执行之前,它依然非常活跃--排序行为视作一个跟对象。而一档这些对象被移除队列,并且这些对象的fainalizer方法已经执行,那么这些对象就变成孤立的对象,会在下一阶段的垃圾回收过程中被回收。

Finalizer非常有用,但它们也有一些限制:

  • Finalizer减缓内存分配和收集(因为GC需要追踪那些Finalizer在运行)
  • Finalizer延长对象及其所引用对象的生命周期(这些对象只有在下一次垃圾回收运行过程中被真正地删除)
  • 对于一组对象,Finalizer的调用顺序是不可预测的
  • 你不能控制一个对象的finalizer何时被调用
  • 如果一个对象的finalizer被阻塞,那么其他对象不能处置(Finalized)
  • 如果程序没有卸载(unload)干净,那么finalizer会被忽略

总之,finalizer在一定程度上就好比律师--一旦有诉讼那么你确实需要他们,一般你不想使用他们,除非万不得已。如果你使用他们,那么你需要100%确保你了解他们会为你做什么。

下面是实施finalizer的一些准则:

  • 确保finalizer快速执行
  • 绝对不要在finalier中使用阻塞
  • 不要引用其他可finalizable对象
  • 不要抛出异常
在Finalizer中调用Dispose

一个流行的模式是使finalizer调用Dispose方法。这么做是有意义的,尤其是当清理工作不是紧急的,并且通过调用Dispose加速清理;那么这样的方式更多是一个优化,而不是一个必须。

下面的代码展示了该模式是如何实现的

class Test : IDisposable
{
public void Dispose() // NOT virtual
{
Dispose (true);
GC.SuppressFinalize (this); // Prevent finalizer from running.
}
protected virtual void Dispose (bool disposing)
{
if (disposing)
{
// Call Dispose() on other objects owned by this instance.
// You can reference other finalizable objects here.
// ...
}
// Release unmanaged resources owned by (just) this object.
// ...
}
˜Test()
{
Dispose (false);
}
}

Dispose方法被重载,并且接收一个bool类型参数。而没有参数的Dispose方法并没有被声明为virtual,只是在该方法内部调用了带参数的Dispose方法,且传递的参数的值为true。

带参数的Dispose方法包含了真正的处置对象的逻辑,并且它被声明为protected和virtual。这样就可以保证其子类可以添加自己的处置逻辑。参数disposing标记意味着它在Dispose方法中被正确的调用,而不是从finalizer的最后采取模式所调用。这也就表明,如果调用Dispose时,其参数disposing的值如果为false,那么该方法,在一般情况下,都会通过finalizer引用其他对象(因为,这样的对象可能自己已经被finalized,因此处于不可预料的状态)。这里面涉及的规则非常多!当disposing参数是false时,在最后采取的模式中,仍然会执行两个任务:

释放对操作系统资源的直接引用(这些引用可能是因为通过P/Invoke调用Win32 API而获取到)

删除由构造器创建的临时文件

为了使这个模式更强大,那么任何会抛出异常的代码都应包含在一个try/catch代码块中;而且任何异常,在理想状态下,都应该被记录。此外,这些记录应当今可能既简单又强大。

请注意,在无参数的Dispose方法中,我们调用了GC.SuppressFinalize方法,这会使得GC在运行时,阻止finalizer执行。从技术角度讲,这没有必要,因为Dispose方法必然会被重复调用。但是,这么做会改进性能,因为它允许对象(以及它所引用的对象)在单个循环中被垃圾回收器回收。

复活

假设一个finalizer修改了一个活的对象,使其引用了一个“垂死”对象。那么当下一次垃圾回收发生时,CLR会查看之前垂死的对象是否确实没有任何引用指向它--从而确定是否对其执行垃圾回收。这是一个高级的场景,该场景被称作复活(resurrection)。

为了证实这点,假设我们希望创建一个类管理一个临时文件。当类的实例被回收后,我们希望finalizer删除临时文件。这看起来很简单

public class TempFileRef
{
public readonly string FilePath;
public TempFileRef (string filePath) { FilePath = filePath; }

~TempFileRef() { File.Delete (FilePath); }
}

实际,上诉代码存在bug,File.Delete可能会抛出一个异常(引用缺少权限,或者文件处于使用中) 。这样的异常会导致拖垮整个程序(还会阻止其他finalizer执行)。我们可以通过一个空的catch代码块来“消化”这个异常,但是这样我们就不能获取任何可能发生的错误。 调用其他的错误报告API也不是我们所期望的,因为这么做会加重finalizer线程的负担,并且会妨碍对其他对象进行垃圾回收。 我们期望显示finalization行为简单、可靠、并快速。

一个好的解决方法是在一个静态集合中记录错误信息:

public class TempFileRef
{
static ConcurrentQueue<TempFileRef> _failedDeletions
= new ConcurrentQueue<TempFileRef>();
public readonly string FilePath;
public Exception DeletionError { get; private set; }
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch (Exception ex)
{
DeletionError = ex;
_failedDeletions.Enqueue (this); // Resurrection
}
}
}

把对象插入到静态队列_failedDeletions中,使得该对象处于引用状态,这就确保了它仍然保持活着的状态,直到该对象最终从队列中出列。

GC.ReRegisterForFinalize

一个复活对象的finalizer不会再次运行--除非你调用GC.ReRegisterForFinalize

在下面的例子中,我们试图在一个finalizer中删除一个临时文件。但是如果删除失败,我们就重新注册带对象,以使其在下一次垃圾回收执行过程中被回收。

public class TempFileRef
{
public readonly string FilePath;
int _deleteAttempt;
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch
{
if (_deleteAttempt++ < 3) GC.ReRegisterForFinalize (this);
}
}
}

如果第三次尝试失败后,finalizer会静悄悄地放弃删除临时文件。我们可以结合上一个例子增强该行为--换句话说---那就是在第三次失败后,把该对象加入到_failedDeletions队列中。

垃圾回收工作原理

标准的CLR使用标记和紧凑的GC对存储托管堆上的对象执行自动内存管理。GC可被视作一个可被追踪的垃圾回收器,在这个回收器中,它(GC)不与任何对象接触;而是被间歇性地被唤醒,然后跟踪存储在托管堆对象图,以确定哪些对象可以被视为垃圾,进而对这些对象执行垃圾回收。

当(通过new关键字)执行内存分配是,或当已经分配的内存达到了某一阀值,亦或当应用程序占用的内存减少时,GC启动一个垃圾收集。这个过程也可以通过手动调用System.GC.Collect方法启动。在一个垃圾回收过程中,所有线程都可能被冻结。

GC从根对象引用开始,查找贵根对象对应的整个对象图,然后把所有的对象标记为可访问的对象。一旦这个过程完成,所有被标记为不再使用的对象,将被垃圾回收器回收。

没有finalizer的不再使用的对象立即被处置;而拥有finalizer的不再使用对象将会在GC完成之后,在finalizer线程上排队以等待处理。这些对象(在finalizer线程上排队的对象)会在下一次垃圾回收过程中被回收(除非它们又复活了)。

而那些剩余的“活”对象(还需要使用的对象),被移动到堆叠开始位置(压缩),这样以腾出更多空间容纳更多对象。改压缩过程有两个目的:其一是避免了内存碎片,这样就使得在为新对象分配空间后,GC只需使用简单的策略即可,因为新的对象总是分配在堆的尾部。其二就是避免了维护一个非常耗时的内存片段列表任务。

在执行完一次垃圾回收之后,为新对象分配内存空间时,如果没有足够的空间可以使用,操作系统不能确保更多的内存使用时,抛出OutOfMemoryException。

优化技术

GC引入了各种优化技术来减少垃圾回收的时间。

通用垃圾回收

最重要的优化就是垃圾回收时通用的。其优点是:尽管快速分配和处置大量对象,某些对象是长存内存,因此他们不需要被垃圾回收追踪。

基本上,GC把托管堆分为三类:Gen0是在堆上刚刚分配的对象;Gen1经过一次垃圾回收后仍然存活的对象;剩余的为Gen2。

CLR限制Gen0的大小(在32位CLR中,最大16MB,一般大小为数百KB到几MB)。当Gen0空间耗尽,GC便触发一个Gen0垃圾回收--该垃圾回收发生非常频繁。对于Gen1,GC也应用了一个相似的大小限制,因为Gen1垃圾回收也是相当频繁并且快速完成。Gen2包含了所有类型的垃圾回收,然而,发生在Gen2的垃圾回收执行时间长,并且也不会经常发生。下图展示了一个完全垃圾回收:

image

如果真要列出一组大概的数字,那么Gen0垃圾回收执行耗费少于1毫秒,在一个应用程序中一般不会被注意到。而全垃圾回收,如果程序包含大的图形对象,则可能会耗费100毫秒。执行时间受诸多因素影响二次可能会有不同,尤其是Gen2的垃圾回收,它的尺寸是没有限定的。

段时间存活的对象,如果使用GC会非常有效。比如下面示例代码中的StringBuilder,就会很快地被发生在Gen0上的垃圾回收所回收。

string Foo()
{
var sb1 = new StringBuilder ("test");
sb1.Append ("...");
var sb2 = new StringBuilder ("test");
sb2.Append (sb1.ToString());
return sb2.ToString();
}

大对象堆

GC为大对象(大小超过85,000字节)使用单独的堆。这就避免了大量消耗Gen0堆。因为在Gen0上没有大对象,那么就不会出现分配一组16MB的对象(这些对象由大对象组成)之后,马上触发垃圾回收。

大对象堆不适合于压缩,这是因为发生垃圾回收时,移动内存大块的代价非常高。如果这么做,会带来下面两个后果:

  • 内存分配低效,这是因为GC不能总是把对象分配在堆的尾部,它还必须查看中间的空隙,那么这就要求维护一个空白内存块链表。
  • 大对象堆适合于片段化。这意味着冻结一个对象,会在大对象堆上生成一个空洞,这个空洞很难在再被填充。比如,一个空洞留下了86000字节的空间,那么这个空间就只能被一个85000字节或86000自己的对象填充(除非与另外的一个空洞连接在一起,形成更大的空间)

大对象堆还是非通用的堆,大对象堆上的所有对象被视作Gen2

并发回收和后台回收

GC在执行垃圾回收时,必须释放(阻塞)你的程序所使用的线程。在这个期间包含了Gen0发生的时间和Gen1发生的时间。

由于执行Gen2回收可能占用较长的时间,因此GC会在你的程序运行时,堆Gen2回收进行特殊的尝试。该优化技术仅应用于工作站的CLR平台,一般应用于windows桌面系统(以及所有运行独立程序的Windows)。原因是由于阻塞线程进行垃圾回收所带来的延迟对于没有用户接口的服务器应用程序一般不会带来问题。

这种对于工作站的优化历史上称之为并发回收。从CLR4.0kaishi ,它发生了革新并重命名为后台回收。后台回收移除了一个限制,由此,并发回收不再是并发的,如果Gen0部分已经执行完而Gen2回收还正在执行。这就意味着,从CLR4.0开始,持续分配内存的应用程序会更加敏感。

GC通知(适用于服务端CLR)

从Framework 3.5 SP1开始,服务器版本的CLR在一个全GC将要发生时,向你发送通知。你可以在服务器池配置中配置该特性:在一个垃圾回收执行之前,把请求转向到另外一台服务器。然后你立即调查垃圾回收,并等待其完成,在垃圾回收执行完成之后,把请求转回到当前服务器。

通过调用GC.RegisterForFullGCNotification,可以启用GC通知。然后,启动另外一个线程,该线程首先调用GC.WaitForFullGCApproach,当该方法返回GCNotificationStatus指明垃圾回收已经进入等待执行的队列,那么你就可以把请求转向到其他的服务器,然后手执行一次手动垃圾回收(见下节)。然后,你调用GC.WaitForFullGCComplete方法,当该方法返回时,GC完成;那么该服务器就可以开始再次接收请求。然后在有需要的时候,你可以再次执行上述整个过程。

强制垃圾回收

通过调用GC.Collect方法,你可以随时手动强制执行一次垃圾回收。调用GC.Collect没有提供任何参数会执行一次完全垃圾回收。如果你提供一个整数类型的参数,那么执行对应的垃圾回收。比如GC.Collect(0)执行Gen0垃圾回收。

// Forces a collection of all generations from 0 through Generation.
//
public static void Collect(int generation) {
    Collect(generation, GCCollectionMode.Default)
}


// Garbage Collect all generations.
//
[System.Security.SecuritySafeCritical]  // auto-generated
public static void Collect() {
    //-1 says to GC all generations.
    _Collect(-1, (int)InternalGCCollectionMode.Blocking);
}

一般地,允许GC去决定何时执行垃圾回收可以得到最好的性能;这是因为强制垃圾回收会把Gen0的对象不必要地推送到Gen1(Gen1不必要地推送到Gen2),从而影响性能。这还会扰乱GC自身的调优能力--在程序运行时,GC动态地调整每种垃圾回收的临界值以最大限度地提高性能。

但是,也有另外。最常见的可以执行手动垃圾回收的场景就是当一个应用程序进入休眠状态,比如执行日常工作的windows服务。这样的程序可能使用了System.Timters.Timer以每隔24小时触发一次行为。当该行为完成之后,在接着的24小时之内没有任何代码会执行,那就意味着,在这段时间内,不会分配任何内存,因此GC就没有机会被激活。服务在执行时所消耗的任何内存,在接着的24小时都会被持续占用--甚至是空对象图。那么解决方法就是在日常的行为完成之后调用GC.Collect()方法进行垃圾回收。

为了回收由于finalizer延迟回收的对象,你可以添加一行额外的代码以调用WaitForPendingFinalizers,然后再调用一次垃圾回收

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
另外一种调用GC.Collect方法的场景是当你测试一个有Finazlier的类时。
内存压力

.NET运行时基于一些列因素决定何时启动垃圾回收,其中一个因素就是机器内存的总负载。 如果程序使用了非托管内存,那么运行时会对其内存的使用情况持盲目地乐观的态度,这是因为CLR之关心托管内存。通过告诉CLR已经分配了特定量的非托管内存内存,来减轻CLR的盲目性;调用CG.AddMemoryPresure方法可以完成该目的。如果取消该行为(当所占用的托管内存已经被释放),那么可以调用GC.RemoveMemoryPressure。

管理内存泄漏

在非托管语言中,比如C++,你必须记住当对象不再使用时,应手动地释放内存;否则,将导致内存泄漏。在托管世界中,内存泄漏这种错误时不可能发生的,这归功于CLR的自动垃圾回收。

尽管如此,大型的和复杂的.NET程序也会出现内存泄漏;只不错内存泄漏的方式比较温和,但具有相同的症状和结果:在程序的生命周期内,它消耗越来越多的内存,到最后导致程序重启。好消息是,托管内存泄漏通常容易诊断和预防。

托管内存泄漏是由不再使用的活对象引起,这些对象之所以存活是凭借不再使用引用或者被遗忘的引用。一种常见的例子就是事件处理器--它们堆目标对象保存了一个引用(除非目标是静态方法)。比如,下面的类:

class Host
{
public event EventHandler Click;
}
class Client
{
Host _host;
public Client (Host host)
{
_host = host;
_host.Click += HostClicked;
}
void HostClicked (object sender, EventArgs e) { ... }
}

下面的测试类包含1个方法实例化了1000个Client对象

class Test
{
static Host _host = new Host();
public static void CreateClients()
{
Client[] clients = Enumerable.Range (0, 1000)
.Select (i => new Client (_host))
.ToArray();
// Do something with clients ...
}
}

你可能会认为,当CeateClients方法结束后,这个1000个Client对象理解适用于垃圾回收。很不幸,每个Client对象都包含一个引用:_host对象,并且该对象的Click事件引用每个Client实例。 如果Click事件不触发,那么就不会引起注意,或者HostClicked方法不做任何事情也不会引起注意。

解决这个问题的一种方式就是使Client类实现接口IDisposable,并且在dispose方法中,移除时间处理器

public void Dispose() { _host.Click -= HostClicked; }

Client实例的使用者,在使用完实例之后,调用Client类的dispose方法处置该实例

Array.ForEach (clients, c => c.Dispose());

下面的对比展示两种方式的差别

CLR Profiler
Index
实现IDisposable 未实现IDisposable
Time line image image
Heap statistics image image
GC Generatation Sizes image image
计时器

不要忘记timmers也会引起内存泄漏。根据计时器的种类,会引发两种不同的内存泄漏。首先我们来看System.Timers命名空间下的计时器。在下面的例子中,Foo类每秒调用一次tmr_Elapsed方法

using System.Timers;
class Foo
{
Timer _timer;
Foo()
{
_timer = new System.Timers.Timer { Interval = 1000 };
_timer.Elapsed += tmr_Elapsed;
_timer.Start();
}
void tmr_Elapsed (object sender, ElapsedEventArgs e) { ... }
}

很不幸,Foo的实例决定不会被回收。原因在于.NET Framework本身持有对计活动的时器的引用,从而导致.net framework会触发这些计时器的Elapsed事件。因此

  • .NET Framework将使_timer处于活动状态
  • 通过tmr_Elapsed事件处理器,_timer将使Foo实现处于活动状态

当你意识到Timer实现了IDisposable接口之后,解决的方法就在也明显不过了。处置Timer实例以停止计时器,并确保.NET Framework不再引用该计时器对象。

class Foo : IDisposable
{
...
public void Dispose() { _timer.Dispose(); }
}

相对于我们上面讨论的内容,WPF和Windows窗体的计时器表现出完全相同的方式。

然而,System.Threading命名空间下的计时器确是一个特例。.NET Framework没有引用活动线程计时器;想法,却直接引用回调代理。这就意味着如果你忘记处置线程计时器,那么finalizer会自动触发并停止计时器然后处置该计时器。比如:

static void Main()
{
var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000);
GC.Collect();
System.Threading.Thread.Sleep (10000); // Wait 10 seconds
}
static void TimerTick (object notUsed) { Console.WriteLine ("tick"); }

如果上面的代码编译为发布模式,那么计时器会被回收,并且在它再次触发之前被处置(finalized)。同样地,我们可以在计时器结束后通过处置该计数器以修复这个问题

using (var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000))
{
GC.Collect();
System.Threading.Thread.Sleep (10000); // Wait 10 seconds
}

using语句会隐式地调用tmr.Dispose方法,以确保tmr变量确实处于“使用(活动状态)”;因此不会在代码块结束之前被当作是死对象。讽刺的是,调用Dispose方法实际上使对象存活的时间更长了。

诊断内存泄漏

避免托管内存泄漏的最简单方式就是在编写应用程序时就添加监控内存占用。你可以在程序中通过调用下面的代码来获取当前内存的使用情况

long memoryUsed = GC.GetTotalMemory (true);

如果你采取测试驱动开发,那么你可以使用单元测试判断是否按照期望释放了内存。入股这样的判断失败,那么接着你就应该检查你最近对程序所作的修改。

如果你已经有一个大型程序,并且该程序存在托管内存泄漏问题,那么你应该使用windgb.exe工具来帮助你解决问题。当然你还可以使用其他的图形化工具,比如CLR Profiler, SciTech的Memory Profiler,或者Red Gate的ANTS Memory Profiler。

弱引用

有时候,引用一个对GC而言是“隐形”的对象,并且对象保持活动状态,这非常有用。这既是弱引用,它由System.WeakReference类实现。使用WeakReference,使用其构造器函数并传入目标对象。

var sb = new StringBuilder ("this is a test");
var weak = new WeakReference (sb);
Console.WriteLine (weak.Target); // This is a test

如果目标对象仅仅由一个或多个弱引用所引用,那么GC会把其加入到垃圾回收队列中。如果目的对象被回收,那么WeakReference的Target属相则为NULL。

var weak = new WeakReference(new StringBuilder("weak"))
Console.WriteLine(weak.Target); // weak
GC.Collect();
Console.WriteLine(weak.Target == null); // (true)

为了避免目标对象在测试其为null和使用目标对象之间被回收,把目标对象分配给一个局部变量

var weak = new WeakReference (new StringBuilder ("weak"));
var sb = (StringBuilder) weak.Target;
if (sb != null) { /* Do something with sb */ }

一旦目标对象分配给一个局部变量,那么目的对象就有了一个强类型根对象,从而在局部变量使用期间不会被回收。

下面例子中的类通过弱引用追踪所有被实例化的Widget对象,从而使这些实例不会被回收

class Widget
{
static List<WeakReference> _allWidgets = new List<WeakReference>();
public readonly string Name;
public Widget (string name)
{
Name = name;
_allWidgets.Add (new WeakReference (this));
}
public static void ListAllWidgets()
{
foreach (WeakReference weak in _allWidgets)
{
Widget w = (Widget)weak.Target;
if (w != null) Console.WriteLine (w.Name);
}
}
}

这样一个系统的唯一缺点就是,静态列表会随着时间推移而增加,逐渐累积对应null对象的弱引用。因此,你需要自己实现一些清理策略。

弱引用和缓存

使用弱引用的目的之一是为了缓存大对象图。通过弱引用,使得耗费内存的数据可以进行简要的缓存而不是造成内存的大量占用。

_weakCache = new WeakReference (...); // _weakCache is a field
...
var cache = _weakCache.Target;
if (cache == null) { /* Re-create cache & assign it to _weakCache */ }

在实际上,该策略只会发挥一半的作用,这是因为你不能控制GC何时运行,并且也不能控制GC会会执行哪一类回收。尤其是,当你的缓存是在Gen0中,那么这类内存会在微妙级别类被回收。因此,至少,你需要使用两类缓存,通过它们,首先你拥有一个强类型,然后不时地把该强类型转换成弱类型。

弱引用和事件

在前面的章节中,我们看到事件是如何引起内存泄漏。而且解决这种内存泄漏的最简单方法是避免时间订阅,或者对为订阅事件的对象实现Dispose方法。此外,弱引用也提供了另外一种解决方案。

假设一个带来对其目标持有一个弱引用。那么这样的一个代理并不会使其目标为活动状态,除非这些目标对象有独立的引用。当然,这并不会阻止一个被触发的代理,在目标对象进入回收队列之后但在GC开始对该目标对象执行回收前的时间段中,击中一个未被引用的目标。为了该方法高效,你的代码必须非常稳定。下面的代码就是就是采用这种方式的具体实现:

public class WeakDelegate<TDelegate> where TDelegate : class
{
class MethodTarget
{
public readonly WeakReference Reference;
public readonly MethodInfo Method;
public MethodTarget (Delegate d)
{
Reference = new WeakReference (d.Target);
Method = d.Method;
}
}
List<MethodTarget> _targets = new List<MethodTarget>();
public WeakDelegate()
{
if (!typeof (TDelegate).IsSubclassOf (typeof (Delegate)))
throw new InvalidOperationException
("TDelegate must be a delegate type");
}
public void Combine (TDelegate target)
{
if (target == null) return;
foreach (Delegate d in (target as Delegate).GetInvocationList())
_targets.Add (new MethodTarget (d));
}
public void Remove (TDelegate target)
{
if (target == null) return;
foreach (Delegate d in (target as Delegate).GetInvocationList())
{
MethodTarget mt = _targets.Find (w =>
d.Target.Equals (w.Reference.Target) &&
d.Method.MethodHandle.Equals (w.Method.MethodHandle));
if (mt != null) _targets.Remove (mt);
}
}
public TDelegate Target
{
get
{
var deadRefs = new List<MethodTarget>();
Delegate combinedTarget = null;
foreach (MethodTarget mt in _targets.ToArray())
{
WeakReference target = mt.Reference;
if (target != null && target.IsAlive)
{
var newDelegate = Delegate.CreateDelegate (
typeof (TDelegate), mt.Reference.Target, mt.Method);
combinedTarget = Delegate.Combine (combinedTarget, newDelegate);
}
else
deadRefs.Add (mt);
}
foreach (MethodTarget mt in deadRefs) // Remove dead references
_targets.Remove (mt); // from _targets.
return combinedTarget as TDelegate;
}
set
{
_targets.Clear();
Combine (value);
}
}
}

上述代码演示了许多C#和CLR的有趣的地方。首先,我们在构造器中检查了TDelegate是一个代理类型。这是因为C#本身的限制--因为下面的语句不符合C#的语法

... where TDelegate : Delegate // Compiler doesn't allow this

由于必须要进行类型限制,所以我们在构造器中执行运行时检查。

在Combine方法和Remove方法中,我们执行了引用转换,通过as运算符(而没有使用更常见的转换符)把target对象转换成Delegate类型。这是由于C#不允许转换符使用类型参数--因为它不能分清这是一个自定义的转换还是一个引用抓换(下面的代码不能拖过编译)。

foreach(Delegate d in ((Delegate)target).GetInvocationList())
                _targets.Add(new MethodTarget(d));

当调用GetInvocationList,由于这些方法可能被一个多播代理调用,多播代理就是一个代理有多余一个的方法接收。

对于Target属性,我们使其为一个多播代理--通过一个弱引用包含所有的代理引用,从而使其目标对象保持活动。然后我们清楚剩余的死引用,这样可以避免_targets列表无限制的增长。下面的代码演示了如何使用我们上面创建的实现了事件的代理类:

public class Foo
{
WeakDelegate<EventHandler> _click = new WeakDelegate<EventHandler>();
public event EventHandler Click
{
add { _click.Combine (value); } remove { _click.Remove (value); }
}
protected virtual void OnClick (EventArgs e)
{
EventHandler target = _click.Target;
if (target != null) target (this, e);
}
}

请注意,在触发事件时,在检查和调用之前,我们把_click.Target对象赋值给一个临时变量。这就避免了目标对象被GC回收的可能性。



相关评论