[经典文章翻译]垃圾收集: 在Microsoft .NET Framework中的自动化内存管理 - 第一部分

原作者: Jeffrey Richter

 

第二部分的翻译: [经典文章翻译]垃圾收集: 在Microsoft .NET Framework中的自动化内存管理 - 第二部分

 

概述:

在Microsoft .NET公共语言运行时环境中的垃圾收集把跟踪内存使用并在合适的时候释放内存的责任从开发人员身上彻底地卸了下来. 然而, 你可能会想要了解它是如何做到的. 这篇文章的上半部分揭示了资源是如何被分配和管理的, 然后对垃圾收集算法是如何工作的给出了一个step-by-step的描述. 我们还要讨论在垃圾收集器决定释放资源内存的时候, 如何允许资源恰当地被释放的方式, 以及如何在一个对象释放的时候强制对象来进行清理动作.

 

为你的应用程序实现恰当的资源管理, 这可能会是一项很难的, 很繁琐的任务. 它可以把你的注意力从你真正想要解决的问题上分散开. 要是有一种机制能够简化令人厌恶的内存管理任务该有多好? 幸运的是, 在.NET中, 有垃圾收集器(GC).

 

让我们稍等一下. 任何一个程序都要使用某种资源, 内存缓冲池, 屏幕控件, 网络连接, 数据库资源, 等等. 事实上, 在面向对象的环境下, 他们是标识某种你的应用程序可以使用的资源的类型. 要使用这些资源的任意一种, 都需要分配能够代表这种类型的内存. 访问资源需要的步骤如下:

  1. 为某种代表着资源的类型分配内存.
  2. 初始化内存来设置资源的初始状态, 是的资源可用.
  3. 通过该类型的实例成员来访问资源.
  4. 破坏资源的状态, 清理资源.
  5. 释放内存

这个简单的范式一直都是程序错误的主要来源. 想想看, 有多少次你曾经在使用资源后忘记了释放内存呢? 或者是想要在你已经释放了内存之后又想访问它呢?

 

这两个bug比任何应用程序的bug都要糟糕, 因为后果以及后果所发生的时间都是不可预计的. 对于其他的bug, 当你看见你的程序表现不正常是, 你会马上修复掉. 但是这两种bug会引起资源泄露和对象崩溃, 让你的程序在不可预知的时间发生不可预知的错误. 事实上, 已经有很多工具是为了帮助开发人员定位这两种错误而特别制作的了.

 

当我检查GC的时候, 你会发现它完全地把开发人员从监控内存使用和释放内存这样的任务上解放出来了. 然而, 垃圾收集器对于内存中的类型所代表的资源一无所知. 这意味着, 垃圾收集器不可能知道何时开始修改资源的状态. 为了恰当的释放资源, 研发人员必须书写知道如何恰当地清除一个资源的代码. 在.NET Framework中, 研发人员在Close, Dispose, 或者Finalize方法中书写这样的代码, 我们稍后会描述这些方法的. 正如你后面会看到的, 垃圾收集器会自动地决定何时执行这些方法.
     

还有, 许多代表着资源的类型并不要求有任何的清理动作. 比如说, 一个长方形资源可以通过简单地破坏掉内存中代表着这个类型的左右, 宽高数据来完全地清理. 相反地, 代表着一个文件资源或者网络连接资源的类型会在这项资源将被破坏的时候显式地执行清理代码. 我会解释所有的这一切是如何恰当地做到的. 现在, 让我们看一下内存是如何分配和资源是如何初始化的吧.

 

资源分配

==============

Microsoft .NET Common language runtime要求所有的资源够在托管堆上分配. 除了永远都不需要你来从托管堆上释放对象外, 这跟C-runtime heap差不多. 对象会在应用程序不再需要它们的时候自动地释放. 当然, 这会引发出一个问题: 托管堆怎么知道何时一个对象不再被应用程序需要了呢? 我马上就回答这个问题.

 

GC现在有一些算法. 每一种算法都经过良好的调整, 以便于为特定环境下提供最好的性能. 这篇文章集中注意力在common language runtime使用的GC算法上. 让我们从基本概念开始.

 

当进程被初始化的时候, runtime会保留一块连续的内存, 这块内存起始时没有任何在其上的存储分配. 这个块地址空间就是托管堆. 这个堆还维护着一个指针, 我们称这个指针为NextObjPtr. 这个指针指示着堆内可以分配给下一个对象的地址. 初始时, NextObjPtr 指针被设置到那块保留下来的连续内存的起始地址.

 

