浅谈GC 垃圾回收机制

其实作为小白的我写程序中根本不用管内存的分配和垃圾回收。但我想了解了解垃圾回收机制,于是稍微花了点时间研究了一下Microsoft .NET的垃圾回收机制。看了一篇英文的介绍,好不容易翻译了一点(我是文章的搬运工,只是翻译了一遍,没有原创性)。如有理解错误的地方,敬请提出。

如果让我们自己写程序实现合理的资源管理,我想肯定很多人都写不出来(包括我)。资源管理对我们来说是一个困难、乏味的工作。也有可能干扰我们的注意点,以至于弄混我们写程序的主要目的,导致本末倒置。幸好很多语言都有垃圾回收机制,使开发人员不用实时的注意内存管理。那么资源是如何去分配和管理的?垃圾回收算法是如何工作的?垃圾回收器如何决定去释放一个内存资源时?资源释放的方式?以及如何强制清理一个象?


在公共语言运行时(Common Language Runtime),垃圾回收器用作自动内存管理器时,优点如下:

  • 开发人员不用把精力过多投入到内存管理中
  • 有效分配托管堆上的对象(后边详细介绍托管堆)
  • 自动回收一些不再使用的对象的内存。
  • 通过确保对象不能使用另一个对象的内容来提供内存安全。

下面我将简单的介绍我所理解的Microsoft .NET公共语言的垃圾回收机制:

首先使用资源要遵循以下几个步骤:

  1. 根据所要使用的资源类型分配内存。(如int型分配4个字节)。
  2. 内存分配后还不能直接用,要对内存进行初始化,并初始化资源的状态,让资源可用。
  3. 通过访问类型的实例成员来使用资源。
  4. 改变要清理资源的状态。
  5. 最后一步,终于可以释放内存了。

这五个步骤看起来很简单,却是大部分程序出错的主要原因。毕竟我们会经常在不需要内存时忘记释放内存,或者在内存已经被释放后又去访问内存。这两种Bug会比其它类型的Bug更烦人,因为这是一个不定时的炸弹,指不定什么时间就以让人意想不到的方式爆炸了,而其他的Bug,至少你能找到大概的位置。而GC让开发者再也不用关注内存的使用情况,什么时间去释放内存。但是垃圾回收器是不知道资源在内存中的类型的,意味着垃圾回收器不能做上边的第4步来改变资源的状态。

在.NET框架中,开发者可以调用一些Close,Dispose,Finalize方法,这个我会在后边描述。垃圾回收器可以自动的调用这些方法,是不是很神奇?其实许多类型的资源是不需要清理的,如只需销毁保存在类型内存中的左、右、长、宽就可以完全清楚矩形资源。另一方面,文件资源、网络连接资源的类型在销毁资源时需要执行一些显式的清理代码。下边会简单介绍如何完成这些任务,现在开始描述内存分配和资源初始化。

内存分配

Microsoft .NET公共语言运行时(Common Language RunTime简称CLR)要求所有资源已经被托管堆分配好了,有点类似于C-runtime堆,只在应用程序不需要时自动释放对象,永远不会在托管堆中释放对象。当然,这又提出了一个新的问题,托管堆如何知道应用程序不需要此对象了哪?

现在有很多GC算法,每种算法都有它所适用的环境,不同环境下性能会有所不同。这篇文章主要介绍被CLR使用的GC算法。说明一下托管堆的概念,当一个进程被初始化,运行时会保留一个连续区域的地址空间,该区域最初未被分配存储空间。这个地址空间就被称为托管堆。不懂?没关系,继续往下看。托管堆会包含一个指针,命名为NextObjPtr,此指针指向在堆中分配下一个对象的位置。最初这个指针指向地址保留区的基地址。

应用程序使用new运算符创建一个对象,此运算符首先确保新对象所需的字节适合保留区域。如果合适,NextObjPtr指向堆中的对象,调用该对象的构造函数,new运算符返回对象的地址。

                                                                      图片1:托管堆

此时,NextObjPtr将被递增指向下一个对象在堆中的位置,图一显示的托管堆中有三个对象A、B、C,下一个被分配的对象位于NextObjPtr所指向的位置。先说一下C-runtime堆如何分配内存的。在C-runtime堆中,为一个对象分配内存需要遍历一个结构体组成的链表。一旦找到足够大的块,就必须拆分块,并且必须修改链接列表节点中的指针,以保持所有内容的完整性。对托管堆来说,分配一个对象仅仅意味着给指针增加一个值。相比之下,这非常快。实际上从托管堆分配对象的速度几乎与从线程堆栈分配内存的速度一样快!

