享受代码,享受人生

SOA is an integration solution. SOA is message oriented first.
The Key character of SOA is loosely coupled. SOA is enriched
by creating composite apps.
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

C#与NET实战 第4章 平台CLR 节选

Posted on 2007-08-21 22:07  idior  阅读(1579)  评论(4编辑  收藏  举报

4.7 垃圾收集器和托管堆

4.7.1 垃圾收集技术简介

在.NET语言中,析构托管对象的职责并不是由程序员来完成。值类型的析构问题容易解决,分配在当前线程栈上的对象,在退栈清空时自动销毁。而真正的问题出在引用类型身上。为了简化开发人员的任务,.NET平台提供了垃圾收集器(或简称GC),它将自动承担回收为对象分配的内存。这就是所谓的托管堆。

如果一个对象不再有别的引用,它对程序来说就是不可访问的,也就不再需要它了。GC会标记所有可访问的对象。当一个对象没有标记为可访问,那就意味着它在程序中不能被访问而且应当销毁。不过存在一个问题就是实际上GC仅在决定这样做时(一般是当程序需要内存时)才会解除分配给对象的内存。开发者可以对GC进行有限的控制。与其他语言,如C++等相比,这在程序的执行期间引入了一层不定因素。一个直接结果就是程序员感到不能拥有彻底的控制权而失望。然而在垃圾收集器的帮助下,以往经常出现的诸如内存泄露等问题确实大大减少了。

clip_image002尽管已经有GC,内存泄露还是有可能发生。实际上,开发人员可能因为保持无用对象的引用而造成内存泄露(比如没有清空的集合)。不过,.NET程序大多数常见的内存泄露都是因为没有删除非托管资源引起的。

请记住GC是CLR中的一个软件层,每个进程中都存在一个独立的实例。

4.7.2 垃圾收集算法遇到的问题

我们此处的目标不是探讨各种现存的垃圾收集算法,而是希望你认识到GC的设计人员需要面对的问题。这样,就可以更好地了解为什么下面各节所描述的算法被微软选中来实现.NET的垃圾收集机制。

你可能会以为这是一件很简单的事情,或者等对象不再被引用时释放掉不就完了。但是这种过分单纯的方法很容易导致算法失败。比如,设想有两个对象A和B,A中有一个指向B的引用,B中有一个指向A的引用,除了它们之间的交叉引用之外这两个对象没有任何其他引用。很明显,程序不再需要这两个对象,即使它们仍有引用GC也应当销毁它们。GC必须使用引用树和循环引用检测算法来解决这些问题。

除了对象的销毁之外还有另外一个问题。就是GC必须要避免堆碎片。大小不同的内存区域经过一段时间的分配和解除分配后,堆便会成为碎片。许多程序不再使用的内存空间使得整个内存很乱,导致内存的浪费。GC的一个任务就是清理堆碎片从而减少内存碎片的产生。有多个算法来完成这项任务。

最后,常识和经验告诉我们一个规则:越老的对象生命期越长,越新的对象生命期就越短。如果将这个规则用于GC的回收算法设计,就是要优先解除分配更新的对象而不是更老的对象,那么就可获得性能的提升。

.NET 2.0的GC在实现时已考虑到上述问题和经验规则。

4.7.3 .NET的GC

GC对对象的收集动作是由CLR监测到内存不足时自动引发的。微软的文档并没有介绍判断这一时机的具体算法。

术语代(generation)定义了两次收集动作的时间间隔。每个对象都属于创建它的代。此外,下面这个公式总是成立的。

{进程中代的数目} = 1 + {进程中收集动作的数目}

代是以数字的方式表现的,这个数字在每次收集动作之后都会增加,直到达到代的上限为止(目前CLR的实现中,该上限为2)。按照约定,第0代总是最“年轻”的对象,就是说刚分派到堆中的对象会成为第0代对象的一员。

这一机制的结果就是属于同一个代的对象在内存中都是紧邻存放的,如图4-4所示。

image

图4-4 对象的代

4.7.4 第一步:寻找根对象

活动对象(即不能解除分配的对象)的引用树的根是那些由静态字段引用的对象、线程栈中引用的对象以及物理地址(或物理地址的偏移量)保存在处理器的某个寄存器里的对象。

4.7.5 第二步:建立活动对象树

GC会从根对象开始建立这棵树,方法是将所有已经在树上的对象所引用的对象添加到树中,并重复这一过程直到无法再加入新的引用。所有被这棵树所引用的对象都标记为活动对象。如果一个对象已经被标记为活动的,这个算法就不会再次考虑这个对象以免在树中出现循环引用。GC从类型元数据中获得类型的定义,并根据它寻找对象中的引用。

在图4-5中,对象A和B为对象树的根,而且A和B同时引用了C。B还引用了E;C引用了F。对象D则没有标记为活动对象。

image

 

图4-5 引用对象树

4.7.6 第三步:解除分配非活动对象

GC线性遍历堆,将所有未标记为活动的对象解除分配。某些对象的Finalize()方法需被调用以便物理销毁。这个方法也称作终结器(finalizer),定义在Object类中。如果对象所属的类重写了这个方法,那么就必须在对象被销毁之前调用这个方法。调用终结器的任务由另一个专用的线程来完成,以便不重载负责对象收集的线程。因此,需要调用终结器的对象的内存分配将存在于一次收集动作执行的整个过程中进行。

在以下两种情况中GC并不需要遍历整个堆:第一种是GC已经收集到足够的内存;第二种是需要进行部分收集时。部分收集对树的遍历算法有很大影响,因为有可能老对象(第2代)引用了较新的对象(第0代或第1代)。尽管收集动作的触发频率与具体应用程序有关,但可以基于以下数量级考虑:每秒进行一次第0代不完全收集;每进行10次0代收集才进行一次第1代不完全收集;而每进行10次1代收集才进行一次完整的收集。

图4-6中展示对象D没有被标记为活动的,因此它将被销毁。

image

图4-6 解除分配非活动对象

4.7.7 第四步:清理堆碎片

GC清理堆碎片,意思是GC会将活动对象移动到堆底部,以填补上一步解除分配对象所留下的内存空洞。堆顶的地址将重新计算,而每一代的数字均增加。越老的对象越靠近堆底,而越新的对象则越靠近堆顶。进一步说,两个同时创建的活动对象,在内存中也会紧挨着存放。GC常常仅检查最新的对象,也就是说要探测的几乎总是同一个内存页,这样能获得最大限度的性能。

图4-7(继续图4-6)中,GC已经提升了代的数字。假设对象D的类没有终结器,这意味着对象D的内存已经解除分配,而且堆碎片已经清理过了(E和F已经移动并填充了D留下的内存空洞)。注意A和B所在的代并没有增加,因为它们已经是在第2代了。

 

image

图4-7 清理堆碎片

有一些不能移动的特殊对象(这些对象也称为定址对象),它们的物理地址不能被GC所修改。如果一个对象被未受保护的代码或非托管代码引用,那么可以考虑将其定址。这个问题的具体信息可参见14.2.8节。

4.7.8 第五步:重新计算托管引用所使用的物理地址

某些对象的内存地址在上一个步骤中已经改变了,接下来GC必须遍历活动对象树,并用新的物理地址更新每个对象的引用。

4.7.9 推荐做法

下面是一些从上述算法中总结出来的推荐做法。

  • 尽早释放对象,以免它们提升到更高的代中。
  • 找出所有生命期很长的对象,分析它们生命期过长的原因以便尝试减少它们的生命期。我们建议用微软提供的CLR Profiler工具来进行此项分析,也可以使用Visual Studio Team System所提供的剖析工具。
  • 只要可能,就避免在生命期较长的对象内引用生命期短的对象。
  • 避免实现终结器,以免对象推迟收集。
  • 尽早将引用设为null,特别是在调用一个大方法之前。

4.7.10 针对大对象的特殊堆

所有大小在特定临界值之下的对象都将按照前面各节所介绍的那样储存在托管堆中。不过微软没有在文档中明确这个具体临界值,我们估计它的数量级大约是20到100KB。因性能原因,大小超过这个临界值的对象会储存在一个特殊堆中。GC 不会移动这个堆中的对象。Windows内存页面的大小根据处理器不同可能是4K或者8K。在这个特殊堆中的对象总是储存在页面大小整数倍的空间中,即使对象的真实大小并不确切是页面大小的整数倍。这样会导致少许内存浪费,但一般不会对性能造成很大影响。至于多大的对象才会储存在大对象堆的具体实现对开发人员来说是透明的。

4.7.11 多线程环境下的垃圾收集

如果手动触发,GC的收集动作可以在一个应用程序的线程中执行;或者也可以在CLR决定需要垃圾收集时在CLR的线程中执行。开始收集以前,CLR必须先将其他应用程序线程挂起,以免在收集过程中出现栈的改变。为了做到这一点,现有好几种技术。我们说JIT编译器可以在代码中插入安全点(safe point),允许程序在此查看是否有收集动作等待执行。注意第0代的堆通常划分为若干部分(称为arena),每个线程负责一个,以避免对堆的并发访问带来的同步问题。最后提醒,终结器是由另一个专门的CLR线程来执行的。

4.7.12 弱引用

1. 潜在的问题

在应用程序执行期间,每个对象在任意时刻要么是活动的,即表示引用程序有指向它的引用;要么它就是非活动的。当程序释放了指向对象的最后一个引用,那么该对象就从活动状态转为非活动状态,就没有可能继续访问这个对象了。

事实上,在活动与非活动之间还存在第三种中间状态。如果对象处在这种状态,应用程序还可以访问该对象,而GC也可以随时释放它。这明显是一种矛盾的状态,因为对象还可以访问就表示它还有引用,而还有引用的话就应该不能销毁它。为了解决这个矛盾,我们需要引入弱引用(week reference)的概念。当对象是通过弱引用而引用的,就既可以被应用程序访问,又可以被GC回收。

2. 使用弱引用的原因和时机

如果满足以下所有条件,开发人员就可以使用弱引用。

  • 对象稍后可能会被使用,但是不确定。如果确定稍后一定会使用,那就应该用强引用。
  • 如果需要,对象可以重新构造出来(比如从数据库构造)。如果稍后有可能用到对象,但是无法重建它,就不能让GC把它销毁。
  • 对象占相对比较大的内存(若干KB)。如果对象十分轻量,就可以保持在内存里。不过若前两个条件适用于大量的轻量对象,也最好对每个对象都使用弱引用。

所有这些条件都是十分理论化的。实践当中我们说弱引用相当于对象的缓存。实际上这些条件对缓存中的对象来说都是完全能够满足的(我们这里讲的缓存是指概念上的缓存,而不是指某种具体实现)。

使用缓存可以看成是一种在内存消耗、处理器资源和网络带宽的使用之间自然而且自动的平衡。当缓存消耗太多内存时,就可以销毁其中一部分对象。而假如我们稍后又要访问这些对象,就需要使用处理器和带宽资源将所需的对象重新构造出来。

综上所述,如果需要实现一个缓存,我们推荐使用弱引用。

3. 如何使用弱引用

弱引用需要通过System.WeakReference类的帮助才能使用。与其进行冗长的讨论不如给出一个例子,请看以下C#代码。

例4-14

image

WeakReference类提供了一个bool IsAlive()方法,如果弱引用所指的对象已被销毁,这个方法就返回false。此外还推荐在弱引用创建之后尽快将该对象所有强引用都置为null以确保销毁它的所有强引用。

4. 短弱引用和长弱引用

WeakReference类有两个构造函数。

image

如果使用第一个构造函数,那么trackResurrection参数将默认设为false。如果这个参数设为true,那么应用程序仍能在对象的Finalize()方法调用之后,而对象的内存还没有真正发生改变(通常是指清理堆碎片时将其他对象复制到这一对象所在的内存位置)的这段时间内访问这个对象。这种情况我们称之为长弱引用。如果该参数设为false,那么一旦Finalize()方法调用完毕,引用程序就不能再访问这个对象。这种情况称为短弱引用。

尽管使用长弱引用可能会获得一些便利,但最好避免使用它们,因为长弱引用维护起来十分困难。实际上,若Finalize()方法的实现没有对长弱引用作出解释,那么它将可能导致处于无效状态的对象复苏。

4.7.13 使用System.GC类影响GC的行为

我们可以使用System.GC类的静态方法来更改或分析GC的行为。主要的目的是提升应用程序的性能。不过微软早已为优化.NET的GC投入了很大心血。所以我们推荐仅在确信可以获得性能优势的时候才使用System.GC类提供的功能。下面列出了该类的一些静态属性和方法。

  • static int MaxGeneration{ get; },该属性返回托管堆最高代的数字。默认情况下在当前版本的.NET中该属性返回2,而且在整个应用程序生命期内保持为常数。
  • static void WaitForPendingFinalizers(),该方法将挂起当前线程,直到所有等待执行的终结器全部执行完毕为止。
  • static void Collect()和static void Collect(int generation),该方法指示GC开始一次收集动作。用这个方法可能触发一次部分收集,因为GC收集第0代到第generation代之间的对象。这个generation参数不能超过MaxGeneration。如果调用没有参数的重载方法,那么垃圾收集器将收集所有各代的对象。
    调用这个方法通常都预示着不好的行为,因为开发人员希望它能解决糟糕设计所带来的内存问题(比如分配太多对象,对象之间太多引用以及内存泄露,等等)。
    不过在调用一些关键函数之前引发一次收集动作也不失为一个好主意,这样就不会让这个关键函数被内存耗尽或者意料之外的收集动作所打扰。这种情况下,推荐按照下面的顺序调用以确保获取最大限度的可用内存。

image

  • static int CollectionCount( int generation ),返回对指定的代所进行的收集次数。这个方法可以用来监测某段特定的时间内有没有发生过收集动作。
  • static int GetGeneration(object obj)和static int GetGeneration( WeakReference wo),返回由强引用或弱引用所引对象的代号。
  • static void AddMemoryPressure(long pressure)和static void RemoveMemoryPressure (long pressure)提供这两个方法是因为GC的算法实际上并没有考虑到非托管内存。想象一下,假如有32个Bitmap类的实例,每个实例占32字节;而每个实例又含有一个指向6MB大小的位图的引用。如果不用这两个方法,GC会认为只分配了32×32字节内存,因而可能不会觉得有必要进行一次收集动作。比较好的做法是,如果一个类需要维护较大块的内存,那么就在它的构造函数和终结器中分别调用这两个函数。还要提到一点,就是在8.3.2节将要讨论的HandleCollector类也提供类似的服务。
  • static long GetTotalMemory(bool forceFullCollection),返回托管堆当前预计的大小,以字节为单位。可以通过指定forceFullCollection参数为true来获得更精确的结果,这样该方法就会在有收集动作时阻塞执行。当收集动作完成或者等待时间超过一定限度之后,该方法就会返回。GC完成自己的任务之后,这个函数所返回的值就会更准确。
  • static void KeepAlive(object obj),确保调用KeepAlive的方法执行期间,obj对象不会被GC销毁。KeepAlive()必须在方法体的结尾调用。你可能认为这个方法没什么用,因为它要求传入一个对象的强引用,因此在调用之前对象必定存在。实际上JIT编译器会对本地代码进行优化,使得所有本地引用变量在最后一次使用之后都置为空值。这个KeepAlive()就是简单地关闭这一优化。
  • static void SuppressFinalize(object obj),当对象作为参数传递给这个方法之后,它的Finalize()方法就不会再被GC调用。本来GC确保在进程结束之前,所有支持终结器的对象其Finalize()方法都会被调用的。
    Finalize()方法包含的代码逻辑上应当是解除对象所分配的资源。不过,由于我们无法控制调用Finalize()方法的时机,所以经常会另外创建一个专门的方法来解除资源的分配,以便在我们想调用的时候调用。通常我们用IDisposable.Dispose()方法来达到这个目的。在这个方法里应当调用SuppressFinalize()方法,因为一旦调用了Dispose(),再调用Finalize()就没有意义了。这个话题还将在11.13.1节继续讨论。
  • static void ReRegisterForFinalize(object obj),传入该方法的对象,其Finalize()方法将会在垃圾收集时被GC调用。这个方法主要在以下两种情况下使用。
    • 第一种情况:如果已经调用过SuppressFinalize()方法而我们又改变主意了(这种情况应该避免,因为这预示着不好的设计)。
    • 第二种情况:如果在Finalize()方法中调用ReRegisterForFinalize()方法,对象就能免于在GC中被销毁。这可以用来分析GC的行为。不过,若不停地调用Finalize(),程序将无法结束。因此必须考虑到在一定的条件下不调用ReRegisterForFinalize()方法,以便程序可以终止运行。此外,如果在这样的Finalize()方法中引用了其他对象,那么必须小心这些对象可能在不留意的情况下被GC销毁。实际上这种“无法销毁”的对象并不当成活动对象来考虑,因此它引用的其他对象不会在建立引用树的时候算进去。