第21章 自动内存管理

本章将讨论托管应用程序如何构造新对象,托管堆如何控制这些对象的生存期,以及如何回收这些对象的内存。简单的说,本章要解释CLR中的垃圾回收器是如何工作的,还要解释与它有关的性能问题。

21.1理解垃圾回收平台的基本工作原理

在.NET Framework中,内存中的资源(即所有二进制信息的集合)分为“托管资源”和“非托管资源”。托管资源必须接受.NET Framework的CLR的管理 (如内存类型安全性检查) 。而非托管资源则不必接受.NET Framework的CLR管理, 需要手动清理垃圾(显式释放)。注意,“垃圾回收”机制是.NET Framework的特性,而不是C#的。

每个程序都要使用这样或那样的资源,比如文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。事实上,在面向对象的环境中,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。

以下是访问一个资源所需的具体步骤:

  • 在C#中使用new操作符创建一个新对象,编译器就会自动生成IL指令newobj,为代表此资源的类型分配内存。(如何分配见下面“从托管堆分配资源”
  • 初始化内存,设置资源的初始状态,使资源可用。类型的实例构造器负责设置该初始化状态。
  • 访问类型的成员来使用资源。
  • 摧毁资源的状态以进行清理。
  • 释放内存,垃圾回收器独自负责这一块。

垃圾回收(garbage collection)自动发现和回收不再使用的内存,不需要程序员的协助。使开发人员得到了解放,现在不必跟踪内存的使用,也不必知道在什么时候释放内存。但是,垃圾回收器不可以管理内存中的所有资源,对内存中的类型所代表的资源也是一无所知的。这意味着垃圾回收器不知道怎么执行“摧毁资源的状态以进行清理”。这部分资源就需要开发人员自己写代码实现回收。在.Net framework中,开发人员通常会把清理这类资源的代码写到Finalize,Dispose和Close方法中。

在.net中提供三种模式来回收内存资源:finalize方法,dispose模式,close方法:

  • finalize方法是.net内部的一个释放内存资源的方法。这个方法不对外公开,由垃圾回收器自动调用。
  • dispose提供了一种显示释放内存资源的方法。Dispose调用方法是: 要释放的资源对象.dispose()。
  • close和dispose其实一样,只不过有的对象没有提供dispose的方法,只提供了close方法。而close其实在那个对象的类中,依然是调用了一个私有的dispose方法,而finalize其实也是调用一个不对外公开的dispose方法。

然而,值类型、集合类型、String、Attribute、Delegate和Exception所代表的资源无需执行特殊的清理操作(原因如下)。列如,只需销毁对象的内存中维护的字符数组,一个String资源就会被完全清理。

值类型和(引用类型的引用)其实是不需要什么“垃圾回收器”来释放内存的,因为当它们出了作用域后会自动释放所占内存(因为它们都保存在“堆栈”中,学过数据结构可知这是一种“先进后出-后进先出”的结构)。只有引用类型的引用所指向的对象实例才保存在“堆”中,而堆因为是一个自由存储空间,所以它并没有像“堆栈”那样有生存期 (“堆栈”的元素弹出后就代表生存期结束,也就代表释放了内存)。并且非常要注意的是,垃圾回收器只对“堆”这块区域起作用。

从托管堆分配资源

初始化新进程时,运行时会为进程保留一个连续的地址空间区域。这个保留的地址空间被称为托管堆,并且这个地址空间最初并没有对应的物理存储空间。

除值类型外,CLR要求所有资源都从托管堆分配。

托管堆还维护着一个指针,我们把它称为NextObjPtr,它指向下一个对象在堆中的分配位置。

