CLR via C#深解笔记七 - 自动内存管理(垃圾回收)

每个应用程序都要使用这样或者那样的资源,比如文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。事实上,在面向对象的环境中,每个类型都代表可供程序使用的一种资源。
要使用这些资源,必须为代表资源的类型分配内存。
 
访问一个资源所需的具体步骤如下:
#1,调用IL指令newobj, 为代表资源的类型分配内存。C#中使用new操作符,编译器就会自动生成该指令。
#2,初始化内存,设置资源的初始状态,使资源可用。类型的实例构造器负责设置该初始状态。
#3,访问类型的成员(可根据需要反复)来使用资源。
#4,摧毁资源的状态以进行清理。
#5,释放内存。垃圾回收将独自负责这一步。
 
需要注意的是,值类型(含所有枚举类型)、集合类型、String、Attribute、Delegate和Exception 所代表的资源无需执行特殊的清理操作。如,只要销毁对象的内存中维护的字符数组,一个String资源就会被完全清理。
CLR要求所有的资源都从托管堆(managed heap)分配。应用程序不需要的对象会被自动清除。那么“托管堆又是如何知道应用程序不再用一个对象?”
 
进程初始化时,CLR要保留一块连续的地址空间,这个地址空间最初并没有对象的物理内存空间。这个地址空间就是托管堆。托管堆还维护着一个指针,我把它称为NextObjPtr。指向下一个对象在堆中的分配位置。刚开始时候,NextObjPtr设为保留地址空间的基地址。
 
IL指令newobj用于创建一个对象。许多语言都提供了一个new操作符,它导致编译器在方法的IL代码中生成一个newobj指令。newobj指令将导致CLR执行如下步骤:
#1,计算类型(极其所有基类型)的字段需要的字节数。
#2,加上字段的开销所需的字节数。每个对象都有两个开销字段:一个是类型对象指针,和一个同步块索引。
#3,CLR检查保留区域是否能够提供分配对象所需的字节数,如有必要就提交存储(commit storage)。如果托管堆有足够的可用空间,对象会被放入。对象是在NextObjPtr指针指向的地址放入的,并且为它分配的字节会被清零。接着,调用类型的实例构造器(为this参数传递NextObjPtr), IL指令newobj(或者C# new 操作符)将返回对象的地址。就在地址返回之前,NextObjPtr指针的值会加上对象占据的字节数,这样会得到一个新值,它就指向下一个对象放入托管堆时的地址。
 
 
作为对比,让我们看一下C语言运行时堆如何分配内存,它为对象分配内存需要遍历一个由数据结构组成的链表,一旦发现一个足够大的块,那个块就会被拆分,同时修改链表节点中的指针,以确保链表的完整性。
对于托管堆,分配对象只需在一个指针上加一个值 - 这显然要快得多。事实上,从托管堆中分配对象的速度几乎可以与从线程栈分配内存媲美!
另外,大多数堆(C运行时堆)都是在他们找到可用空间的地方分配对象。所以,如果连续创建几个对象,这些对象极有可能被分散,中间相隔MB的地址空间。但在托管堆中,连续分配的对象可以确保它们在内存中是连续的。
托管堆似乎在实现的简单性和速度方面远远优于普通的堆,如C运行时堆。而托管堆之所以有这些好处,是因为它做了一个相当大胆的假设 - 地址空间和存储是无限的。而这个假设显然是不成立的,也就是说托管堆必须通过某种机制来允许它做这样的假设。这个机制就是垃圾回收器。
 
垃圾回收的工作原理
CLR的垃圾回收(garbage collection)。
应用程序调用new操作符创建对象时,可能没有足够的地址空间来分配对象。托管堆将对象需要的字节数加到NextObjPtr指针中的地址上来检测这种情况。如果结果值超过了地址空间的末尾,表明托管堆已满,必须执行一次垃圾回收。
 