应用程序使用new操作符创建一个对象. 这个操作符首先确保新对象所需要的字节在保留区域中放得下. 如果对象大小合适, 那么NextObjPtr 指针会指向堆中的对象, 这个对象的构造函数会被调用, new操作符会返回对象的地址.

 

Bb985010.gcifig01(en-us,MSDN.10)

图示1

 

在这个点上, NextObjPtr 会增长, 越过这个对象, 以便于它继续指向堆中下一个对象可以被放置的地方. 图示1展现了一个有三个对象A, B, C组成的托管堆. 下一个将要被分配的对象会被放在NextObjPtr 所指向的地方(紧挨着C对象).

 

现在让我们看一看C-runtime heap 是如何分配对象的吧. 在C-runtime heap 中, 为一个对象分配内存需要遍历一个数据结构的链表. 一旦足够大的内存块被发现, 这个快就会被拆分, 然后链表中的指针必须被修改来保持正确. 对于托管堆来讲, 分配一个对象就简单地意味着给一个指针增加一个值. 相比较而言, 这个真的特别快. 事实上, 在托管堆上分配一个对象基本上跟在栈上分配一个对象的速度一样快!

 

目前为止, 听起来托管堆远比C-runtime heap 要卓越得多, 因为它的速度和实现. 当然, 托管堆能有这些好处因为它进行了一项非常大胆的假设: 地址空间和存储空间是无限的. 这个假设有点荒谬. 如果说托管堆要进行这样的一个假设的话, 那么它一定是依靠了某种机制. 这个机制就叫做garbage collector. 让我们来看一下它是如何工作的吧.

 

当应用程序调用new操作符来创建对象的时候, 可能在地址空间里没有剩下足够大小的区域来分配这个对象了. 托管堆可以通过在NextObjPtr指针上加上新对象的大小来检测这一点. 如果NextObjPtr指针超过了这块内存区域的话, 那么堆就满了, 并且必须被执行一次垃圾收集.

 

现实中, 垃圾收集会在第0代装满了的时候发生. 简单地说, 一代就是一个由垃圾收集器实现的用于提高性能的一种机制. 这个想法就是, 新创建的对象是年青一代的一部分, 应用程序生存期早期创建的对象属于老的一代. 把对象按代来分能够允许垃圾收集器回收特定代的对象而不是回收所有的托管堆中的对象. 垃圾收集器将在本文的第二个部分中进行讨论.

 

垃圾收集算法

=============

垃圾收集器会检查堆中是否有任何对象已经不再被应用程序使用. 如果这样的对象存在, 那么这些对象的内存就可以被回收了(如果堆中没有更多内存了, 那么new操作符会抛出OutOfMemoryException). 那么垃圾收集器是如何知道应用程序是否还要使用一个对象呢? 正如你猜测到的, 这不是一个可以简简单单就回答的了的问题.

 

任何一个应用程序都有一系列的root. 鉴别存储位置的root, 指向托管堆中的对象或者是指向被设置为null的对象们. 比如说, 所有的应用程序的全局对象和静态对象的指针都被认为是一个应用程序的root. 另外, 线程栈上的任何局部变量或参数对象的指针也被认为是应用程序的roots. 最后, 任何包含指向托管堆上对象的CPU寄存器也被认为是应用程序root的一部分. 处于活动状态的root是有just-in-time (JIT) 编译器和common language runtime维护的, 并且它们被设置为可以被垃圾收集算法访问.

 

当垃圾收集器开始运行的时候, 它假设堆中的所有对象都是垃圾. 也就是说, 它假设应用程序的roots没有一个引用到了堆中的对象. 现在, 垃圾收集开始遍历roots, 并构建一幅包含所有从roots可以访问的到的对象的图. 比如说, 垃圾收集器会通过一个全局变量找到一个堆上的对象.

 

图示2 展现了有几个已经分配了的对象的对, 其中应用程序的roots直接引用到了对象A,C,D和F. 所有的这些对象都变为图的一部分. 当添加对象D的时候, 垃圾收集器注意到这个对象引用到了对象H, 然后对象H也被加入到了图中. 垃圾收集器继续递归地遍历所有够得着的对象.

Bb985010.gcifig02(en-us,MSDN.10)

图示2: 堆中分配的对象.

 