C#提供了new操作符创建一个新对象,将导致编译器在IL代码中生成一个newobj指令。newobj指令将导致CLR执行以下步骤来为类型分配内存:

  • 计算类型的字段需要的字节数。
  • 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。
  • CLR检查托管堆中是否能够提供分配对象所需的字节数,如果有足够的可用空间,对象会被放入存储。(在哪里放入)该对象是在NextObjPtr指针指向的地址放入的,并且为它分配的字节会被清零。接着,调用类型的实例构造器(为this参数传递NextObjPtr),设置资源的初始状态,使资源可用。NextObjPtr指针的值会加上对象占据的字节数,这样会得到一个新值,它指向下一个对象放入托管堆时的地址。
  • 可能会出现没有足够的地址空间来分配该对象的情况。托管堆将对象需要的字节数加到NextObjPtr指针中的地址上来检测这个情况。如果结果值超过了地址空间的末尾,表明托管堆已满,必须执行一次垃圾回收。
  • IL指令newobj将返回对象的地址。

下图展示了3个对象(A,B和C)的一个托管堆。C没有放入之前,NextObjPtr是在B后面的。放入C后,如果要分配其他新对象,它将放在NextObjPtr指针指向的位置(紧接着对象C后)。

21.2 垃圾回收算法

垃圾回收器检查托管堆中是否有应用程序不再使用的对象。如果有,它们使用的内存就可以被回收。那么,垃圾回收器是怎么知道一个对象不再被使用呢?

CPU寄存器(CPU Register)是CPU自己的“临时存储器”,比内存的存取还快。按与CPU远近来分,离得最近的是寄存器,然后缓存 (计算机一、二、三级缓存),最后内存。

每个应用程序都包含一组根(Roots)。每个根都是一个存储位置,他们可能指向托管堆上的某个地址,也可能是null。类中定义的任何静态字段,方法的参数,局部变量(仅限引用类型变量)等都是根,另外cpu寄存器中的对象指针也是根。

例如,所有的全局和静态对象指针,另外在线程栈上的局部变量/参数都是应用程序的根。只有引用类型的变量才被认为是根,值类型的变量永远不被认为是跟。

如果一个根引用了堆中的一个对象,则该对象为“可达”,否则即是“不可达”。被根引用的堆中的对象不被视为垃圾。

当垃圾回收器开始运行,它会假设托管堆上的所有对象都是垃圾。换句话说,它假设线程栈中没有引用堆中对象的变量,没有CPU寄存器引用堆中的对象,也没有静态字段引用堆中的对象。

垃圾回收分为2个阶段:

垃圾回收器的第一阶段是所谓的标记(marking)阶段。

垃圾回收器沿着线程栈上行以检查所有的根。如果发现一个根引用了一个对象,就在对象的“同步块索引字段”上开启一位(将这个bit设为1)---对象就是这样被标记的。当所有的根都检查完毕后,堆中将包含可达(已标记)与不可达(未标记)对象。

如下图,展示了一个堆,其中包含几个已分配的对象。应用程序的根直接引用对象ACDF,所有这些对象都被标记。标记好根和它的字段引用对象之后,垃圾回收器检查下一个根,并继续标记对象。如果垃圾回收器试图标记之前已经被标记过的对象,就会换一个路径继续遍历。这样做有两个目的:首先,垃圾回收器不会多次遍历一组对象,提高性能。其次,如果存在对象的循环链表,可以避免无限循环。

垃圾回收器的第二个阶段是压缩(compact)阶段。

在这个阶段中,垃圾回收器线性地遍历堆,以寻找未标记的连续内存块。如果发现大的可用的连续内存块,垃圾回收器会把非垃圾(标记/可达)的对象移动到这里来进行压缩堆。堆内存压缩后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾对象之后的位置。这时候new操作符就可以继续成功的创建对象了。这个过程有点类似于磁盘空间的碎片整理。以此,对堆进行压缩,不会造成进程虚拟地址空间的碎片化。

如上图所示,绿色框表示可达对象,黄色框为不可达对象。不可达对象清除后,移动可达对象实现内存压缩(变得更紧凑)。

压缩之后,“指向这些对象的指针”的变量和CPU寄存器现在都会失效,垃圾回收器必须重新访问所有根,并修改它们来指向对象的新内存位置。这会造成显著的性能损失。这个损失也是托管堆的主要缺点。

基于以上特点,垃圾回收引发的回收算法也是一项研究课题。因为如果真等到托管堆满才开始执行垃圾回收,那就真的太“慢”了。