重要提示:
前面的描述有些过于简单,事实上,垃圾回收是在第0代满的时候发生的。有的垃圾回收器使用了代(generation)的机制,该机制唯一的目的就是提升性能。其基本思路,在应用程序的生存期中,新建的对象是新一代,而创建得比较早的是老一代。第0代就是最近分配的对象,从未被垃圾回收算法检查过。在一次垃圾回收中,存活下来的对象被提升到另一代(如第1代)。将对象划分为代,使垃圾回收器能专注于回收特定的代,而不是每次都要回收托管堆中的对象。这里假设垃圾回收是在堆满的时候发生的。
 
垃圾回收器检查托管堆中是否有应用程序不再使用的任何对象。如果有,它们使用的内存就可以回收(若是垃圾回收之后,堆中仍然没有可用的内存,new操作符将会抛出一个OutOfMemoryException)。垃圾回收器如何知道应用程序正在使用一个对象?这的确不是一个三言两语就可以说清楚的问题。
 
每个应用程序都包含一组根(root)。每个根都是一个存储位置,其中包含指向引用类型对象的一个指针。该指针要么引用托管堆中的一个对象,要么为null。
如,类型中定义的任何静态字段被认为是一个根。除此之外,任何方法参数或局部变量也被认为是一个根。只有引用类型的变量才被认为是根;值类型的变量永远不被认为是根。
 
垃圾回收器会检查寄存器中引用的对象都是根,而这些根引用的堆中的对象不应被视为垃圾。除此之外,垃圾回收器还可以沿着线程的调用栈上行,检查每个方法的内部表来确定所有调用方法的根。最后,垃圾回收器遍历所有类型对象来获取静态字段中存储的根集合。
 
垃圾回收器开始执行时,它假设堆中所有对象都是垃圾。换句话说,它假设线程栈中没有引用了堆中对象的变量,没有CPU寄存器引用队中的对象,也没有静态字段引用堆中的对象。
 
第一阶段 - 标记
垃圾回收器的第一个阶段就是所谓的标记(marking)阶段。在这个阶段,垃圾回收器沿着线程栈上行以检查所有根。如果发现一个根引用了一个对象,就在对象的“同步块索引字段”上开启一位 (即设置一个bit,或者说设置为1)--对象就是这样“标记”的。若标记某一个对象时,发现这个对象(如D对象)含有一个引用了另一个对象(如H对象)的字段,会造成这个H对象也被标记。垃圾回收器就是如此,以递归的方式遍历所有可达的对象。
 
 
标记好根和它的字段引用的对象之后,垃圾回收器检查下一个根,并继续标记对象。如果垃圾回收器试图标记一个先前标记过的对象,就会停止沿着这个路径走下去。这个行为有两个目的。一是,垃圾回收器不会多次遍历一组对象,所以性能得到显著提高;而是,如果存在对象的循环链表,可以避免陷入无限循环。
检查好所有根之后,堆中将包含一组已标记和未标记的对象。已标记的对象是通过应用程序的代码可达的对象,而未标记的对象是不可达的。不可达的对象被认为是垃圾,它们占用的内存可以回收。
 
第二阶段 - 压缩
现在,垃圾回收器开始第二个阶段,即压缩(compact)阶段,在这个阶段中,垃圾回收器线性地遍历堆,以寻找未标记(垃圾)对象的连续内存块。(注意:此处“压缩”并非压缩,即托管堆,增多可用内存;相反,这里的压缩更接近于“碎片整理”,事实上,正确意思即“变得更加紧凑”。这个事实上,源于上世纪的80年开始,人们将compact看成是compress的近义词而翻译成“压缩”,以讹传讹至今。)
如果发现的内存块比较小,就忽略它们。但是,如果发现大的、可用的连续内存块,垃圾回收器会把非垃圾的对象移动到这里以压缩堆。
 