一旦这部分图构建结束, 垃圾收集器会检查下一个root并再次遍历对象. 当垃圾收集器从各一个对象走到另一个对象的时候, 如果它想要添加一个对象, 但是这个对象它以前添加过了, 那么垃圾收集器就会在那个路径上停下来. 这样做有两个目的. 第一, 它明显地提高了性能, 因为它不用遍历对象两次. 第二, 当你的程序中有环状引用的时候它能防止垃圾收集器进入无限循环.
     

一旦所有的roots都被检查过了, 垃圾收集器的图就包含了所有通过某种途径可以被应用程序的roots访问得到的对象. 任何没有在这幅图中的对象都是应用程序访问不到的了, 所以, 他们可以被认为是垃圾. 垃圾收集器之后会线性地遍历整个堆, 寻找垃圾对象的连续的内存块(现在可以认为是这些地方是空闲内存空间了). 垃圾收集器然后会移动非垃圾对象到内存的低地址空间(使用你已经知道多年的标准的memcpy函数), 消除掉堆中的碎片. 当然, 移动内存中的对象会使得所有指向这些对象的指针失效. 所以, 垃圾收集器必须修改应用程序的roots, 以便于这些指针能够指向对象的新地址. 另外, 如果任何对象包含指向另一个对象的指针, 垃圾收集器也有责任修改这些指针. 图示3展现了经过回收后的内存的样子.

Bb985010.gcifig03(en-us,MSDN.10)

图示3: 经过回收后的托管堆

 

鉴定了所有的垃圾之后, 所有的非垃圾对象都被紧凑地排列到了一起, 并且所有的非垃圾指针都被修复了, NextObjPtr 指针再次指向紧挨着最后一个非垃圾对象的地址. 在这个点上, new操作符会再次被尝试执行, 然后应用程序所需要的资源会被成功地创建出来.

 

正如你所看到的, GC产生了一个显著的性能下降, 这是使用托管堆的一个明显的不足之处. 然而, 记住GC仅在堆满了的时候发生, 并且直到堆满了之前, 托管堆都是比C-runtime heap要快很多很多的. Runtime的垃圾收集器还提供了可以显著提高垃圾收集性能的某些优化. 我们会在这篇文章的第二部分, 讨论generation的时候讲到这些优化.

 

在这个点上, 有些事情需要注意并指出. 你没必要实现任何的代码来管理任何你应用程序使用的任何资源的生命期了. 并且, 注意, 我们在本文开始时提高的两个bug已经不再存在了. 首先, 资源不可能再泄露了, 因为任何你应用程序的roots不可能访问到的资源会在某个时间点上被回收. 第二, 访问一个已经释放的资源是不可能的, 因为如果资源可以被访问得到的话, 那么它们是不会被释放的. 如果不能访问得到, 那么你的应用程序就不可能访问到它. 图示4中的代码展示了资源是如何是被分配和管理的.

class Application
{
    public static int Main(String[] args)
    {
        // ArrayList object created in heap, myArray is now a root
        ArrayList myArray = new ArrayList();

        // Create 10000 objects in the heap
        for (int x = 0; x < 10000; x++)
        {
            myArray.Add(new Object());    // Object object created in heap
        }

        // Right now, myArray is a root (on the thread's stack). So, 
        // myArray is reachable and the 10000 objects it points to are also 
        // reachable.
        Console.WriteLine(a.Length);

        // After the last reference to myArray in the code, myArray is not 
        // a root.
        // Note that the method doesn't have to return, the JIT compiler 
        // knows
        // to make myArray not a root after the last reference to it in the 
        // code.

        // Since myArray is not a root, all 10001 objects are not reachable
        // and are considered garbage.  However, the objects are not 
        // collected until a GC is performed.
    }
}

图示4

 

如果GC是这么的好, 你也许会想他为什么不在ANSI C++中呢? 原因在于垃圾收起必须能鉴别一个应用程序的roots, 并且一定要能找到所有对象的指针. C++的问题是它允许指针从一种类型转换为另一种类型, 我们没有办法知道一个指针指向的是什么. 在common language runtime中, 托管堆永远都知道对象的真实类型, 并且它还知道用来确定哪个对象的哪个成员引用了其他对象的所有元数据信息.

 

终止化- Finalization

===============

垃圾收集器提供了你可以利用的额外的一些特性. Finalization 允许一个资源可以在它自身被回收的时候被优雅地清理. 通过finalization, 代表着一个文件或者网络连接的资源能够在垃圾收集器决定释放资源内存的时候, 恰当地清除自己.

 