垃圾回收器的好处:

  • 不必自己写代码来管理应用程序所用的对象的生存期。
  • 不会发生对象泄漏的情况,因为任何对象只要没有应用程序的根引用它,就会在某个时刻被垃圾回收器回收。

垃圾回收算法 --- 分代(Generation)算法

代是CLR垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能。分代回收,速度显然快于回收整个堆。

CLR托管堆支持3代:第0代,第1代,第2代。第0代的空间约为256KB,第1代约为2M,第2代约为10M。新构造的对象会被分配到第0代。

如上图所示,当第0代的空间满时,垃圾回收器启动回收,不可达对象(上图C、E)会被回收,存活的对象被归为第1代。

当第0代空间已满,第1代也开始有很多不可达对象以至空间将满时,这时两代垃圾都将被回收。存活下来的对象(可达对象),第0代升为第1代,第1代升为第2代。

实际CLR的代回收机制更加“智能”,如果新创建的对象生存周期很短,第0代垃圾也会立刻被垃圾回收器回收(不用等空间分配满)。另外,如果回收了第0代,发现还有很多对象“可达”,

并没有释放多少内存,就会增大第0代的预算至512KB,回收效果就会转变为:垃圾回收的次数将减少,但每次都会回收大量的内存。如果还没有释放多少内存,垃圾回收器将执行完全回收(3代),如果还是不够,则会抛出“内存溢出”异常。

也就是说,垃圾回收器会根据回收内存的大小,动态的调整每一代的分配空间预算!达到自动优化!

.NET垃圾回收器的基本工作原理是:通过最基本的标记清除原理,清除不可达对象;再像磁盘碎片整理一样压缩、整理可用内存;最后通过分代算法实现性能最优化。

21.4使用终结操作来释放本地资源