很自然,移动内存中的对象之后,包含"指向这些对象的指针"的变量和CPU寄存器现在都会变得无效。所以,垃圾回收器必须重新访问应用程序的所有根,并修改它们来指向对象的新内存位置。另外,如果对象中的字段指向的另一个已经移动了位置的对象,垃圾回收器也要负责改正这些字段。堆内存压缩之后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾对象之后的位置。如下所示,一次垃圾回收后的托管堆:
 
 
如你所见,垃圾回收会造成显著的性能损失,这是使用托管堆的主要特点。但要注意的是,垃圾回收只在第0代满的时候才会发生。在此之前,托管堆的性能远远高于C运行时堆。
最后,CLR的垃圾回收器提供了一些特殊的优化措施,可以大幅度提高垃圾回收的性能。
 
到这里,作为一名程序员,你应该从前面的论述得出四点重要的认识:
第一点,不必自己实现代码来管理应用程序所用的对象的生存期。垃圾回收机制使开发人员得到解放,无需关注内存释放,可以专注真正要解决的问题
第二点,不再发生对象泄露的情况,因为任何对象只要没有应用程序的根引用它,都会在某个时刻被垃圾回收器回收,所以应用程序将不可能再发生内存的泄露的情况。
第三点,应用程序也不再可能访问一个被释放的对象。因为,假如对象可达,就不会被释放;假如不可达,应用程序就是没得办法访问它。
第四点,因为垃圾回收导致了内存的压缩(compact),所以托管对象不可能造成进程的虚拟地址空间的碎片化。如果是非托管堆,如C运行时堆,地址空间的碎片化现象可能非常严重。然后,一个例外是在使用大对象的时候,仍然是有可能碎片化的。
 
重要提示:在负责加载类型的那个AppDomain卸载之前,类型的静态字段永远是它引用的任何 对象的根,造成内存泄露的一个常见原因就是让某个静态字段引用一个集合对象,然后不停地向集合对象添加数据项。静态字段保持集合对象的存活,而集合对象保持它的所有数据项的存活。有鉴于此,应该尽量避免使用静态字段。
 
使用终结操作来释放本地资源
大多数类型只需要内存就可以正常工作,但是也有一些类型除了要使用内存,还要使用本地资源。
如,System.IO.FileStream类型需要打开一个文件(本地资源)并保存文件的句柄。然后,该类型的Read和Write方法用该句柄来操作文件。
 
终结(finalization)是CLR提供的一种机制,允许对象在垃圾回收器回收其内存之前执行一些得体的清理工作。任何包装了本地资源(如文件,网络连接、套接字、互斥体或者其他类型)的类型都必须支持终结操作。
简单地说,类型实现了一个命名为Finalize的方法。当垃圾回收器判断一个对象是垃圾时,会调用对象的Finalize方法(如果有的话)。可以这样理解:实现了Finalize方法的任何类型实际上是在说,它的所有对象都希望在“被处决之前吃上最后一顿餐”。
 
Microsoft C#团队认为,Finalize方法是在编程语言中需要特殊语法的一种方法(类似于C#要求用特殊的语法定义构造器)。因此,在C#中,必须在类名前加一个~符号来定义Finalize方法,如下所示:
 
C#定义的Finalize方法的特殊语法非常类似于C++定义析构器的语法。事实上,在C#编程语言规范的早起版本中,真的是将该方法称为析构器。但是,Finalize方法的工作原理和非托管C++的析构器完全不同,这会使从一种语言迁移到另一种语言的开发人员产生极大的混淆。
 
实现了Finalize方法时,一般都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。例如,FileStream类型定义了一个文件句柄字段,它标识了本地资源。
FileStream类型还定义了一个Finalize方法,它在内部调用CloseHandle函数,并向它传递文件句柄字段。这就确保了在托管的FileStream对象被确定为垃圾后,本地文件句柄会得以关闭。若是包装了本地资源的类型没有定义Finalize方法,本地资源就得不到关闭,导致资源泄露,直至进程终止。进程终止时,这些本地资源才会被操作系统回收。
 
Finalize 方法是在垃圾回收器回收前调用,但是,CLR并不保证各个Finalize方法的调用顺序。
 
以下五种事件会导致垃圾回收:
 
#1,第0代满
第0代满时,垃圾回收会自动开始。该事件是目前导致Finalize方法被调用的最常见的一种方式,因为随着应用程序代码运行并分配新对象,这个事件会自然而然地发生。
 