听起来托管堆优于C-runtime堆,速度快还简单。当然托管堆有这些优势是因为它做了一个大的假设:地址空间和存储是无限的。这一假设(毫无疑问)是不可能出现的,并且托管堆必须使用一种机制,允许堆做出这一假设。这个机制称为垃圾回收器,让我们看看它是如何工作的。

当应用程序调用new运算符来创建对象时,该区域中可能没有足够的地址空间分配给该对象。堆通过将新对象的大小添加到NextObjPtr来检测这一点。 如果nextobjptr超出地址空间区域的末尾,则堆已满,必须执行回收了。 实际上,当第0代完全满时,回收就发生了。(堆按代进行组织,因此它可以处理长生存期的对象和短生存期的对象。 垃圾回收主要在回收通常只占用一小部分堆的短生存期对象时发生。 堆上的对象有三代,垃圾回收常常发生在0代上)简单的说,就是通过垃圾回收器提高性能的一种机制。 新创建的对象是年轻一代的一部分,在应用程序生命周期早期创建的对象是旧一代的。 将对象分代可以允许垃圾收集器收集特定的代,而不是收集托管堆中的所有对象。后边会详细介绍代。

垃圾回收算法

垃圾回收器检查堆中是否有应用程序将不再使用的对象。 如果有这样的对象,那么回收这些对象使用的内存(如果堆得不到更多的内存,new运算符就抛出OutOfMemoryException错误)。  那么垃圾回收器是怎么知道应用程序是否还使用某个对象?这个问题有点难,别着急,往下看。

每一个程序都是有开始的第一句代码(也就是根)。根标识存储位置,所指的对象在托管堆中或者对象被置为空。比如,在程序中所有的静态全局变量(或对象)就会被认为是程序的根。此外,任何局部变量/对象指针参数在线程的堆栈被认为是应用程序的根的一部分。最后,任何包含指向托管堆中对象的指针的CPU寄存器也被视为应用程序根的一部分。活动根的列表由实时(JIT)编译器和公共语言运行时维护,并可供垃圾收集器的算法访问。当垃圾收集器开始运行时,它假设堆中的所有对象都是垃圾。换句话说,它假定应用程序的根没有引用堆中的任何对象。现在,垃圾收集器开始遍历根并构建从根可以访问的所有对象的图表。例如,垃圾收集器可以定位指向堆中对象的全局变量。

图片2显示了一个具有多个已分配对象的堆,其中应用程序的根直接指向对象A、C、D和F。所有这些对象都成为图形的一部分。添加对象D时,收集器注意到该对象引用对象H,并且对象H也添加到图形中。收集器继续递归地遍历所有可到达的对象。

                                                                                 图2 堆中分配的对象

一旦图表的这一部分完成,垃圾收集器将检查下一个根目录,并再次遍历对象。当垃圾收集器从一个对象移动到另一个对象时,如果它试图将一个对象添加到先前添加的图形中,那么垃圾收集器可以停止沿着该路径移动。这有两个目的。首先,它有助于显著提高性能,因为它不会多次遍历一组对象。其次,如果有对象的循环链接列表,它可以防止无限循环。

检查完所有根之后,垃圾收集器的图形包含从应用程序根可以访问的所有对象的集合;应用程序无法访问图形中没有的任何对象,因此被视为垃圾。垃圾收集器现在线性地遍历堆,查找连续的垃圾对象块(现在被认为是可用空间)。然后垃圾收集器将非垃圾对象在内存中向下移动(使用多年来已知的标准memcpy函数),消除堆中的所有间隙。当然,在内存中移动对象会使指向对象的所有指针失效。所以垃圾收集器必须修改应用程序的根,以便指针指向对象的新位置。此外,如果任何对象包含指向另一个对象的指针,垃圾收集器也负责更正这些指针。图3显示了集合之后的托管堆。

                                                                图3  收集后的托管堆

在识别出所有垃圾、压缩所有非垃圾、修复所有非垃圾指针之后,nextobjptr就位于最后一个非垃圾对象之后。此时,将再次尝试新操作,并成功创建应用程序请求的资源。

如您所见,GC会产生显著的性能损失,这是使用托管堆的主要缺点。但是,请记住,只有当堆已满时才会发生GCS,在此之前,托管堆比C运行时堆快得多。运行时的垃圾收集器还提供了一些优化,这些优化大大提高了垃圾收集的性能。在本文的第2部分中,当我讨论几代人时,我将讨论这些优化。

在这一点上有一些重要的事情需要注意。您不再需要实现任何代码来管理应用程序使用的任何资源的生命周期。注意我在本文开头讨论的两个bug是如何消失的。首先,不可能泄漏资源,因为从应用程序的根目录不可访问的任何资源都可以在某个时刻收集。第二,访问已释放的资源是不可能的,因为如果可以访问,资源将不会被释放。如果无法访问,那么您的应用程序就无法访问它。图4中的代码演示了如何分配和管理资源。