终结(Finalization是CLR提供的一种机制,允许对象在垃圾回收器回收其内存之前执行一些得体的清理工作。任何包装了本地资源(例如文件)的类型都必须支持终结操作。简单的说,类型实现了一个命名为Finalize的方法。当垃圾回收器判断一个对象是垃圾时,会调用对象的Finalize方法。

C#团队认为,Finalize方法是编程语言中需要特殊语法的一种方法。在C#中,必须在类名前加一个~符号来定义Finalize方法。

internal sealed class SomeType
{
        ~SomeType()
        {
            //这里的代码会进入Finalize方法
        }
}

编译上述代码,会发现C#编译器实际是在模块的元数据中生成一个名为Finalize的protected override方法。方法主体被放到try块中,finally块放入了一个对base.Finalize的调用。

实现Finalize方法时,一般都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。

如果包装了本地资源的类型没有定义Finalize方法,本地资源就得不到关闭,导致资源泄露,直至进程终止。进程终止时,这些本地资源才会被操作系统回收。

21.5对托管资源使用终结操作

不要对托管资源进行终结操作,终结操作几乎专供释放本地资源。

21.6 什么会导致Finalize方法被调用

Finalize方法在垃圾回收结束时调用,有以下5种事件会导致开始垃圾回收:

  • 第0代满 只有第0代满时,垃圾回收器会自动开始。该事件是目前导致调用Finalize方法最常见的一种方式。
  • 代码显式调用System.GC的静态方法Collect  代码可以显式请求CLR执行即时垃圾回收操作。
  • Windows内存不足 当Windows报告内存不足时,CLR会强制执行垃圾回收。
  • CLR卸载AppDomain 一个ApppDomain被卸载时,CLR认为该AppDomain中不存在任何根,因此会对所有代的对象执行垃圾回收。
  • CLR关闭 一个进程正常终止时(比如通过任务管理器关闭),CLR就会关闭。CLR关闭会认为进程中不存在 任何根,因此会调用托管堆中所有对象的Finalize方法。此时,CLR不会尝试压缩或释放内存,因为整个进程都要终止,由Windows回收进程中所有内存。

21.7终结操作揭秘

终结操作表面看起来简单:创建一个对象,当它被回收时,它的Finalize方法会得到调用。但深究下去,远没有这么简单。

应用程序创建一个新对象时,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器调用之前,会将一个指向该对象的指针放到一个终结列表 (finalization list) 中。终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,在回收该对象之前,会先调用对象的Finalize方法。

下图展示了包含几个对象的一个托管堆。有的对象从应用程序的根可达,有的不可达(垃圾)。对象C,E,F,I,J被创建时,系统检测到这些对象的类型定义来了Finalize方法,所有指向这些对象的指针要添加到终结列表中。

垃圾回收开始时,对象B,E,G,H,I和J被判定为垃圾。垃圾回收器扫描终结列表以查找指向这些对象的指针。找到一个指针后,该指针会从终结列 表中移除,并追加到freachable队列中。freachable队列(发音是“F-reachable”)是垃圾回收器的内部数据结构。 Freachable队列中的每个指针都代表其Finalize方法已准备好调用的一个对象。

下图展示了回收完毕后托管堆的情况。从图中我们可以看出B,E和H已经从托管堆中回收了,因为它们没有Finalize方法,而E,I,J则暂时没有被回收,因为它们的Finalize方法还未调用。

一个特殊的高优先级的CLR线程负责调用Finalize方法。使用专用的线程可避免潜在的线程同步问题。freachable队列为空时,该线程将睡眠。当队列中有记录项时,该线程就会被唤醒,将每一项从freachable队列中移除,并调用每一项的 Finalize方法。

如果一个对象在freachable队列中,那么意味这该对象是可达的,不是垃圾。

原本,当对象不可达时,垃圾回收器将把该对象当成垃圾回收了,可是当对象进入freachable队列时,有奇迹般的”复活”了。然后,垃圾回收 器压缩(内存脆片整理)可回收的内存,特殊的CLR线程将清空freachable队列,并调用其中每个对象的Finalize方法。

垃圾回收器下一次回收时,发现已终结的对象成为真正的垃圾,因为应用程序的根不再指向它,freachhable队列也不再指向它。所以,这些对象的内存会直接回收。

 整个过程中,可终结对象需要执行两次垃圾回收器才能释放它们占用的内存。可在实际开发中,由于对象可能被提升到较老的一代,所以可能要求不止两次进行垃圾回收。下图展示了第二次垃圾回收后托管堆中的情况。

21.8 Dispose模式:强制对象清理资源

Finalize方法非常有用,因为它确保了当托管对象的内存被释放时,本地资源不会泄漏。但是,Finalize方法的问题在于,他的调用时间不能保证。另外,由于他不是公共方法,所以类的用户不能显式调用它。

类型为了提供显式进行资源清理的能力,提供了Dispose模式。所有定义了Finalize方法的类型都应该同时实现Dispose模式,使类型的用户对资源的生存期有更多的控制。

类型通过实现System.IDisposable接口的方式来实现Dispose模式:

public interface IDisposable
{
        void Dispose();
}

任何类型只有实现了该接口,将相当于声称自己遵循Dispose模式。无参Dispose和Close方法都应该是公共和非虚的。

21.9使用实现了Dispose模式的类型

FileStream类实现了System.IDisposable接口。

            FileStream fs = new FileStream();
            //显示关闭文件 Dispose/Close
            fs.Dispose();
            fs.Close();
            fs.Write();
    

显示调用一个类型的Dispose或Close方法只是为了能在一个确定的时间强迫对象执行清理。 这两个方法并不能控制托管堆中的对象所占用的内存的生存期。这意味着即使一个对象已完成了清理,仍然可在它上面调用方法,但会抛出 ObjectDisposedException异常。

21.10 C#的using语句

如果决定显式地调用Dispose和Close这两个方法之一,强烈建议把它们放到一个异常处理finally块中。这样可以保证清理代码得到执行。

C#提供了一个using语句,这是一种简化的语法来获得上述效果。

using(FileStream fs = new FileStream())
{
                fs.Write();
}

在using语句中,我们初始化一个对象,并将它的引用保存到一个变量中。然后在using语句的大括号 内访问该变量。编译这段代码时,编译器自动生成一个try块和一个finally块。在finally块中,编译器会生成代码将变量转型成一个 IDispisable并调用Dispose方法。显然,using语句只能用于哪些实现了IDisposable接口的类型。

21.12手动监视和控制对象的生存期

CLR为每一个AppDomain都提供了一个GC句柄表 (GC Handle table) 。该表允许应用程序监视对象的生存期,或手动控制对象的生存期。

在一个AppDomain创建之初,该句柄表是空的。句柄表中的每个记录项都包含以下两种信息:一个指针,它指向托管堆上的一个对象;一个标志(flag),它指出你想如何监视或控制对象。

为了在这个表中添加或删除记录项,应用程序要使用System.Runtime.InteropServices.GCHandle类型。

21.13对象复活

前面说过,需要终结的一个对象被认为死亡时,垃圾回收器会强制该对象重生,使它的Finalize方法得以调用。Finalize方法调用之后,对象才真正的死亡。

需要终结的一个对象会经历死亡、重生、再死亡的“三部曲”。一个死亡的对象重生的过程称为复活(resurrection) 。复活一般不是一件好事,应避免写代码来利用CLR这个“功能”。

21.18   线程劫持

前面讨论的垃圾回收算法有一个很大的前提就是:只在一个线程运行。

在现实开发中,经常会出现多个线程同时访问托管堆的情况,或至少会有多个线程同时操作堆中的对象。一个线程引发垃圾回收时,其它线程绝对不能访问任何线程,因为垃圾回收器可能移动这些对象,更改它们的内存位置。

CLR想要进行垃圾回收时,会立即挂起执行托管代码中的所有线程,正在执行非托管代码的线程不会挂起。然后,CLR检查每个线程的指令指针,判断线程指向到哪里。接着,指令指针与JIT生成的表进行比较,判断线程正在执行什么代码。

如果线程的指令指针恰好在一个表中标记好的偏移位置,就说明该线程抵达了一个安全点。线程可在安全点安全地挂起,直至垃圾回收结束。如果线程指令指针不在表中标记的偏移位置,则表明该线程不在安全点,CLR也就不会开始垃圾回收。在这种情况下,CLR就会劫持该线程。也就是说,CLR会修改该线程栈,使该线程指向一个CLR内部的一个特殊函数。然后,线程恢复执行。当前的方法执行完后,他就会执行这个特殊函数,这个特殊函数会将该线程安全地挂起。

然 而,线程有时长时间执行当前所在方法。所以,当线程恢复执行后,大约有250毫秒的时间尝试劫持线程。过了这个时间,CLR会再次挂起线程,并检查该线程 的指令指针。如果线程已抵达一个安全点,垃圾回收就可以开始了。但是,如果线程还没有抵达一个安全点,CLR就检查是否调用了另一个方法。如果 是,CLR再一次修改线程栈,以便从最近执行的一个方法返回之后劫持线程。然后,CLR恢复线程,进行下一次劫持尝试。

所有线程都抵达安全点或被劫持之后,垃圾回收才能使用。垃圾回收完之后,所有线程都会恢复,应用程序继续运行,被劫持的线程返回最初调用它们的方法。

实际应用中,CLR大多数时候都是通过劫持线程来挂起线程,而不是根据JIT生成的表来判断线程是否到达了一个安全点。之所以如此,原因是JIT生成表需要大量内存,会增大工作集,进而严重影响性能。

21.20大对象

任何85000字节或更大的对象都被自动视为大对象(large object)

大对象从一个特殊的大对象堆中分配。这个堆中采取和前面小对象一样的方式终结和释放。但是,大对象永远不压缩(内存碎片整理),因为在堆中下移850000字节的内存块会浪费太多CPU时间。

大对象总是被认为是第2代的一部分,所以只能为需要长时间存活的资源创建大对象。如果分配短时间存活的大对象,将导致第2代被更频繁地回收,进而会损害性能。

posted @ 2016-06-09 18:22  Chris_在IT道路上前行  阅读(237)  评论(0编辑  收藏  举报