CLR via C#, 4th -- 【核心机制】 -- 第21章托管堆和垃圾回收
21.1 托管堆基础
访问一个资源所需的步骤
1,调用IL指令newobj,为代表资源的类型分配内存(一般使用C#new操作符来完成)。
2,初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始状态。
3,访问类型的成员来使用资源(有必要可以重复)。
4,摧毁资源的状态以进行清理。
5,释放内存。垃圾回收器独自负责这一步。
有多少次程序员忘记释放不再需要的内存而造成内存泄漏?又有多少次试图使用已经释放的内存,然后由于内存被破坏而造成程序错误和安全漏洞?
使用需要特殊清理的类型时,编程模型还是像刚才描述的那样简单。只是有时需要尽快清里资源,而不是非要等着GC 介入。可在这些类中调用一个额外的方法(称为Dispose),按照自己的节奏清理资源。另一方面,实现这样的类需要考虑到较多的问题,一般只有包装了本机资源(文件、套接字和数据库连接等程序外部的资源)的类型才需要特殊清理。
21.1.1从托管堆分配资源
托管堆
进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR要求所有对象都从托管堆分配。CLR还要维护一个指针,我把它称作NextObjPtr,该指针指向下一个对象在堆中的分配位置。刚开始的时候,NextObjPtr设为地址空间区域的基地址。
C#的new操作符导致CLR执行以下步骤
1,计算类型的字段(以及从基类型继承的字段)所需的字节数。
2,加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。
3,CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在NextObjPtr指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象引用。就在返回这个引用之前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。
由于托管堆在内存中连续分配这些对象,所以会因为引用的“局部化”(locality)而获得性能上的提升。具体地说,这意味着进程的工作集会非常小,应用程序只需使用很少的内存,从而提高了速度。还意味着代码使用的对象可以全部驻留在CPU的缓存中。
21.1.2垃圾回收算法
至于对象生存期的管理,有的系统采用的是某种引用计数算法。但许多引用计数系统最大的问题是处理不好循环引用。
鉴于引用计数垃圾回收器算法存在的问题,CLR改为使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。
根
我们将所有引用类型的变量都称为根。
GC的标记阶段
CLR开始GC时,首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态。然后,CLR进入GC的标记阶段。在这个阶段,CLR遍历堆中的所有对象,将同步块索引字段中的一位设为0。这表明所有对象都应删除。然后,CLR检查所有活动根,查看它们引用了哪些对象。这正是CLR的GC称为引用跟踪GC的原因。如果一个根包含null,CLR忽略这个根并继续检查下个根。
任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1。一个对象被标记后,CLR会检查那个对象中的根,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了因为循环引用而产生死循环。

可达(reachable)
已标记的对象不能被垃圾回收,因为至少有一个根在引用它。我们说这种对象是可达(reachable)的,因为应用程序代码可通过仍在引用它的变量抵达(或访问)它。
不可达(unreachable)
未标记的对象是不可达(unreachable)的,因为应用程序中不存在使对象能被再次访问的根。
GC的压缩(compact)阶段
CLR知道哪些对象可以幸存,哪些可以删除后,就进入GC的压缩(compact)阶段。在这个阶段,CLR对堆中已标记的对象进行移动,压缩所有幸存下来的对象,使它们占用连续的内存空间。
这样做有许多好处。首先,所有幸存对象在内存中紧挨在一起,恢复了引用的“局部化”,减小了应用程序的工作集,从而提升了将来访问这些对象时的性能。其实,可用空间也全部是连续的,所以这个地址空间区段得到了解放,允许其他东西进驻。最后,压缩意味着托管堆解决了本机(原生)堆的空间碎片化问题。
在内存中移动了对象之后有一个问题有待解决。引用幸存对象的根现在引用的还是对象最初在内存中的位置,而非移动之后的位置。被暂停的线程恢复执行时,将访问旧的内存位置,会造成内存损坏。这显然不能容忍的,所以作为压缩阶段的一部分,CLR还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象:只是对象在内存中变换了位置。

如果CLR在一次GC之后回收不了内存,而且进程中没有空间来分配新的GC区域,就说明该进程的内存已耗尽。此时,试图分配更多内存的new操作符会抛出OutOMemoryException。应用程序可捕捉该异常并从中恢复。但大多数应用程序都不会这么做:相反,异常会成为未处理异常,Windows将终止进程并回收进程使用的全部内存。
作为程序员,应注意本章开头描述的两个bug不复存在了。首先,内存不可能泄漏,因为从应用程序的根访问不了任何对象,都会在某个时间被垃圾回收。其次,不可能因为访问被释放的内存而造成内存损坏,因为现在只能引用活动对象;非活动的对象是引用不了的。
静态字段引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地向集合添加数据项。
21.1.3 垃圾回收和调试
using System; using System.Threading; public static class Program { public static void Main() { // Create a Timer object that knows to call our TimerCallback // method once every 2000 milliseconds. Timer t = new Timer(TimerCallback, null, 0, 2000); // Wait for the user to hit <Enter>. Console.ReadLine(); } private static void TimerCallback(Object o) { // Display the date/time when this method got called. Console.WriteLine("In TimerCallback: " + DateTime.Now); // Force a garbage collection to occur for this demo. GC.Collect(); } }
观察代码,可能以为TimerCallback方法每隔2000毫秒调用一次。毕竟,代码创建了一个Timer对象,而且有一个变量t引用该对象。只要计时器对象存在,计时器就应该一直触发。但要注意,TimerCallback方法调用GC.Collect()强制执行了一次垃圾回收。
回收开始时,垃圾回收器首先假定堆中的所有对象都是不可达的(垃圾);这自然也包括Timer对象。然后,垃圾回收器检查应用程序的根,发现在初始化之后,Main方法再也没有用过变量t。既然应用程序没有任何变量引用Timer对象,垃圾回收自然会回收分配给它的内存:这使计时器停止触发,并解释了为什么TimerCallback方法只被调用了一次。
现在,假定用调试器单步调试Main,而且在将新Timer对象的地址赋给t之后,立即发生了一次垃圾回收。然后,用调试器的“监视”窗口查看t引用的对象,会发生什么事情呢?因为对象已被回收,所以调试器无法显示该对象。大多数开发人员都没有料到这个结果,认为不合常理。所以,Microsoft提出了一个解决方案。
使用C编译器的/debug开关编译程序集时,编译器会应用System.Diagnostics.DebuggableAttribute,并为结果程序集设置DebuggingModes的DisableOptimizations标志。运行时编译方法时JIT编译器看到这个标志,会将所有根的生存期延长至方法结束。在我的例子中,JIT编译器认为Main的t变量必须存活至方法结束。所以在垃圾回收时,GC认为t仍然是一个根,t引用的Timer对象仍然“可达”,Timer对象会在回收中存活,TimerCallback方法会被反复调用,直至Console.ReadLine方法返回而且Main方法退出。
这很容易验证,只需在命令行中重新编译程序,但这一次指定C#编译器的/debug开关。运行可执行文件,会看到TimerCallback方法被反复调用。注意,C#编译器的/optimize+编译器开关会将DisableOptimizations禁止的优化重新恢复,所以实验时不要指定该开关。
试着像下面这样修改Main方法:
public static void Main() { // Create a Timer object that knows to call our TimerCallback // method once every 2000 milliseconds. Timer t = new Timer(TimerCallback, null, 0, 2000); // Wait for the user to hit <Enter>. Console.ReadLine(); // Refer to t after ReadLine (this gets optimized away) t = null; }
但编译上述代码(无/debug+开关),并运行可执行文件,会看到TimerCallback方法仍然只被调用了一次。问题在于,JIT编译器是一个优化编译器,将局部变量或参数变量设为null,等价于根本不引用该变量。换言之,JIT编译器会将t= null;整行代码删除(优化掉)。所以,程序仍然不会按期望的方式工作。
下面才是Main方法的正确修改方式:
public static void Main() { // Create a Timer object that knows to call our TimerCallback // method once every 2000 milliseconds. Timer t = new Timer(TimerCallback, null, 0, 2000); // Wait for the user to hit <Enter>. Console.ReadLine(); // Refer to t after ReadLine (t will survive GCs until Dispose returns) t.Dispose(); }
现在编译代码(无/debug+编译器开关)并运行可执行文件,会发现TimerCallback方法被正确地重复调用,程序终于得到修正。现在发生的事情是,t引用的对象必须存活,才能在它上面调用Dispose实例方法(t中的值要作为this实参传给Dispose),真是讽刺,要显式要求释放计时器,它才能活到被释放的那一刻。
21.2 代:提升性能
CLR的GC是基于代的垃圾回收器(generational garbage collector)
托管堆在初始化时不包含对象。添加到堆的对象称为第0代对象。简单地说,第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。

CLR初始化时为第0代对象选择一个预算容量(以KB为单位)。如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。
在垃圾回收中存活的对象现在成为第1代对象。第1代对象已经经历了垃圾回收器的一次检查。事实上,它还必须为第1代选择预算。

一次垃圾回收后,第0代就不包含任何对象了。和前面一样,新对象会分配到第0代中。

开始一次垃圾回收时,垃圾回收器还会检查第1代占用了多少内存。在本例中,由于第1代占用的内存远少于预算,所以垃圾回收器只检查第0代中的对象。回顾一下基于代的垃圾回收器做出的假设。第一个假设是越新的对象活得越短。因此,第0代包含更多垃圾的可能性很大,能回收更多的内存。由于忽略了第1代中的对象,所以加快了垃圾回收速度。
基于代的垃圾回收器还假设越老的对象活得越长。也就是说,第1代对象在应用程序中很有可能是继续可达的。如果垃圾回收器检查第1代中的对象,很有可能找不到多少垃圾,结果是回收不了多少内存。因此,对第1代进行垃圾回收很可能是浪费时间。如果真的有垃圾在第1代中,它将留在那里。

随着第1代正在缓慢增长。假定第1代的增长导致它的所有对象占用了全部预算。
当应用程序试图分配对象时,由于第0代已满,所以必须开始垃圾回收。但这一次垃圾回收器发现第1代占用了太多内存,以至于用完了预算。由于前几次对第0代进行回收时,第1代可能已经有许多对象变得不可达。所以这次垃圾回收器决定检查第1代和第0代中的所有对象。

和之前一样,垃圾回收后,第0代的幸存者被提升至第1代,第1代的幸存者被提升至第2代,第0代再次空出来了,准备好迎接新对象的到来。第2代中的对象经过了2次或更多次检查。虽然到目前为止已发生过多次垃圾回收,但只有在第1代超出预算时才会检查第1代中的对象。而在此之前,一般都已经对第0代进行了好几次垃圾回收。
托管堆只支持三代:第0代、第1代和第2代。
事实上,如果第0代中的所有对象都是垃圾,垃圾回收时就不必压缩(移动)任何内存;只需让NextObjPtr指针指回第0代的起始处即可。
21.2.1 垃圾回收触发条件
前面说过,CLR在检测第0代超过预算时触发一次GC。
- 代码显式调用System.GC的静态Collect方法
- Windows报告低内存情况
- CLR正在卸载AppDomain
- CLR在进程正常终止时关闭。
21.2.2 大对象
CLR将对象分为大对象和小对象。本章到目前为止说的都是小对象。目前认为85000字节或更大的对象是大对象。
CLR以不同方式对待大小对象:
- 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。
- 目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。但这可能在进程中的大对象之间造成地址空间的碎片化,以至于抛出OutOMemoryException.CLR将来的版本可能压缩大对象。
- 大对象总是第2代,绝不可能是第0代或第1代。所以只能为需要长时间存活的资源创建大对象。分配短时间存活的大对象会导致第2代被更频繁地回收,会损害性能。大对象一般是大字符串(比如XML或JSON)或者用于10操作的字节数组(比如从文件或网络将字节读入缓冲区以便处理)。
21.2.3垃圾回收模式
工作站
该模式针对客户端应用程序优化GC.GC造成的延时很低,应用程序线程挂起时间很短,避免使用户感到焦虑。在该模式中,GC假定机器上运行的其他应用程序都不会消耗太多的CPU资源。
服务器
该模式针对服务器端应用程序优化GC。被优化的主要是吞吐量和资源利用。GC假定机器上没有运行其他应用程序(无论客户端还是服务器应用程序),并假定机器的所有CPU都可用来辅助完成GC。该模式造成托管堆被拆分成几个区域(section),每个CPU一个。开始垃圾回收时,垃圾回收器在每个CPU上都运行一个特殊线程;每个线程都和其他线程并发回收它自己的区域。对于工作者线程(worker thread)行为一致的服务器应用程序,并发回收能很好地进行。这个功能要求应用程序在多CPU计算机上运行,使线程能真正地同时工作,从而获得性能的提升。
应用程序默认以“工作站”GC模式运行。寄宿了CLR的服务器应用程序(比如ASP.NET或Microsoft SQL Server)可请求CLR加载“服务器”GC.但如果服务器应用程序在单处理器计算机上运行,CLR将总是使用“工作站"GC模式。
独立应用程序可创建一个配置文件告诉CLR使用服务器回收器。配置文件要为应用程序添加一个gcServer元素。下面是一个示例配置文件:
<configuration> <runtime> <gcServer enabled="true"/> </runtime> </configuration>
应用程序运行时,可查询GCSettings类的只读Boolean属性IsServerGC来询问CLR它是否正在“服务器”GC模式中运行:
using System; using System.Runtime; // GCSettings is in this namespace public static class Program { public static void Main() { Console.WriteLine("Application is running with server GC=" + GCSettings.IsServerGC); } }
除了这两种主要模式,GC还支持两种子模式:并发(默认)或非并发。
在并发方式中,垃圾回收器有一个额外的后台线程,它能在应用程序运行时并发标记对象。一个线程因为分配对象造成第0代超出预算时,GC首先挂起所有线程,再判断要回收哪些代。
事实上,垃圾回收器更倾向于选择不压缩。可用内存多,垃圾回收器便不会压缩堆;这有利于增强性能,但会增大应用程序的工作集。
为了告诉CLR不要使用并发回收器,可创建包含geConcurrent元素的应用程序配置文件。下面是配置文件的一个例子:
<configuration> <runtime> <gcConcurrent enabled="false"/> </runtime> </configuration>
GC模式是针对进程配置的,进程运行期间不能更改。但是,你的应用程序可以使用GCSettings类的GCLatencyMode属性对垃圾回收进行某种程度的控制。
TABLE 21-1 Symbols Defined by the GCLatencyMode Enumerated Type
|
Symbol Name |
Description |
|
Batch (default for |
Turns off the concurrent GC. |
|
Interactive (default for |
Turns on the concurrent GC. |
|
LowLatency |
Use this latency mode during short-term, time-sensitive operations (like drawing animations) where a generation 2 collection might be disruptive. |
|
SustainedLowLatency |
Use this latency mode to avoid long GC pauses for the bulk of your application’s execu-tion. This setting prevents all blocking generation 2 collections from occurring as long as memory is available. In fact, users of these applications would prefer to install more RAM in the machine in order to avoid GC pauses. A stock market application that must respond immediately to price changes is an example of this kind of application. |
LowLatency模式有必要多说几句。一般用它执行一次短期的、时间敏感的操作,再将模式设回普通的Batch或Interactive。在模式设为LowLatency期间,垃圾回收器会全力避免任何第2代回收,因为那样花费的时间较多。当然,调用GC.Collect0)仍会回收第2代。
此外,如果Windows告诉CLR系统内存低,GC也会回收第2代。
21.2.4 强制垃圾回收
可调用GC类的Collect方法强制垃圾回收。可向方法传递一个代表最多回收几代的整数、一个GCCollectionMode以及指定阻塞(非并发)或后台(并发)回收的一个Boolean值。以下是最复杂的Collect重载的签名:
void Collect(Int32 generation, GCCollectionMode mode, Boolean blocking);
TABLE 21-2 Symbols Defined by the GCCollectionMode Enumerated Type
|
Symbol Name |
Description |
|
Default |
The same as calling GC.Collect with no flag. Today, this is the same as passing Forced, but this may change in a future version of the CLR. |
|
Forced |
Forces a collection to occur immediately for all generations up to and including the specified generation. |
|
Optimized |
The garbage collector will only perform a collection if the collection would be productive either by freeing a lot of memory or by reducing fragmentation. If the garbage collection would not be productive, then the call has no effect |
21.2.5 监视应用程序的内存使用
安装.NET Framework时会自动安装一组性能计数器,为CLR的操作提供大量实时统计数据。这些统计数据可通过Windows自带的PerfMon.exe工具或者“系统监视器"Activex控件来查看。为了监视CLR的垃圾回收器,请选择"NET CLR Memory"性能对象。
还有PerfView工具可分析内存和应用程序的性能,该工具能收集"Windows事件跟踪”(Event Tracing for Windows,ETW)日志并处理它们。
21.3 使用需要特殊清理的类型
例如,System.IO.FileStream类型需要打开一个文件(本机资源)并保存文件的句柄。
句柄(handle)
第一种解释:句柄是一种特殊的智能指针 。当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,就要使用句柄。
第二种解释:整个Windows编程的基础。一个句柄是指使用的一个唯一的整数值,即一个4字节(64位程序中为8字节)长的数值,来标识应用程序中的不同对象和同类中的不同的实例,诸如,一个窗口,按钮,图标,滚动条,输出设备,控件或者文件等。
句柄是Windows用来标志应用程序中建立的或是使用的唯一整数,Windows大量使用了句柄来标识对象。
终结(finalization)
包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源(GC对它一无所知)的泄漏,这当然是不允许的。所以,CLR提供了称为终结(finalization)的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,GC会从托管堆回收对象。
终极基类System.Object定义了受保护的虚方法Finalize,垃圾回收器判定对象是垃圾后,会调用对象的Finalize方法(如果重写)。
C#要求在类名前添加~符号来定义Finalize方法
internal sealed class SomeType { // This is the Finalize method ~SomeType() { // The code here is inside the Finalize method } }
创建封装了本机资源的托管类型
创建封装了本机资源的托管类型时,应该先从System.Runtime.InteropServices.SafeHandle这个特殊基类派生出一个类。
SafeHandle类有两点需要注意
其一,它派生自CriticalFinalizerObject;后者在System.Runtime.ConstrainedExecution命名空间定义。CLR以特殊方式对待这个类及其派生类。
其二,SafeHandle是抽象类,必须有另一个类从该类派生并重写受保护的构造器、抽象方法ReleaseHandle以及抽象属性IsInvalid的get访问器方法。
SafeHandle派生类非常有用,因为它们保证本机资源在垃圾回收时得以释放。
SafeHandle派生类最后一个值得注意的功能是防止有人利用潜在的安全漏洞。问题起因是一个线程可能试图使用一个本机资源,而另一个线程试图释放该资源。这可能造成句柄循环使用漏洞。SafeHandle类防范这个安全隐患的办法是使用引用计数。
System.Runtime.InteropServices命名空间还定义了一个CriticalHandle类。该类除了不提供引用计数器功能,其他方面与SafeHandle类相同。CriticalHandle类及其派生类通过牺牲安全性来换取更好的性能(因为不用操作计数器)。
21.3.1 使用包装了本机资源的类型
以常用的System.IO.FileStream类为例,可利用它打开一个文件,从文件中读取字节,向文件写入字节,然后关闭文件。FileStream对象在构造时会调用Win32 CreateFile函数,函数返回的句柄保存到SafeFileHandle对象中,然后通过FileStream对象的一个私有字段来维护对该对象的引用。FileStream类还提供子几个额外的属性(例如Length,Position,CanRead)和方法(例如Read,Write,Flush)。
using System; using System.IO; public static class Program { public static void Main() { // Create the bytes to write to the temporary file. Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 }; // Create the temporary file. FileStream fs = new FileStream("Temp.dat", FileMode.Create); // Write the bytes to the temporary file. fs.Write(bytesToWrite, 0, bytesToWrite.Length); // Delete the temporary file. File.Delete("Temp.dat"); // Throws an IOException } }
遗憾的是,生成并运行上述代码,它也许能工作,但大多数时候都不能。问题在于File的静态Delete方法要求Windows删除一个仍然打开的文件。所以Delete方法会抛出System.IO.IOException异常,并显示以下字符串消息:文件"Temp.dat"正由另一进程使用,因此该进程无法访问此文件。
类如果想允许使用者控制类所包装的本机资源的生存期,就必须实现如下所示的IDisposable 接口。
幸好,FileStream类实现了IDisposable接口。在实现中、会在FileStream对象的私有SafeFileHandle字段上调用Dispose。现在就能修改代码来显式关闭文件,而不是等着未来某个时候GC的发生。下面是修改后的源代码。
using System; using System.IO; public static class Program { public static void Main() { // Create the bytes to write to the temporary file. Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 }; // Create the temporary file. FileStream fs = new FileStream("Temp.dat", FileMode.Create); // Write the bytes to the temporary file. fs.Write(bytesToWrite, 0, bytesToWrite.Length); // Explicitly close the file when finished writing to it. fs.Dispose(); // Delete the temporary file. File.Delete("Temp.dat"); // This always works now. } }
注意,并非一定要调用Dispose才能保证本机资源得以清理。本机资源的清理最终总会发生,调用Dispose只是控制这个清理动作的发生时间。另外,调用Dispose不会将托管对象从托管堆删除。只有在垃圾回收之后,托管堆中的内存才会得以回收。这意味着即使dispose了托管对象过去用过的任何本机资源,也能在托管对象上调用方法。
public static class Program { public static void Main() { // Create the bytes to write to the temporary file. Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 }; // Create the temporary file. FileStream fs = new FileStream("Temp.dat", FileMode.Create); // Write the bytes to the temporary file. fs.Write(bytesToWrite, 0, bytesToWrite.Length); // Explicitly close the file when finished writing to it. fs.Dispose(); // Try to write to the file after closing it. fs.Write(bytesToWrite, 0, bytesToWrite.Length); // Throws ObjectDisposedException // Delete the temporary file. File.Delete("Temp.dat"); } }
这不会造成对内存的破坏,因为FileStream对象的内存依然“健在”。只是在对象被显式ispose之后,它的方法不能再成功执行而已。
using System; using System.IO; public static class Program { public static void Main() { // Create the bytes to write to the temporary file. Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 }; // Create the temporary file. using (FileStream fs = new FileStream("Temp.dat", FileMode.Create)) { // Write the bytes to the temporary file. fs.Write(bytesToWrite, 0, bytesToWrite.Length); } // Delete the temporary file. File.Delete("Temp.dat"); } }
using语句初始化一个对象,并将它的引用保存到一个变量中。然后在using语句的大括号内访问该变量。编译这段代码时,编译器自动生成try块和finally块。在finally块中,编译器生成代码将变量转型为一个IDisposable并调用Dispose方法。但是很显然,using语句只能用于那些实现了IDisposable接口的类型。
21.3.2 一个有趣的依赖性问题
FileStream fs = new FileStream("DataFile.dat", FileMode.Create); StreamWriter sw = new StreamWriter(fs); sw.Write("Hi there"); // The following call to Dispose is what you should do. sw.Dispose(); // NOTE: StreamWriter.Dispose closes the FileStream; // the FileStream doesn't have to be explicitly closed.
没有代码显式调用Dispose会发生什么?在某个时刻,垃圾回收器会正确检测到对象是垃圾,并对其进行终结。但垃圾回收器不保证对象的终结顺序。所以,如果FileStream对象先终结,就会关闭文件。然后,当StreamWriter对象终结时,会试图向已关闭的文件写入数据,造成抛出异常。相反,如果是StreamWriter对象先终结,数据就会安全写入文件。
Microsoft是如何解决这个问题的呢?让垃圾回收器以特定顺序终结对象是不可能的,因为不同的对象可能包含相互之间的引用,垃圾回收器无法正确猜出这些对象的终结顺序。Microsoft的解决方案是:StreamWriter类型不支持终结,所以永远不会将它的缓冲区中的数据flush到底层FileStream对象。这意味着如果忘记在StreamWriter对象上显式调用Dispose,数据肯定会丢失。Microsoft希望开发人员注意到这个数据一直丢失的问题,并插入对Dispose的调用来修正代码。
21.3.3 GC为本机资源提供的其他功能
本机资源有时会消耗大量内存,但用于包装它的托管对象只占用很少的内存。一个典型的例子就是位图。一个位图可能占用几兆字节的本机内存,托管对象却极小,只包含一个HBITMAP(一个4或8字节的值)。如果进程操作许多位图,进程的内存消耗将以一个恐怖的速度增长。为了修正这个问题,GC类提供了以下两个静态方法:
public static void AddMemoryPressure(Int64 bytesAllocated); public static void RemoveMemoryPressure(Int64 bytesAllocated);
如果一个类要包装可能很大的本机资源,就应该使用这些方法提示垃圾回收器实际需要消耗多少内存。垃圾回收器内部会监视内存压力,压力变大时,就强制执行垃圾回收。
有的本机资源的数量是固定的。例如,Windows以前就限制只能创建5个设备上下文。应用程序能打开的文件数量也必须有限制。如果这些本机资源的数量有限,那么一旦试图使用超过允许数量的资源,通常会导致抛出异常。为了解决这个问题,命名空间System.Runtime.InteropServices提供了HandleCollector类。
如果一个类要包装数量有限制的本机资源,就应该使用该类的实例来提示垃圾回收器实际要使用资源的多少个实例。该类的对象会在内部监视这个计数,计数太大就强制垃圾回收。
21.3.4终结的内部工作原理
终结列表(finalization list)
如果对象的类型定义了Finalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放到一个终结列表(finalization list)中。终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象-回收该对象的内存前应调用它的Finalize方法。
注意 虽然System.Object定义了Finalize方法,但CLR知道忽略它。也就是说,构造类型的实例时,如果该类型的Finalize方法是从System.Object继承的,就不认为这个对象是“可终结”的。类型必须重写Object的Finalize方法,这个类型及其派生类型的对象才被认为是“可终结”的。
freachable队列
垃圾回收开始时,如果对象被判定为垃圾。垃圾回收器扫描终结列表以查找对这些对象的引用。找到一个引用后,该引用会从终结列表中移除,并附加到freachable队列。freachable队列(发音是"F-reachable")也是垃圾回收器的一种内部数据结构。队列中的每个引用都代表其Finalize方法已准备好调用的一个对象。
21.3.5 手动监视和控制对象的生存期
CLR为每个AppDomain都提供了一个GC句柄表(GC Handle table),允许应用程序监视或手动控制对象的生存期。这个表在AppDomain创建之初是空白的。表中每个记录项都包含以下两种信息:对托管堆中的一个对象的引用,以及指出如何监视或控制对象的标志(flag)。
应用程序使用如下所示的System.Runtime.InteropServices.GCHandle类型在表中添加或册除记录项。
// This type is defined in the System.Runtime.InteropServices namespace public struct GCHandle { // Static methods that create an entry in the table public static GCHandle Alloc(object value); public static GCHandle Alloc(object value, GCHandleType type); // Static methods that convert a GCHandle to an IntPtr public static explicit operator IntPtr(GCHandle value); public static IntPtr ToIntPtr(GCHandle value); // Static methods that convert an IntPtr to a GCHandle public static explicit operator GCHandle(IntPtr value); public static GCHandle FromIntPtr(IntPtr value); // Static methods that compare two GCHandles public static Boolean operator ==(GCHandle a, GCHandle b); public static Boolean operator !=(GCHandle a, GCHandle b); // Instance method to free the entry in the table (index is set to 0) public void Free(); // Instance property to get/set the entry's object reference public object Target { get; set; } // Instance property that returns true if index is not 0 public Boolean IsAllocated { get; } // For a pinned entry, this returns the address of the object public IntPtr AddrOfPinnedObject(); public override Int32 GetHashCode(); public override Boolean Equals(object o); }
简单地说,为了控制或监视对象的生存期,可调用GCHandle的静态Alloc方法并传递想控制/监视的对象的引用。还可传递一个GCHandleType,这是一个标志,指定了你想如何控制/监视对象。GCHandleType是枚举类型,它的定义如下所示:
public enum GCHandleType { Weak = 0, // Used for monitoring an object’s existence WeakTrackResurrection = 1, // Used for monitoring an object’s existence Normal = 2, // Used for controlling an object’s lifetime Pinned = 3 // Used for controlling an object’s lifetime }
Weak
该标志允许监视对象的生存期。具体地说,可检测垃圾回收器在什么时候判定该对象在应用程序代码中不可达。注意,此时对象的Finalize方法可能执行,也可能没有执行,对象可能还在内存中。
WeakTrackResurrection
该标志允许监视对象的生存期。具体地说,可检测垃圾回收器在什么时候判定该对象在应用程序的代码中不可达。注意,此时对象的Finalize方法(如果有的话)已经执行,对象的内存已经回收。
Normal
该标志允许控制对象的生存期。具体地说,是告诉垃圾回收器:即使应用程序中没有变量(根)引用该对象,该对象也必须留在内存中。垃圾回收发生时,该对象的内存可以压缩(移动)。不向Alloc方法传递任何GCHandleType标志,就默认使用GCHandleType.Normal.
Pinned
该标志允许控制对象的生存期。具体地说,是告诉垃圾回收器:即使应用程序中没有变量(根)引用该对象,该对象也必须留在内存中。垃圾回收发生时,该对象的内存不能压缩(移动)。需要将内存地址交给本机代码时,这个功能很好用。本机代码知道GC不会移动对象,所以能放心地向托管堆的这个内存写入。
GCHandle是轻量级的值类型,其中包含一个实例字段(一个IntPtr字段),它引用了句柄表中的记录项索引。要释放GC句柄表中的这个记录项时,可以获取GCHandle实例,并在这个实例上调用Free方法,Free方法将IntPtr字段设为0,使实例变得无效。
下面展示了垃圾回收器如何使用GC句柄表。当垃圾回收发生时,垃圾回收器的行为如下。
1,垃圾回收器标记所有可达的对象(本章开始的时候已进行了描述)。然后,垃圾回收器扫描GC句柄表:所有Normal或Pinned对象都被看成是根,同时标记这些对象(包括这些对象通过它们的字段引用的对象)。
2,垃圾回收器扫描GC句柄表,查找所有Weak记录项。如果一个weak记录项引用了未标记的对象,该引用标识的就是不可达对象(垃圾),该记录项的引用值更改为null.
3,垃圾回收器扫描终结列表。在列表中,对未标记对象的引用标识的是不可达对象,这些引用从终结列表移至freachable队列。这时对象会被标记,因为对象又变成可达了。
4,垃圾回收器扫描GC句柄表,查找所有WeakTrackResurrection记录项。如果一个WeakTrackResurrection记录项引用了未标记的对象(它现在是由freachable队列中的记录项引用的),该引用标识的就是不可达对象(垃圾),该记录项的引用值更改为null。
5,垃圾回收器对内存进行压缩,填补不可达对象留下的内存“空洞”,这其实就是一个内存碎片整理的过程。Pinned对象不会压缩(移动),垃圾回收器会移动它周围的其他对象。
需要将托管对象的指针交给本机代码时使用Normal标记,因为本机代码将来要回调托管代码并传递指针。但不能就这么将托管对象的指针交给本机代码,因为如果垃圾回收发生,对象可能在内存中移动,指针便无效了。解决方案是调用GCHandle的Alloc方法,传递对象引用和Normal标志。将返回的GCHandle实例转型为IntPtr,再将这个IntPtr传给本机代码。本机代码回调托管代码时,托管代码将传递的IntPtr转型为GCHandle,查询Target属性获得托管对象的引用(当前地址)。本机代码不再需要这个引用之后,你可以调用GCHandle的Free方法,使未来的垃圾回收能够释放对象(假定不存在引用该对象的其他根)
注意在这种情况下,本机代码并没有真正使用托管对象本身;它只是通过一种方式引用了对象。但某些时候,本机代码需要真正地使用托管对象。这时托管对象必须固定(pinned),从而阻止垃圾回收器压缩(移动)对象。
C#提供了一个fixed语句,它能在一个代码块中固定对象。
注意这个类是线程安全的,多个线程能同时使用它(虽然这也意味着类的性能并不出众)。
浙公网安备 33010602011771号