关于本文作者
Mickey Williams是Codev Technologies的创始人之一。Codev Technologies是一家从事位Windows程序开发者提供咨询和工具的机构。他同时也是.NET Experts的主要成员,他在此讲授.NET Framework的课程。他时常在美国和欧洲的一些研讨会上发表演讲,并且已经写了八本有关Windows程序设计方面的著作。他目前正被微软出版社邀请写作“Microsoft Visual C#”。
---------------------------------------------------------------------
在.NET的所有技术中,最具争议的恐怕是垃圾收集(Garbage Collection,GC)了。作为.NET框架中一个重要的部分,托管堆和垃圾收集机制对我们中的大部分人来说是陌生的概念。在这篇文章中将要讨论托管堆,和你将从中得到怎样的好处。
为什么要托管堆?
.NET框架包含一个托管堆,所有的.NET语言在分配引用类型对象时都要使用它。像值类型这样的轻量级对象始终分配在栈中,但是所有的类实例和数组都被生成在一个内存池中,这个内存池就是托管堆。
垃圾收集器的基本算法很简单:
● 将所有的托管内存标记为垃圾
● 寻找正被使用的内存块,并将他们标记为有效
● 释放所有没有被使用的内存块
● 整理堆以减少碎片
托管堆优化
看上去似乎很简单,但是垃圾收集器实际采用的步骤和堆管理系统的其他部分并非微不足道,其中常常涉及为提高性能而作的优化设计。举例来说,垃圾收集遍历整个内存池具有很高的开销。然而,研究表明大部分在托管堆上分配的对象只有很短的生存期,因此堆被分成三个段,称作generations。新分配的对象被放在generation 0中。这个generation是最先被回收的——在这个generation中最有可能找到不再使用的内存,由于它的尺寸很小(小到足以放进处理器的L2 cache中),因此在它里面的回收将是最快和最高效的。
托管堆的另外一种优化操作与locality of reference规则有关。该规则表明,一起分配的对象经常被一起使用。如果对象们在堆中位置很紧凑的话,高速缓存的性能将会得到提高。由于托管堆的天性,对象们总是被分配在连续的地址上,托管堆总是保持紧凑,结果使得对象们始终彼此靠近,永远不会分得很远。这一点与标准堆提供的非托管代码形成了鲜明的对比,在标准堆中,堆很容易变成碎片,而且一起分配的对象经常分得很远。
还有一种优化是与大对象有关的。通常,大对象具有很长的生存期。当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,这部分堆永远不会被整理。因为移动大对象所带来的开销超过了整理这部分堆所能提高的性能。
关于外部资源(External Resources)的问题
垃圾收集器能够有效地管理从托管堆中释放的资源,但是资源回收操作只有在内存紧张而触发一个回收动作时才执行。那么,类是怎样来管理像数据库连接或者窗口句柄这样有限的资源的呢?等待,直到垃圾回收被触发之后再清理数据库连接或者文件句柄并不是一个好方法,这会严重降低系统的性能。
所有拥有外部资源的类,在这些资源已经不再用到的时候,都应当执行Close或者Dispose方法。从Beta2(译注:本文中所有的Beta2均是指.NET Framework Beta2,不再特别注明)开始,Dispose模式通过IDisposable接口来实现。这将在本文的后续部分讨论。
需要清理外部资源的类还应当实现一个终止操作(finalizer)。在C#中,创建终止操作的首选方式是在析构函数中实现,而在Framework层,终止操作的实现则是通过重载System.Object.Finalize 方法。以下两种实现终止操作的方法是等效的:
~OverdueBookLocator()
{
Dispose(false);
}
和:
public void Finalize()
{
base.Finalize();
Dispose(false);
}
在C#中,同时在Finalize方法和析构函数实现终止操作将会导致错误的产生。
除非你有足够的理由,否则你不应该创建析构函数或者Finalize方法。终止操作会降低系统的性能,并且增加执行期的内存开销。同时,由于终止操作被执行的方式,你并不能保证何时一个终止操作会被执行。
内存分配和垃圾回收的细节
对GC有了一个总体印象之后,让我们来讨论关于托管堆中的分配与回收工作的细节。托管堆看起来与我们已经熟悉的C++编程中的传统的堆一点都不像。在传统的堆中,数据结构习惯于使用大块的空闲内存。在其中查找特定大小的内存块是一件很耗时的工作,尤其是当内存中充满碎片的时候。与此不同,在托管堆中,内存被组制成连续的数组,指针总是巡着已经被使用的内存和未被使用的内存之间的边界移动。当内存被分配的时候,指针只是简单地递增——由此而来的一个好处是,分配操作的效率得到了很大的提升。
当对象被分配的时候,它们一开始被放在generation 0中。当generation 0的大小快要达到它的上限的时候,一个只在generation 0中执行的回收操作被触发。由于generation 0的大小很小,因此这将是一个非常快的GC过程。这个GC过程的结果是将generation 0彻底的刷新了一遍。不再使用的对象被释放,确实正被使用的对象被整理并移入generation 1中。
当generation 1的大小随着从generation 0中移入的对象数量的增加而接近它的上限的时候,一个回收动作被触发来在generation 0和generation 1中执行GC过程。如同在generation 0中一样,不再使用的对象被释放,正在被使用的对象被整理并移入下一个generation中。大部分GC过程的主要目标是generation 0,因为在generation 0中最有可能存在大量的已不再使用的临时对象。对generation 2的回收过程具有很高的开销,并且此过程只有在generation 0和generation 1的GC过程不能释放足够的内存时才会被触发。如果对generation 2的GC过程仍然不能释放足够的内存,那么系统就会抛出OutOfMemoryException异常。
带有终止操作的对象的垃圾收集过程要稍微复杂一些。当一个带有终止操作的对象被标记为垃圾时,它并不会被立即释放。相反,它会被放置在一个终止队列(finalization queue)中,此队列为这个对象建立一个引用,来避免这个对象被回收。后台线程为队列中的每个对象执行它们各自的终止操作,并且将已经执行过终止操作的对象从终止队列中删除。只有那些已经执行过终止操作的对象才会在下一次垃圾回收过程中被从内存中删除。这样做的一个后果是,等待被终止的对象有可能在它被清除之前,被移入更高一级的generation中,从而增加它被清除的延迟时间。
需要执行终止操作的对象应当实现IDisposable接口,以便客户程序通过此接口快速执行终止动作。IDisposable接口包含一个方法——Dispose。这个被Beta2引入的接口,采用一种在Beta2之前就已经被广泛使用的模式实现。从本质上讲,一个需要终止操作的对象暴露出Dispose方法。这个方法被用来释放外部资源并抑制终止操作,就象下面这个程序片断所演示的那样:
public class OverdueBookLocator: IDisposable
{
~OverdueBookLocator()
{
InternalDispose(false);
}
public void Dispose()
{
InternalDispose(true);
}
protected void InternalDispose(bool disposing)
{
if(disposing)
{
GC.SuppressFinalize(this);
// Dispose of managed objects if disposing.
}
// free external resources here
}
}
在这里,对象可以通过两种方式被清除。第一种方式是通过IDisposable接口的Dispose方法。此方法在对象显式地结束时被客户代码调用,它调用InternalDispose(true)。在这种情况下所有的对象都被清除了。如果析构函数被调用,那么InternalDispose(false)被调用,此时只有外部资源会被释放。如果我们已经执行了终止操作,那么我们自己的对象有可能已经被释放了,此后对它们的引用有可能引起异常。
对GC.SuppressFinalize的调用会阻止垃圾收集器将对象放入终止队列中。这样做可以降低在一次GC过程中由于整理对象而引起的内存消耗,并且由于终止操作不会被调用,从而使性能得到提高。
对C#的优化
因此使用IDisposable.Dispose()来释放资源是个很好的方式,它不但可以减少一些在托管堆上进行操作的内存需求,而且能够减少必须执行终止操作的对象的数量。但是它使用起来比较麻烦,尤其是有多个临时对象被创建的时候更是如此。为了能够从IDisposable接口受益,C#客户程序应该书写象下面这样的代码:
OverdueBookLocator bookLocator = null;
try
{
bookLocator = new OverdueBookLocator();
// Use bookLocator here
Book book = bookLocator.Find("Eiffel, the Language");
.
.
.
}
finally
{
if(bookLocator != null)
{
IDisposable disp = bookLocator as IDisposable;
disp.Dispose();
}
}
finally中的代码被用来在有异常发生时作适当的清理工作。为了C#客户程序能够简单有效地使用Dispose模式,Beta2引入了using表达式。Using表达式允许你简化你的代码,因此上面的代码可以写成:
using(bookLocator = new OverdueBookLocator())
{
// Use bookLocator here
Book book = bookLocator.Find("Eiffel, the Language");
}
无论何时分配具有明确定义的生存期的类型时,你都应该使用using表达式。它能保证对IDisposable接口的适当调用,即使是在有异常发生的时候。
使用System.GC类
System.GC类用来访问被.NET framework暴露出来的垃圾回收机制。这个类包含以下一些有用的方法:
- ● GC.SuppressFinalize 这个方法在前面已经描述过了,它能够抑制终止操作。如果你已经将属于一个对象的外部资源释放了,调用这个方法来抑制此对象的终止操作的执行。
- ● GC.Collect 具有两个版本。不带参数的版本在托管堆的所有generation上执行回收动作。另一个版本带有一个整型参数,此参数指明所要进行回收操作的generation。你将很少调用这个方法,因为垃圾收集器在需要的时候会自动调用它。
- ● GC.GetGeneration 返回作为参数传入的对象所在的generation。这个方法在由于性能的原因而进行的调试和跟踪中很有作用,但是在大部分应用中作用有限。
- ● GC.GetTotalMemory 返回堆中已经被分配的内存总量。由于托管堆的工作方式,这个数字并不精确,但是如果你以true作为参数的话,还是会得到一个比较接近的近似值。这个方法在计算之前会先执行一遍回收操作。
下面是使用这些方法的一个例子:
/// <summary>
/// Displays current GC information
/// </summary>
/// <param name="generation">The generation to collect</param>
/// <param name="waitForGC">Run GC before calculating usage?</param>
public void CollectAndAudit(int generation, bool waitForGC)
{
int myGeneration = GC.GetGeneration(this);
long totalMemory = GC.GetTotalMemory(waitForGC);
Console.WriteLine("I am in generation {0}.", myGeneration);
Console.WriteLine("Memory before collection {0}.", totalMemory);
GC.Collect(generation);
Console.WriteLine("Memory after collection {0}.", totalMemory);
}
关于本文作者 Mickey Williams是Codev Technologies的创始人之一。Codev Technologies是一家从事位Windows程序开发者提供咨询和工具的机构。他同时也是.NET Experts的主要成员,他在此讲授.NET Framework的课程。他时常在美国和欧洲的一些研讨会上发表演讲,并且已经写了八本有关Windows程序设计方面的著作。他目前正被微软出版社邀请写作“Microsoft Visual C#”。