如果GC如此之大,您可能会想知道为什么它不在ANSI C++中。原因是垃圾收集器必须能够识别应用程序的根,并且必须能够找到所有对象指针。C++的问题在于它允许将指针从一种类型转换为另一种类型,并且无法知道指针所指的是什么。在公共语言运行时中,托管堆始终知道对象的实际类型,并且元数据信息用于确定对象的哪些成员引用其他对象。

定稿:

垃圾收集器提供了一个您可能想要利用的附加功能:终结。完成允许资源在被收集时在其自身之后进行适当的清理。通过使用终结,当垃圾收集器决定释放资源的内存时,表示文件或网络连接的资源能够正确地清理自己。

这里对发生的事情过于简单化:当垃圾收集器检测到对象是垃圾时,垃圾收集器调用对象的Finalize方法(如果存在),然后回收对象的内存。例如,假设您有以下类型(在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方法。这有几个原因:

  1. 可完成的对象将升级到较旧的代,这会增加内存压力,并防止在垃圾收集器确定对象是垃圾时收集对象的内存。此外,该对象直接或间接引用的所有对象也将被提升。
  2. 可完成对象的分配时间较长。
  3. 强制垃圾收集器执行Finalize方法可能会严重损害性能。记住,每个对象都是最终确定的。因此,如果我有一个由10000个对象组成的数组,那么每个对象都必须调用其finalize方法。
  4. 可终结对象可能引用其他(不可终结)对象,从而不必要地延长其生存期。实际上,您可能需要考虑将一个类型分成两种不同的类型:一种轻量级类型,具有不引用任何其他对象的Finalize方法,另一种类型没有引用其他对象的Finalize方法。
  5. 您无法控制何时执行Finalize方法。对象可能会保留资源,直到下次运行垃圾收集器。
  6. 当应用程序终止时,一些对象仍然可以访问,并且不会调用它们的Finalize方法。如果后台线程正在使用对象,或者在应用程序关闭或AppDomain卸载期间创建了对象,则可能发生这种情况。此外,默认情况下,当应用程序退出时,不会为无法访问的对象调用Finalize方法,以便应用程序可以快速终止。当然,所有操作系统资源都将被回收,但托管堆中的任何对象都无法正常清理。可以通过调用system.gc类型的requestFinalizeOnShutdown方法来更改此默认行为。但是,您应该小心使用此方法,因为调用它意味着您的类型正在控制整个应用程序的策略。
  7. 当应用程序终止时,一些对象仍然可以访问,并且不会调用它们的Finalize方法。如果后台线程正在使用对象,或者在应用程序关闭或AppDomain卸载期间创建了对象,则可能发生这种情况。此外,默认情况下,当应用程序退出时,不会为无法访问的对象调用Finalize方法,以便应用程序可以快速终止。当然,所有操作系统资源都将被回收,但托管堆中的任何对象都无法正常清理。可以通过调用system.gc类型的requestFinalizeOnShutdown方法来更改此默认行为。但是,您应该小心使用此方法,因为调用它意味着您的类型正在控制整个应用程序的策略。

如果确定类型必须实现Finalize方法,请确保代码尽可能快地执行。避免所有会阻止Finalize方法的操作,包括任何线程同步操作。此外,如果让任何异常退出Finalize方法,系统只假设Finalize方法返回并继续调用其他对象的Finalize方法。

当编译器为构造函数生成代码时,编译器会自动插入对基类型的构造函数的调用。同样,当C++编译器生成析构函数的代码时,编译器会自动调用基类的析构函数。然而,正如我之前所说,终结方法不同于析构函数。编译器对Finalize方法没有特殊的知识,因此编译器不会自动生成代码来调用基类型的Finalize方法。如果需要此行为并且经常这样做,则必须从类型的Finalize方法显式调用基类型的Finalize方法:

public class BaseObj {
    public BaseObj() {
    }

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

请注意,通常将基类型的Finalize方法调用为派生类型的Finalize方法中的最后一条语句。这将使基本对象尽可能长时间保持活动状态。由于调用基类型finalize方法很常见,C的语法可以简化您的工作。在C中,以下代码

class MyObject {
    ~MyObject() {
        •••
    }
}

使编译器生成此代码:

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

请注意,这个C++语法看起来与C++语言定义析构函数的语法完全一样。但是记住,C不支持析构函数。不要让相同的语法愚弄你。

(未完待续)

总结:

垃圾收集环境的主要目的是为开发人员简化内存管理。这里只介绍了一些通用的GC概念和内部结构。

 

参考资料:  https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章