#2, 代码显式调用System.GC的静态方法Collect
代码可以显式请求CLR执行垃圾回收,虽然Microsoft强烈建议不要这样做,但某些时候还是有必要的。
 
#3, Windows报告内存不足
CLR内部使用Win32的CreateMemoryResourceNotification和QueryMemoryResourceNotification 函数来监视系统的总体内存。如果Windows报告CLR内存不足,CLR将强制执行垃圾回收,即尝试释放已经死亡的对象,从而减小进程工作集的大小。
 
#4, CLR卸载AppDomain
一个AppDomain被卸载时,CLR认为该AppDomain中不再存在任何根,因此会对所有代码的对象执行垃圾回收。
 
#5, CLR关闭
一个进程正常终止时(相对于从外部关闭,比如通过任务管理器关闭),CLR就会关闭。在关闭过程中,CLR会认为该进程中不存在任何根,因此会调用托管堆中的所有对象的Finalize方法。注意,CLR此时不会尝试压缩或释放内存,因为整个进程都要终止,将由Windows负责回收进程的所有内存。
 
CLR使用一个特殊的,专用的线程来调用Finalize方法。对于前4种事件,如果一个Finalize方法进入了无限循环,这个特殊的线程会被阻塞(blocked),其他Finalize方法将得不到调用。这种情况非常糟糕,因为应用程序永远都不能回收由可终结的对象占据的内存 - 只要应用程序运行,就会一直泄露内存。
 
对于第5种事件,每个Finalize方法有大约2秒钟的时间返回。如果Finalize方法在2秒钟内没有返回,CLR将直接杀死(结束)该进程 - 不会调用更多的Finalize方法。另外,如果调用所有对象的Finalize方法的时间超过了40秒钟(也许以后会改变这个值),CLR也会杀死进程。
 
补充介绍:
#1,如果AppDomain卸载,其AppDomain的IsFinalizingForUnload 方法将返回true。
#2,如果进程终止,System.Enviroment.HasShutdownStarted属性将返回true。
 
终结操作的秘密
终结操作似乎很简单: 创建一个对象,当它被回收时,它的Finalize方法会得到调用。而若深究,你会发现远非这么简单。
应用程序创建一个新对象时候,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的一个指针放到一个终结列表(finalization list)中。
终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一个项都指向一个对象 - 在回收该对象的内存之前,应该调用它的Finalize方法。
 
 
注意
System.Object定义了一个Finalize方法,虽然如此,但是CLR会忽略它。也就是说,构造一个类型的实例时,如果该类型的Finalize方法是从System.Object继承的,就不认为这个对象是“可终结”的。类型必须重写Object的Finalize方法,这个类型及其派生类型的对象才被认为是“可终结”的 。
 
如上图,垃圾回收开始,对象B, E, G, H, I 和J被判定为垃圾。垃圾回收器扫描终结列表以查找指向这些对象的指针。找到一个指针后,该指针会从终结列表中移除,并追加到freachable队列中。freachable队列(发音是“F-reachable”)是垃圾回收器的另一个内部数据结构。它中的每个指针都代表其Finalize方法已经准备好调用的一个对象。垃圾回收完毕后托管堆的情况:
 
 
可以看出,对象B, G和H占用的内存已经被回收,因为它们没有Finalize方法。但是,对象E, I和J占用的内存暂时不能回收,因为它们的Finalize方法还没有调用。
 