下面是对所发生的事情的一段过于简略的陈述: 当垃圾收集器检测出一个对象时垃圾的时候, 垃圾收集器会调用这个对象的Finalize 方法(如果有的话), 然后这个对象的内存会被回收. 举个例子, 你有下面这样类型的代码(in C#):

public class BaseObj
{
    public BaseObj()
    {
    }

    protected override void Finalize()
    {
        // Perform resource cleanup code here... 
        // Example: Close file/Close network connection
        Console.WriteLine("In Finalize.");
    }
}

 

现在你可以通过执行下面的语句创建这个对象的一个实例:

BaseObj bo = new BaseObj();

 

在将来的某个时刻, 垃圾收集器会确定这个对象是一个垃圾对象. 当这发生的时候, 垃圾收集器会看这个类型是否有一个Finalize 方法, 如果有, 就调用这个方法, 引发控制台中显示出"In Finalize"的字样, 并回收这个对象占用的内存.
     

很多习惯于使用C++变成的开发人员会一下子看出析构函数和Finalize方法的关联关系. 然而, 我现在要警告你: 对象的finalization和析构函数有着非常不同的语义, 并且在你思考finalization 的时候最好忘记你所知道的关于析构函数的一切. 托管对象永远没有执行析构函数的时候.

 

在我们设计一个类型的时候, 最好要避免使用Finalize函数. 这样做有下面的一些原因:

  • Finalizable objects会被放到更老的一代(generation)中, 这增加了内存的压力, 并且在垃圾收集器确定对象时垃圾的时候阻碍了对象内存的回收. 另外, 所有的直接或间接引用这个对象的对象也被放到了更老的一代(generation)中. 本文的第二部分会讨论generation和promtion. 
  • Finalizable objects 所需要的分配时间更长.
  • 强制垃圾收集器执行一个Finalize 方法会严重地损害性能. 记住, 每一个对象都会被finalized. 所以, 如果我有一个拥有一万个对象的数组, 那么每一个对象的Finalize方法都会被调用一次.
  • Finalizable objects 会引用到其他非finalizable的对象, 不必要的延长了它们的生命期. 事实上, 你也许会想要把一个类型拆分为两个类型, 一个轻量级的带有finalize方法的类型, 而且这个轻量级的类型不引用任何其他的对象, 另一个类型没有Finalize方法, 并且引用其他类型的对象.
  • 你对于Finalize方法在何时执行没有控制权. 对象会在垃圾收集发生之前一直保有资源.
  • 当应用程序终结的时候, 一些对象仍然是可以被访问到的, 而且它们的Finalize方法也不会被调用到. 如果后台线程使用对象的时候, 或者对象在应用程序终结或卸载AppDomain的时候被创建出来, 程序终结也调用不到对象的Finalize方法的情况是会发生的. 另外, 默认情况下, 为了让应用程序迅速终结, 当应用程序退出的时候, Finalize方法也不会为无法到达的对象所调用. 当然, 所有的操作系统资源都会被回收, 但是任何在托管堆中的对象都不会被优雅地清理掉. 你可以通过调用System.GC类型的RequestFinalizeOnShutdown方法来修改这个默认的行为. 然而, 你应该小心地使用这个方法, 因为调用这个方法意味着你的类型是被整个应用程序的policy所控制的. 
  • Runtime不会保证Finalize方法的调用顺序. 比如说, 有一个对象包含一个内部对象. 垃圾收集器已经检测到这两个对象都是垃圾. 更进一步, 如果内部对象的Finalize方法被先调用了. 现在外部对象的Finalize方法被允许访问内部对象, 并调用内部对象的方法, 但是内部对象已经被finalized了, 那么结果就是不可预知的. 基于这个原因, 我们强烈建议Finalize方法不去访问任何内部对象, 任何成员对象.

如果你确定你的类型必须实现一个Finalize方法, 那么请确保代码执行能够越快越好. 避免所有能够阻止Finalize方法的动作, 包括任何线程同步的操作. 还有, 如果你允许任何异常跑出Finalize方法, 系统会假设Finalize方法已经反悔了, 并且会继续调用其他对象的Finalize方法.
     

当编译器生成构造函数的代码的时候, 编译器会自动地插入一条对与基类类型的构造函数的调用. 同样地, 当C++编译器生成出析构函数的时候, 编译器会自动地添加上一条对于基类析构函数的调用. 然而, 正如我说过的, finalize方法跟析构函数不同. 编译器对于Finalize方法没有特别的知识, 所以编译器不会自动地在你的类型的Finalize方法中添加对于基类Finalize方法的调用. 如果你想要这样的行为, 那么你必须显示地在你的类型的Finalize方法中添加对与基类Finalize方法的调用.

public class BaseObj: BaseObj1
{
    public BaseObj()
    {
    }

    protected override void Finalize()
    {
        Console.WriteLine("In Finalize.");
        base.Finalize();    // Call base type's Finalize
    }
}

 

注意, 你调用了基类的Finalize方法, 这使得基类对象会存货尽可能长的时间. 由于调用基类的Finalize方法比较常见, C#提供了一种语法来简化你的工作. 在C#中, 可以使用下面的代码:

class MyObject {
    ~MyObject() { }
}

 

会引发编译器生成这样的代码:

class MyObject {
    protected override void Finalize() {
        base.Finalize();
    }
}

 

注意, C#的这个语法看起了跟C++中定义析构函数一样. 但是请记住, C#不支持析构函数. 别让相近的语法欺骗了你.

 

Finalization的内幕- Finalization Internal

================

在表面上, finlization看起来挺直接: 你创建一个对象, 当对象回收的时候, 对象的Finalize方法会被调用. 但是实际上有比这更多的finaliztion.
     

当应用程序创建新对象的时候, new操作符在堆上分配内存. 如果对象的类型包含Finalize方法, 那么一个指向对象的指针会被放到finalization队列中. Finalization队列是一个由垃圾收集器控制的内部的数据结构. 每一个队列中的条目都指向一个应该在该对象回收内存之前调用自己的Finalize方法的对象.

图示5 展现了一个包含有几个对象的堆. 有些对象可以通过应用程序的root访问到, 有些却不能. 当对象C, E, F, I, 和 J 被创建的时候, 系统检测到这些对象有Finalize方法, 然后指向这些对象的指针被添加到了finalization队列中.

Bb985010.gcifig05(en-us,MSDN.10)

图示5 包含很多对象的堆

 

当垃圾收集发生的时候, 对象B, E, G, H, I, 和 J被确定为垃圾. 垃圾收集器扫描finalization队列, 查找指向这些对象的指针. 当找到一个指针的时候, 指针就会被从finalization队列中移除, 并添加到freachable(发音为"F-reachable")队列中. freachable队列是有垃圾收集器控制的另外一个内部数据结构. 每一个freachable队列中的指针都标示着一个准备调用其Finalize方法的对象.

回收之后, 托管堆会看起来像图示6. 这里, 你看到被对象B, G, 和H占用的内存已经被回收, 因为这些对象没有Finalize方法需要被调用. 然而, 被E, I, 和J占用的内存不能被回收, 因为他们的Finalize方法还没有被调用.

Bb985010.gcifig06(en-us,MSDN.10)

图示6: 垃圾收集后的托管堆

 

有一个特别的运行时线程, 专门用来调用Finalize方法. 当freachable队列空了的时候(通常状况下都是这样的), 这个线程会休眠. 但是当条目出现的时候, 这个线程就会苏醒, 把条目从队列中移除, 调用每个对象的Finalize方法. 因为这个原因, 你不应该在Finalize方法中执行任何对于执行该方法的线程有假设的代码. 比如说, 避免在Finalize方法中调用local storage.

 

Finalization队列和freachable 队列之间的交互很吸引人. 首先, 让我告诉你freachable 的名字是怎么来的吧. 这里的字母f显然代表着finalization; 每一个在freachable队列中的条目都会被调用它们的Finalize方法. 剩下的"reachable"部分意味着这些对象是reachable的. 换一种说法, freachable队列可以被认为是一种root, 就像全局变量和静态变量也是roots一样. 所以, 如果一个对象在freachable队列中, 那么这个对象也算是reachable的, 并不能算是垃圾.

 

简单来说, 当一个对象是不能被reachable的时候, 垃圾收集器会认为这个对象时垃圾. 然后, 当垃圾收集器从finalization队列中移到freachable队列中是, 这个对象就不在被认为是垃圾了, 它的内存也不会被回收. 在这个点上, 垃圾收集器已经完成了对垃圾的鉴别. 有些被认为是垃圾的对象被分类为不再是垃圾了. 垃圾收集器会整理回收了的内存, 然后特别的runtime线程会清空freachable队列, 执行每一个对象的Finalize方法.

Bb985010.gcifig07(en-us,MSDN.10)

图示7: 第二次垃圾收集后的托管堆

 

下一次垃圾收集器被触发的时候, 它看到已经被finalized的对象成了真正的垃圾, 因为application的roots不指向它, 并且freachable队列也不再指向他. 现在这些对象的内存会被简单地回收. 要理解的重要的事情是: 回收需要finalization的对象需要两次GC的执行. 现实中, 所需要的GC执行的次数可能不只两次, 因为这些对象可能会被升级到一个更老的一代中. 图示7显示出了第二次GC执行后的托管堆.

 

复活- Resurrection

==============

整个finalization的概念很迷人. 然而, 事实上其中包含的内容比我描述的还要多很多. 你已经注意到在前面的部分, 当应用程序不再访问一个或者的对象的时候, 垃圾收集器会认为这个对象死掉了. 然而, 如果一个对象需要finalization, 对象会被再次认为是活着的, 知道它被实际地finalized, 然后它在永远的死去了. 换句话说, 需要finalization的对象会先死掉, 再活过来, 然后再一次的死去. 这种非常有趣的现象叫做复活. 复活, 就像语义暗示的, 允许一个对象从死亡状态中回复过来.

 

我已经描述了复活的形式. 当垃圾收集器把一个对象的引用放入freachable队列中的时候, 对象就是从root中可以访问到的了, 也就恢复了生命. 最终, 对象的Finalize方法被调用, 不再有root指向这个对象, 这个对象从此以后就永远的死亡了. 但是如果一个对象的Finalize方法的执行代码指向了一个全局变量, 或者静态变量, 那该怎么办呢?

public class BaseObj 
{
    protected override void Finalize() {
        Application.ObjHolder = this; 
    }
}

class Application 
{
    static public Object ObjHolder;    // Defaults to null
}

 

这种情况下, 当对象的Finalize方法执行的时候, 一个指向对象的指针会被放到root中, 这样这个对象会变得从Applicaiton代码中可以reachable了. 这个对象现在复活了, 垃圾收集不会认为这个对象是垃圾了. 应用程序可以随便使用这个对象, 但是需要注意到的重要的一点是: 这个对象已经被finalized, 所有使用这个对象的动作都会导致不可预期的结果. 还要注意, 如果BaseObj包含指向其他对象的成员(直接, 间接都可以), 那么所有的对象都会复活, 因为他们都在application 的root中变为了reachable. 然而, 注意这些对象当中可能有不少已经被finalized过了.

 

事实上, 在设计你自己的对象类型时, 你的类型的对象会在你完全无法控制的情况下被finalized或复活过来. 请按照能够优雅地处理这种事实方式来实现你的代码. 对于许多类型, 这意味着保持一个Boolean位, 来标识这个类型是否已经被finalized. 然后, 如果有方法调用到你的已经被finalized的对象的时候, 你可以考虑抛出一个异常. 需要的确切的技术取决于你的类型.

现在如果某段代码设置Application.ObjHolder 为null, 那么这个object就是unreachable的了. 最终, 垃圾收集器会认为这个对象是垃圾, 然后回收该对象的存储空间. 注意对象的Finalize方法并不会被调用, 因为没有对象在finalization队列中存在.

 

对于复活技术非常好的应用技术基本没有, 所以你应该尽可能地避免应用复活技术. 事实上, 当大家使用复活技术的时候, 他们通常想要的是每次在对象死去之前优雅地清理自身. 为了使得这个目的成为可能, GC类型提供了一个叫做ReRegisterForFinalize的方法, 它只需要一个参数: 指向这个对象的指针.

public class BaseObj
{
    protected override void Finalize()
    {
        Application.ObjHolder = this;
        GC.ReRegisterForFinalize(this);
    }
}

 

当这个对象的Finalize方法被调用的时候, 它通过制作一个指向对象本身的root使自身复活. Finalize方法然后会调用ReRegisterForFinalize方法, 这个方法把这个对象的地址插入到finalization队列的后面. 当垃圾收集器检测到这个对象再一次地unreachable 的时候, 它会把这个对象指针放到freachable队列中, Finalize方法会再次被调用. 这个具体的例子展现了如何创建一个一直复活的, 永远不死的对象, 而这种对象并不是我们想要的. 所以在Finalize方法内部有条件地设置一个root引用到object是非常常见的解决不死对象的方式.

 

请确保每次复活(resurrection)的时候, 调用ReRegisterForFinalize 不超过一次, 要不然, 这个对象的Finalize方法会调用多次. 这会发生, 因为ReRegisterForFinalize 会在finalization 队列后面添加一条新的记录. 当对象被确定为垃圾的时候, 所有的这些记录会从finalization 队列移到freachable 队列, 然后多次调用对象的Finalize 方法.

 

强制一个对象进行清除

=================

如果你可以的话, 你应该尝试定义不需要进行任何clean up的对象. 不幸的是, 对于许多对象来讲, 这根本不可能. 所以, 对于这些对象, 你必须实现一个Finalize方法作为类型定义的一部分. 然而, 通常推荐你为类型添加一个额外的方法, 该方法允许你类型的用户在他们需要的时候显示地清除对象. 按照惯例, 这个方法应该被叫做Close或者Dispose.

 

总体来说, 如果一个对象可以被reopened或者reused, 那么你应该使用Close. 还有, 如果对象大体上可以被认为被close了, 比如一个文件, 那么你也应该使用Close. 另一方面, 如果对象在被dispose之后不应该再被使用的话, 你就应该使用Dispose, 并且调用试图操纵对象的方法应该会引出异常的抛出. 比方说, 如果你需要另一个Brush对象的话, 你应该在创建一个新的Brush对象.
     

现在, 让我们看看Close/Dispose 应该做些什么. System.IO.FileStream 类型允许用户打开一个文件进行读写. 为了提高性能, 类型的实现中使用了一个内存缓冲池. 仅当类型的缓冲填满了的时候对象才flush内容到文件中. 假设你创建了一个FileStream 对象, 并写了几个字节的信息到里面. 如果这些字节蒙蔽有填满buffer, 那么buffer就不会被写入磁盘. FileStream 类型实现了一个Finalize方法, 当FileStream 对象被收集的时候, Finalize方法会flush任何剩下的数据从内存到磁盘上, 然后关闭文件.

但是这种方式可能对于FileStream类型的使用者来说不够好. 比方说, 第一个FileStream 还没被回收, 但是应用程序希望再创建一个新的指向相同磁盘文件的FileStream 对象. 在这种情况下, 第二个FileStream 对象会打不开文件,如果第一个对象对文件有排他访问权的话. FileStream 的使用者必须有某种方式来强制最终的内存flush到磁盘上, 并且关闭文件.

如果你检查FileStream类型的文档的话, 你会发现有一个方法叫做Close. 当这个方法被调用的时候, 它会flush内存中剩下的数据到磁盘上, 然后关闭文件. 现在FileStream的用户对对象的行为有了控制.

但是一个有趣的问题出现了: 在FileSteam的Finalize方法在FileStream对象回收的时候, 应该做些什么呢? 显然地, 答案是什么也不做. 事实上, 如果应用程序显式地调用了FileStream的Close方法, 那么它的Finalize方法就已经没有必要被调用了. 你知道Finalize方法是不被鼓励使用的, 二这种情况下你却要让系统调用一个什么也不做的Finalize方法. 看起来一定要有某种方式来让系统不去调用对象的Finalize方法. 幸运的是, 还真有. System.GC 类型包含一个静态方法, 叫做SuppressFinalize. 带有一个参数, 这个对象的地址.

 

图表8显示了FileStream类型的实现. 当你调用SuppressFinalize方法的时候, 它会打开与对象相关的一个二进制的标志位. 当这个标志位被打开的时候, 运行时会知道不要把这个对象的指针放到freachable 队列中, 这样就阻止了对象的Finalize方法被调用了.

public class FileStream : Stream {

    public override void Close() {
        // Clean up this object: flush data and close file 
        •••
        // There is no reason to Finalize this object now
        GC.SuppressFinalize(this);
    }

    protected override void Finalize() {
        Close();    // Clean up this object: flush data and close file
    }

    // Rest of FileStream methods go here
    •••
}

图表8: FileStream的实现

 

让我们看另一个相关的问题. 一起使用StreamWriter对象和FileStream对象是非常常见的.

FileStream fs = new FileStream("C:\\SomeFile.txt",
        FileMode.Open, 
        FileAccess.Write, 
        FileShare.Read);
StreamWriter sw = new StreamWriter(fs);
sw.Write("Hi there");

// The call to Close below is what you should do
sw.Close();
// NOTE: StreamWriter.Close closes the FileStream. The FileStream
//  should not be explicitly closed in this scenario

 

注意StreamWriter的构造函数使用一个FileStream 对象作为参数. 在内部实现中, StreamWriter 对象保存了FileStream对象的指针. 这两个对象都有在结束文件访问之后, 应该被flush到文件的内部数据的缓冲. 调用StreamWriter的Close方法会写入最后的数据到FileStream中, 并在内部Close掉FileStream对象, 这个close调用会写入最终的数据到磁盘文件上, 并关掉文件. 因为StreamWriter的Close方法会关闭与它相关的FileStream对象, 你不应该再自己调用fs.Close()方法了.

 

如果你移除了两个Close的调用, 你认为会发生什么呢? 好吧, 垃圾收集器能够正确地检测到对象是垃圾, 然后垃圾对象会被Finalized. 但是垃圾收集器并不会保证哪一个Finalize方法的调用顺序. 所以, 如果FileStream先辈Finalize的话, 它就关闭文件.  然后当StreamWriter 被finalize的时候, 它会尝试写入数据后贯标文件, 这会引发一个异常. 当然如果StreamWriter 先被finalize的话, 那数据就安全地写回到文件中了.

 

微软是如何解决这个问题的呢?要让垃圾收集器按照指定的顺序来执行是不可能的, 因为对象可以包含彼此的指针, 垃圾收集器不可能正确地猜出finalize这些对象的顺序. 所以, 微软的解决方案是: SteamWriter类型根本就不实现Finalize方法. 当然, 这意味着忘记显示地close掉StreamWriter 对象一定会有数据丢失. 微软期望开发人员能看到这中持续的数据丢失现象, 并通过显示地调用Close来修复这个问题.

     

如同早些时候说过的, SuppressFinalize 方法会简单地设置一个对象的bit标志位, 从而对象的Finalize方法不被调用. 然而, 这个标志会在runtime确定是时候调用对象的Finalize方法的时候被重置. 这意味着, 对ReRegisterForFinalize方法的调用不能通过调用SuppressFinalize方法来平衡掉. 图示9里的代码解释了我的意思.

void method()
    {
        // The MyObj type has a Finalize method defined for it
        // Creating a MyObj places a reference to obj on the finalization table.
        MyObj obj = new MyObj();

        // Append another 2 references for obj onto the finalization table.
        GC.ReRegisterForFinalize(obj);
        GC.ReRegisterForFinalize(obj);

        // There are now 3 references to obj on the finalization table.

        // Have the system ignore the first call to this object's Finalize 
        // method.
        GC.SuppressFinalize(obj);

        // Have the system ignore the first call to this object's Finalize 
        // method.
        GC.SuppressFinalize(obj);   // In effect, this line does absolutely 
        // nothing!

        obj = null;   // Remove the strong reference to the object.

        // Force the GC to collect the object.
        GC.Collect();

        // The first call to obj's Finalize method will be discarded but
        // two calls to Finalize are still performed.
    }

图示9

 

ReRegisterForFinalize 和SuppressFinalize 方法的实现是为了解决性能问题的. 只要每次调用SuppressFinalize 对应着一个ReRegisterForFinalize的话, 那么一切工作正常. 需要依靠你来保证不要多次连续地调用ReRegisterForFinalize 或者SuppressFinalize , 要不然的话多次调用一个对象的Finalize方法就会发生.

 

结论

=============

垃圾收集环境的动力就是要帮助研发人员简化内存管理. 第一部分概述了大体上GC的概念和内部实现. 在第二部分, 我们会对所有的讨论做出一个结论. 首先, 我会探索一个叫做WeakReferences的特性, 你可以使用这个特性来减少大对象施加在托管堆上的内存压力. 然后我会检查一个允许你人为地扩展托管对象生命期的机制. 最后, 我会圆满结束各种关于垃圾收集器性能的讨论. 我会讨论generations, multithreaded collections, 还有common language runtime暴露出来的performance counters, 它们允许你监控垃圾收集器的实时行为.

 

原文地址:

Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework

http://msdn.microsoft.com/en-us/magazine/bb985010.aspx

posted on 2010-06-06 15:37  中道学友  阅读(929)  评论(2编辑  收藏  举报

导航

技术追求准确,态度积极向上