重要信息:
终结列表和freachable队列之间的交互非常有意思。
首先,让我告诉你freachable队列这个名称的由来。“f”明显代表“终结”(finalization);freachable队列中的每个记录项都是对托管堆中的一个对象的引用,该对象的Finalize方法应该被调用。“reachable”意味着对象是可达的。换言之,可将freachable队列看成是像静态字段那样的一个根。因此,如果一个对象在freachable队列中,它就是可达的,不是垃圾
简单地说,当一个对象不可达,垃圾回收器就把它视为垃圾。但是,当垃圾回收器将对象的引用从终结列表移至freachable队列时,对象不再被认为是垃圾,其内存不能被回收。标记freachable对象时,这些对象的引用类型的字段也会被递归地标记;所有这些对象都会在垃圾回收过程中存活下来。到这个时候,垃圾回收器才结束对垃圾的标识。由于一些原本被认为是垃圾的对象被重新认为不是垃圾,所以从某种意义上说,这些对象“复活”了。然后,垃圾回收器开始压缩(compact, 使得紧凑)可回收的内存,特殊的CLR线程清空freachable队列,并执行每个对象的Finalize方法。
 
垃圾回收器下一次调用时,会发现已经终结的对象称为真正的垃圾,因为应用程序的根不再指向它,freachable队列也不再指向它。所以,这些对象的内存会直接回收。整个过程中,注意可终结的对象需要执行两次垃圾回收才能释放它们占用的内存。实际应用中,由于对象可能被提升至另一代,所以可能要求不止进行两次垃圾回收(代的问题以后详述)。如下图展示了第二次垃圾回收后托管堆的情况:
 
 
Dispose 模式:强制对象清理资源
Finalize方法非常有用,因为它确保了当托管对象的内存被释放时,本地资源不会泄露。但是,Finalize方法问题在于,它的调用时间是不能保证的。另外,由于它不是公共方法,所以类的用户不能显示调用它。
 
使用包装了本地资源(比如文件、数据库连接和位图等)的托管类型时,确定性地dispose或关闭对象的能力通常都是很有用的。例如,你可能想打开一个数据库连接,查询一些记录,然后关闭该数据库连接 -- 在发生下一次垃圾回收之前,你不希望数据库连接一直处于打开状态,尤其是下一次垃圾回收可能在你获取了数据库记录的几小时或者几天之后才会发生。
 
类型为了提供确定性dispose或者关闭对象的能力,要实现所谓的Dispose模式。类型为了提供显式进行资源清理的能力,必须遵守Dispose模式定义的规范。除此之外,如果一个类型实现了Dispose模式,使用该类型的开发人员就可以准确地知道在对象不需要时,如何显式地dispose它。
 
注意:
所有定义了Finalize方法的类型都应同时实现本节描述的Dispose模式,使类型的用户对资源的生存期有更多的控制,但是,类型也可实现Dispose模式,但不定义Finalize方法。例如,System.IO.BinaryWriter就是这样的类型。
 
System.IDispose的定义如下:
public interface IDispose
{
  void Dispose();
}
 
任何类型只要实现了该接口,就相当于声称自己遵循Dispose模式。简单地说,这意味着类型提供了一个公共无参Dispose方法,可显式调用它来释放对象包装的资源。注意,对象本身的内存不会从托管堆的内存中释放,仍然要由垃圾回收器负责释放对象的内存,而且具体时间不定。提供了Dispose模式的一些类型为了方便起见,还提供了一个Close方法,而只是调用Dispose方法,但这对于Dispose模式来说并非必须。如System.IO.FileStream类提供了Dispose模式,也提供了Close方法。然而,System.Threading.Timer类就没有提供Close方法,虽然它也遵循Dispose模式。
 
 
实现了Dispose模式的类型
 
 
 
 
 
C# 的using语句
 
 
说明:
dispose,在英语语境中,它的意思是“摆脱”或者“除去”(get rid of)一个东西,尤其是在这个东西很难除去的情况下。
在.NET Framework 文档中,它的官方翻译是“释放”,意思是显式释放或者清理对象包装的资源。
之所以认为“释放”不恰当,除了和release一词冲突之外,还因为dispose强调了“清理资源”,而且在完成(它包装)资源的清理之后,对象本身的内存并不会释放。
所以,“dispose一个对象”或者"close一个对象" 真正的意思是: 清理对象中包装的资源(比如它的字段所引用的对象),然后等待垃圾回收器自动回收该对象本身所占用的内存。
posted @ 2016-02-03 00:44  杰克帝.NET  阅读(1272)  评论(0编辑  收藏  举报