托管堆和垃圾回收
托管应用程序如何构造新对象,如何控制这些对象的生存期, 以及如何回收这些对象的内存.
托管堆基础
每个程序都要使用这样或那样的资源,包括文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。事实上,在面向对象的环境中,每个类型都代表可提供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。以下是访问一个资源所需的步骤
- 调用
IL指令newobj
,为代表资源的类型分配内存(一般使用c#new
操作符来完成) - 初始化内存,设置资源的初始状态并使资源可用。类型的实参构造器负责设置初始状态。
- 访问类型成员来使用资源。
- 摧毁资源的状态以进行清理
- 释放内存。垃圾回收器独自负责这一步。
现在,只要写的是可验证的、类型安全的代码(不要用unsafe
关键字),应用程序就不可能会出现内存被破坏的情况。内存仍有可能泄漏,但不像以前那样是默认行为。
现在内存泄漏一般是因为在集合中存储了对象,但不需要对象的时候一直不去删除。
为了简化编程,开发人员经常使用的大多数类型都不需要步骤4摧毁资源的状态以进行清理
, 靠垃圾回收器自动释放内存.
使用需要特殊清理的类型时,编程模型还是像刚才描述的那样。只是有时需要尽快清理资源,而不是非要等GC
介入。可在这些类中调用一个额外的方法(Dispose
),按照自己的节奏清理资源。另一方面,实现这样的类需要考虑到较多问题。一般只有包装了本机资源(文件、套接字和数据库连接等)的类型才需要特殊清理。
从托管堆分配资源
CLR要求所有对象都从托管堆分配。进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR还要维护一个指针,我把它称作NextObjPtr
。该指针指向下一个对象在堆中的分配位置。刚开始的时候,NextObjPtr
设为地址空间区域的基地址。
一个区域被非垃圾对象填满后,CLR会分配更多的区域。这个过程一直重复,直至整个进程地址空间都被填满。所以,你的应用程序的内存受进程的虚拟地址空间的限制。32位进程最多能分配1.5Gb,64位进程最多能分配8Tb。
C#的new
操作符导致CLR执行以下步骤。
- 计算类型的字段(以及从基类型继承的字段)所需的字节数。
- 加上对象的开销所需的字节数。每个对象都有两个开销字段:
类型对象指针
和同步块索引
。对于32位应用程序,这两个字段各自需要32位,所以每个对象要增加8字节。对于64位应用程序,这两个字段各自需要64位,所以每个对象要增加16字节。 - CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在
NextObjPtr指针
指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象引用。就在返回这个引用之前,NextObjPtr指针
的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。
对于托管堆,分配对象只需要在指针上加一个值——速度相当快。由于托管堆在内存中连续分配这些对象,所以会因为引用的“局部化”(locality)而获得性能上的提示。具体地说,这意味着进程的工作集会非常小,应用程序只需要使用很少的内存,从而提高了速度。还意味着代码使用的对象可以全部驻留在cpu的缓存中。结果是应用程序能以惊人的速度访问这些对象,因为cpu在执行大多数操作时,不会因为缓存未命中
而被迫访问较慢的RAM。
随机存取存储器(英语:Random Access Memory,缩写:RAM)也叫主存,是与CPU直接交换数据的内部存储器。
根据前面的描述,似乎托管堆的性能天下无敌。但先别激动,刚才说的有一个大前提——内存无限,CLR总是能分配新对象。但内存不可能无限,所以CLR通过称为垃圾回收
的技术删除堆中你的应用程序不再需要的对象。
垃圾回收算法
应用程序调用new操作符创建对象时,可能没有足够地址空间来分配该对象。发现空间不够,CLR就执行垃圾回收。
至于对象生存期管理,有的系统采用的是某种引用计数算法
, Microsoft自己的组件对象模型(component Object Model,COM)用的就是引用计数
.
这种系统中,堆上的每个对象都维护着一个内存字段来统计程序中多少“部分”正在使用对象。随着每一部分到达代码中某个不再需要对象的地方,就递减对象的计数字段。计数字段变成0,对象就可以从内存中删除了。
许多引用计数系统最大的问题是处理不好循环引用。例如在GUI应用程序中,窗口将容纳对子ui元素的引用,而子ui元素将容纳对父窗口的引用。这种引用会阻止两个对象的计数器达到0,所以两个对象永远不会删除,即使应用程序本身不再需要窗口了。
鉴于引用计数
垃圾回收期算法存在的问题,CLR改为使用一种引用跟踪算法
。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用托管堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量都称为根
。
CLR开始GC时,首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态。然后,CLR进行gc的标记
阶段。在这个阶段,CLR遍历堆中所有对象,将同步块索引字段
中的一位设为0。这表明所有对象都应删除。然后,CLR检查所有活动根
,查看他们引用了哪些对象。这正是CLR的gc称为引用跟踪gc的原因。如果一个跟包含null,CLRr忽略这个根并继续检查下个根
。
任何根
如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引
中的位设为1.一个对象被标记后,CLR会检查那个对象中的根
,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了因为循环引用而产生的死循环。
上图展示了一个堆, 其中包含几个对象, 应用程序的根(变量)直接引用了对象A
,C
,D
,F
. 这4个对象都以及被标记
,标记``D
时, 垃圾回收期发现这个对象含有一个引用H
的字段,造成H
也被标记
. 标记过程
会持续到所有根(变量)
检查完毕.
检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个跟在引用它。我们说这种对象是可达的,因为应用程序代码可通过仍在引用它的变量抵达它。未标记的对象是不可达的,因为应用程序中不存在使对象能被再次访问的根。
CLR知道哪些对象可以幸存,哪些可以删除后,就进入GC的压缩阶段(碎片整理,变的更紧凑)
。在这个阶段,CLR对堆中已标记的对象进行“乾坤大挪移”,压缩所有幸存下来的对象,使他们占用连续的内存空间。这样做有许多好处。首先,所有幸存对象在内存中紧挨在一起,恢复了引用的“局部化”,减小了应用程序的工作集,从而提升了将来访问这些对象时的性能。其实,可用空间也全部是连续的,所以这个地址空间区段得到了解放,允许其他东西进驻。最后,“压缩”意味着托管堆解决了本机(原生)堆的空间碎片化
问题。
大对象堆不会压缩, 大对象堆还是可能发生地址空间碎片化的.
在内存中移动了对象之后有一个问题亟待解决。引用幸存对象的根
现在引用的还是对象最初在内存中的位置,而非移动后的位置,被暂停的线程恢复执行时,将访问旧的内存位置,会造成内存损害。这显然不能容忍的,所以作为压缩阶段的一部分,CLR还要从每个根
减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象:只是对象在内存中变换了位置。
压缩好内存后,托管堆的NextObjPtr指针
指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。
压缩阶段完成后, CLR恢复应用程序的所有线程, 这些线程继续访问对象.
如果CLR在一次gc之后回收不了内存,而且进程中没有空间来分配新的gc区域,就说明该进程的内存已耗尽。此时,视图分配更多内存的new
操作符会抛出OutOfMemoryException
。应用程序可捕捉该异常并从中恢复。但大多数应用程序都不会这么做:相反,异常会成为未处理异常,Windows将终止进程并回收进程使用的全部内存.
静态字段
引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地向集合添加数据项。静态字段使集合一直存活,而集合对象使所有数据项一直存活.
垃圾回收和调试
public static void Main()
{
// 创建每2000毫秒就调用一次TimerCallback方法的Timer对象
var t = new System.Threading.Timer(TimerCallback, null, 0, 2000);
// 等待用户按Enter键
Console.ReadLine();
}
private static void TimerCallback(Object o)
{
// 当调用该方法时,显示日期和时间
Console.WriteLine("In TimerCallback: " + DateTime.Now);
// 出于演示目的, 强制执行一次垃圾回收
GC.Collect();
}
// 在debug下运行, 可以一直输出
// 在release下运行, 只会输出一次
观察代码可能以为TimerCallback
方法每隔2000毫秒调用一次, 毕竟代码创建了一个Timer对象, 而且有一个变量t
引用该对象, 计时器对象存在,计时器就应该一直能触发. 但实际上不是这样的.
运行代码,会发现TimerCallback
方法只被调用一次,因为方法调用了GC.Collect()
强制执行了一次垃圾回收。
回收开始时, 垃圾回收器首先假定堆中的所有对象都是不可达的(垃圾); 这自然也包括Timer对象,然后垃圾回收器检查应用程序的根(引用变量)
,发现初始化t
之后,Main方法再也没有用过变量t
, 应用程序没有任何变量引用Timer对象, 所以被回收了.
大多数开发人员没有料到这个结果,认为不合常理,所以Microsoft提出了一个解决方案:
使用C#编译器/debug开关编译程序集时, 编译器会应用System.Diagnostics.DebuggableAttribute
特性, 并未结果程序集设置DebuggingModes
的DisableOptimizations
标志. 运行时编译方法时, JIT编译器看到这个标志,会将所有根(引用变量)
的生存期延长至方法结束. 在例子中, JIT编译器认为Main的t变量必须存活至方法结束. 所以垃圾回收时GC认为t仍然是一个根
, t引用的Timer对象仍然可达, TimerCallback方法会被反复调用,直至Main退出.
从这个例子看来Debug生成中正常工作的应用程序,但它在Release生成中是不正常的. 没人喜欢只有调试时才正常的程序,所以讨论以下修改:
// 不正确修改方式
public static void Main()
{
// 创建每2000毫秒就调用一次TimerCallback方法的Timer对象
var t = new System.Threading.Timer(TimerCallback, null, 0, 2000);
// 等待用户按Enter键
Console.ReadLine();
// 在ReadLine之后引用t , 但是会被JIT优化掉
// 将局部变量或参数设为null等价于根本不引用该变量
// JIT编译器会将这行代码删除(优化掉)
t = null;
}
// 正确修改方式
public static void Main()
{
// 创建每2000毫秒就调用一次TimerCallback方法的Timer对象
var t = new System.Threading.Timer(TimerCallback, null, 0, 2000);
// 等待用户按Enter键
Console.ReadLine();
// 在ReadLine之后引用t ,
// 在Dispose方法返回前,t会在GC中存活.
t.Dispose();
}
t.Dispose();
要求t对象必须存活才能去调用Dispose
实例方法(t中的值要作为this实参传给Dispose). 显式释放计时器才能活到被释放那一刻.
注意:不用担心对象被过早回收这个问题. 这里讨论的是特殊情况. 所有非timer对象都会根据应用程序的的需要而自动存货。timer是一个比较特殊行为。
代:提升性能
CLR的gc是基于代
的垃圾回收器,他对你的代码做出做出了以下几点假设
- 对象越新,生存期越短。
- 对象越老,生存期越长。
- 回收堆的一部分,速度快于回收整个堆。
大量研究证明,这些假设对于现今大多数应用程序都是成立的,它们影响了垃圾回收器的实现方式。
托管堆在初始化时不包含对象。添加到堆的对象称为第0代对象。简单地说,第0代对象就是那些新构造的对象,垃圾回收期从未检查过它们。
一个新启动的应用程序,分配了5个对象,ABCDE, 一会之后,CE变不可达.
CLR初始化时为第0代对象
选择一个预算容量(以kb为单位)。如果分配一个新对象造成第0代
超过预算,就必须启动一次垃圾回收。假设对象A到E刚好用完第0代
的空间,那么分配F对象就会启动一次垃圾回收.
垃圾回收判断CE是垃圾,就会压缩D对象,使之与B相邻. 垃圾回收之后,未被引用的对象将会被回收,ABD对象称为第1代对象
。一次垃圾回收之后,第0代
就不包含任何对象了。然后会重复上面的逻辑。
经历过几次垃圾回收器的检查就可以称为第几代.
假如继续运行, 新对象会被分配到第0代
中,分配了FGHIJK对象,之后BHJ变得不可达.
现在,再分配新对象L的时候,会造成第0代
超出预算, 必须启动垃圾回收. 开始垃圾回收时必须决定检查哪些代
. 垃圾回收器开始回收时, 会检查第1代
占了多少内存, 由于第一代占的内存远少于预算, 所以垃圾回收器值检查第0代
中的对象. 对象越新,生存期越短。 因此第0代
包含垃圾的可能性越大,能回收更多的内存.
显然,忽略第一代对象能提升垃圾回收器的性能。对性能有更大提振作用的是现在不必遍历托管堆中的每个对象。如果根
或对象引用
了老一代
的某个对象,垃圾回收期可以忽略老对象内部的所有引用,能在更短的时间内构造好可达对象图。当然,老对象的字段也有可能引用新对象。为了确保对老对象的已更新字段进行检查,垃圾回收期利用了JIT编译器内部的一个机制。这个机制在对象的引用字段发生变化时,会设置一个对应的位标志
。这样,只有字段发生变化的老对象才需检查是否引用了第0代
新对象。
注意:Microsoft的性能测试表明,对第0代执行一次垃圾回收,所花的时间不超过1毫秒。
对象越老,生存期越长。
基于代
的垃圾回收器还假设老的对象活得越长, 第1代对象在应用程序中有可能是继续可达的,很有可能找不到多少垃圾. 因此对第1代
进行垃圾回收很可能是浪费时间,如果真的有,它将留在那里.
经过这轮垃圾回收之后幸存下来的第0代
对象都成了第1代
的一部分. 由于垃圾回收没有检查第1代
所以B对象没有被回收.
经过几次垃圾回收,第1代
会慢慢增长, 假定第1代
的增长超出了预算,这时继续运行,因为垃圾回收刚刚完成, 等到第0代
超出预算时, 垃圾回收器将检查第0代
和第1代
(之前因为之前第1代占据的内存小于预算), 两代都被垃圾回收之后,如图所示:
托管堆只支持三代: 第0代,第1代,第2代. 没有第3代.(System.GC.MaxGeneration方法返回2). CLR初始化时, 会为每一代选择预算. 然而, CLR的垃圾回收器时自调节的.
最终结果就是, 垃圾回收器会根据应用程序要求的内存负载来自动优化.
垃圾回收触发条件
- CLR在检测
第0代
超过预算时触发一次GC。这是GC最常见的触发条件 - 代码显式调用
System.GC
的静态Collect
方法- 代码可显式请求CLR执行回收,虽然Microsoft强烈反对这种请求,但有时情势比人强。
- windows报告低内存情况
- CLR内部使用WIN32函数监视总体内存使用情况,在低内存情况下,强制垃圾回收以释放死对象.
- CLR正在卸载AppDomain
- 一个AppDomain卸载时,CLR认为其中一切都不是根,所以执行涵盖
所有代
的垃圾回收。
- 一个AppDomain卸载时,CLR认为其中一切都不是根,所以执行涵盖
- CLR正在关闭
- CLR在进程正常终止时关闭(相反的是外部终止,例如任务管理器). 关闭期间,CLR认为进程中的一切都不是
根
, CLR不会试图压缩或释放内存, window将回收进程的全部内存.
- CLR在进程正常终止时关闭(相反的是外部终止,例如任务管理器). 关闭期间,CLR认为进程中的一切都不是
大对象
还有另一个性能提升值得注意。CLR将对象分为大对象
和小对象
。本章到目前为止说的都是小对象
。目前认为85000字节以上的对象是大对象
。CLR以不同方式对待大小对象
。
大对象
不是在小对象
的地址空间分配,而是在进程地址空间的其他地方分配。- 目前版本的GC不压缩大对象,因为在内存中移动他们代价过高。
- 大对象总是第二代,绝不可能是0代或1代。所以只能为需要长时间存活的资源创建大对象。分配短时间存货的大对象会导致第二代被更频繁的回收,会损害性能。大对象一般是大字符串(比如XML或Json)或用于
I\O
操作的字节数组。
在很大程度上视大对象若无物, 可以忽略它们的存在. 仅在出现解释不了的情况时(比如地址空间碎片化)才对它进行特殊处理.
垃圾回收模式
CLR启动时会选择一个GC模式,进程终止前该模式不会改变。有两个基本GC模式
- 工作站
该模式针对客户端应用程序优化gc。gc造成的延时很低,应用程序线程挂起时间很短,避免使用户感到焦虑。该模式中,gc假定机器上运行的其他应用程序都不会消耗太多的cpu资源。
- 服务器
该模式针对服务器端应用程序优化gc。被优化的主要是吞吐量和资源里利用。GC假定机器上没有运行其他应用程序,并假定机器的所有cpu都可用来辅助完成gc。该模式造成托管堆被拆分成几个区域(section),每个cpu一个。开始垃圾回收时,垃圾回收期在每个cpu上都运行一个特殊线程;每个线程和其他线程并发回收它自己的区域。对于工作者线程行为一致的服务器应用程序,并发回收能很好地进行。
应用程序默认以工作站GC模式运行。寄宿了CLR的服务器应用程序可请求CLR加载服务器GC。但如果服务器应用程序在单处理器计算机上运行,CLR将总是使用工作站gc模式。
<configuration>
<runtime>
<gcServer enabled="true"/>
</runtime>
</configuration>
独立应用程序可创建一个配置文件告诉CLR使用服务器回收器。应用程序运行时,可查询GCSettings类
的只读bool
属性IsServerGc
来询问CLR它是否正在服务器GC模式中运行。
除了这两种主要模式,GC还支持两种子模式:并发(默认)
或非并发
。在并发方式中,垃圾回收器有一个额外的后台线程,它能在应用程序运行时并发标记对象.
<configuration>
<runtime>
<gcConcurrent enabled="false"/>
</runtime>
</configuration>
GC模式是针对进程配置的, 进程运行期间不能更改. 但是可以使用GCSetting
类的GCLatencyMode
属性对垃圾回收进行某种程度的控制.
LatencyMode
,一般用它执行一次短期的,时间敏感的操作,再将模式设回普通的Batch
或Interactive
, 在模式设为LowLatency期间,垃圾回收器会全力避免任何第2代回收, 因为那样花费的时间较多. 当然调用GC.Collect()仍会回收第2代. 如果Windows告诉CLR内存低,也会回收第2代.
强制垃圾回收
System.GC
类型允许应用程序对垃圾回收器进行一些直接控制.
还可调用GC类
的Collect方法
强制垃圾回收, 可以像方法传递一个代表最多回收几代的整数.
public static void Collect(int generation, GCCollectionMode mode, bool blocking, bool compacting);
大多时候都要避免调用任何collect方法:最好让垃圾回收期自行斟酌执行,让他根据应用程序行为跳转各个代的预算。(调用Collect会导致代的预算发生调整)但如果写一个CUI(控制台应用程序)或GUI(图图形用户界面)应用程序,应用程序代码将拥有进程和那个进程中的CLR。对于这种应用程序,你可能希望建议垃圾回收的时间;为此,请将GCCollectionMode
设为Optimized
并调用Collect()
。Default
和Forced模式
一般用于调试、测试和查找内存泄漏。
例如,加入刚才发生了某个非重复性的事件,并导致大量旧对象死亡,就可考虑手动调用一次collect方法。由于是非重复性的,垃圾回收期基于历史的预测可能变得不准吃,所以这时调用Collect时合适的。由于调用Collect会导致代的预算发生调整,所以调用它不是为了改善应用程序的响应时间,而是为了减小进程工作集。
处理大量对象的服务器应用程序
监视应用程序的内存使用
可在进程中调用几个方法来监视垃圾回收期。具体地说,gc类提供了一下静态方法,可调用它们查看某一代发生了多少次垃圾回收,或者托管堆中的对象当前使用了多少内存。
int32 CollectionCount(int generation);
int64 GetTotalMemory(Boolean ForceFullCollection);
为了评估特定代码块的性能,我经常在代码块前后写代码调用这些方法,并计算差异。这使我能很好地把握代码块对进程工作集的影响,并了解执行代码块时发生了多少次垃圾回收。数字太大,就知道应该花更多的时间调整代码块中的算法。
还可了解大度的AppDomain使用了多少内存。安装.NET时会自动安装一组性能计数器,为clr的操作提供大量实时统计数据。这些统计数据可通过windows自带的perfmon.exe工具或者系统监视器activeX控件来查看。
使用需要特殊清理的类型
大多数类型有内存就能正常工作,但有的类型除了内存还需要本机资源。
例如,System.IO.FileStream
类型需要打开一个文件(本机资源)并保存文件的句柄。然后,类型的Read
和Write
方法用句柄操作文件。
包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源的泄漏(因为GC对此一无所知),这当然是不允许的。所以,CLR提供了称为终结(finalization)
的机制,允许对象在被判定为垃圾之后,但在对象回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字)的类型都支持终结
。CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,gc会从托管堆回收对象。
终极基类System.Object
定义了受保护的虚方法Finalize
。垃圾回收期判定判定对象是垃圾后,会调用对象的Finalize
方法(如果重写)。C#团队认为Finalize
在编程语言中需要特殊语法。因此,c#要求在类名前添加~符号
来定义Finalize方法
.
C++有析构器. C#早期称此为析构器, 但是后来Finalize方法的工作方式与C++析构器完全不同, 会产生混淆. 所以称为终结方法.
internal sealed class SomeType
{
// 这是一个Finalize方法
~SomeType()
{
// 这里代码会进入Finalize
}
}
用ILDasm.exe查看得到的程序集,c#编译器实际是在模块的元数据中生成了名为Finalize
的protected override方法
。查看Finalize的IL,会发现方法主体的代码被放到一个try块
中,在finally块
中则放入了一个base.Finalize
调用.
被视为垃圾的对象在垃圾回收完毕后才调用Finalize方法
. 所以这些对象的内存不是马上被回收的, 因为Finalize方法可能要执行访问字段的代码. 可终结对象在回收时必须存活, 造成它被提升到另一代. 使对象活得比正常时间长. 这增大了内存耗用, 应尽可能避免终结. 更糟的是,可终结对象被提升时, 其字段引用的所有对象也会被提升, 因为它们必须继续存活. 所有, 要尽量避免为引用类型的字段定义可终结对象.
注意: Finalize方法
的执行时间是控制不了的. 应用程序请求更多内存时才可能发生GC, 而GC完之后才运行Finalize
. CLR不保证Finalize方法
的调用顺序, 在Finalize方法
中不要访问定义了Finalize方法
的其他类型的对象, 那些对象可能已经终结. 但可以访问安全地访问值类型的实例, 或者访问没有定义Finalize方法
的引用类型的对象.
CLR用一个特殊的、高优先级的专用线程调用Finalize方法
来避免死锁。如果Finalize方法阻塞,该特殊线程就调用不了任何更多的Finalize方法。 这是非常坏的情况,永远回收不了可终结对象占用的内存,就会一直泄露内存. 抛出未处理异常则进程终止,没办法捕捉该异常.
综上所述,Finalize方法
问题较多,使用需谨慎。记住他们是为了释放本机资源而设计的。强烈建议不要重写Object
的Finalize方法
。相反,使用Microsoft在FCL中提供的辅助类。这些辅助类重写了Finalize方法并添加了一些特殊的CLR“魔法”(之后提到)。你从这些辅助类派生自己的类,从而继承CLR的“魔法”。
创建封装了本机资源
的托管类型时,应该先从System.runtime.interopServices.SafeHandle这个特殊基类派生出一个类。
CLR以特殊方式对待这个类及其派生类,具体地说,CLR赋予这个类一下三个很酷的功能
首次构造任何
CriticalFinalizerObject
派生类型的对象时,CLR立即对继承层次结构中所有的Finalize方法
进行JIT编译。这样确保对象被确定为垃圾之后,资源肯定会得到释放。不提前编译的话,无法保证释放(内存紧张时,CLR可能找不到足够的内存来编译Finalize方法
,这会阻止Finalize方法执行,造成本机资源泄漏)。CLR是在调用了
非CriticalFinalizerObject
派生类型的Finalize方法
之后,才调用CriticalFinalizerObject
派生类型的Finalize
。这样,托管资源类就可以在他们Finalize方法
中成功地访问CriticalFinalizerObject
派生类型的对象,例如fileStream类的Finalize方法
可以放心地将数据从内存缓冲区flush(冲洗
到别处)到磁盘,它知道此时磁盘文件还没有关闭。如果appdomain被一个宿主应用程序强行中断,CLR将调用
CriticalFinalizerObject
派生类型的Finalize
方法。宿主应用程序不再信任它内部运行的托管代码时,也利用也利用好这个功能确保本机资源得到释放。
具体看书…. 云里雾里…
使用了包装本机资源的类型
你现在知道了如何定义包装了本机资源的SafeHandle派生类
,接着说说如何使用它。
以常用的System.IO.FileStream
类为例,可利用它打开一个文件,从文件中读取字节,向文件写入字节,然后关闭文件。
FileStream对象
在构造时会调用Win32 CreateFile
函数,函数返回的句柄保存到SafeFileHandle
对象中,然后通过FileStream
对象的一个私有字段来维护对该对象的引用。FileStream
还提供几个额外属性(例如length,position,canread)和方法(read,write,flush)。
假定要写代码来创建一个临时文件,向其中写入一些字节,然后删除文件。
// 大多数时候都不能正常工作,以下代码
static void Main(string[] args)
{
//创建要写入临时文件的字节
Byte[] bytesToWrite=new byte[]{1,2,3,4,5};
//创建临时文件
FileStream fs=new FileStream("temp.dat",FileMode.Create);
//将字节写入临时文件
fs.Write(bytesToWrite,0,bytesToWrite.Length);
// 此方法要求windows删除一个仍然打开的文件,所以会抛出IO异常
File.Delete("temp.dat");//抛出Io异常
}
但某些情况下, 文件可能误打误撞地被删除! 如果另一线程不知怎么造成了一次垃圾回收, 而且这次垃圾回收刚好在调用Write之后, 调用Delete之前发生, 那么FileStream对象
的SafeFileHandle字段
的Finalize方法
就会被调用. 这会关闭文件, 随后Delete操作也可以正常运行,但这种情况发生概率很小, 无法运行的可能性99%.
类如果想允许使用者控制类包装的本机资源的生存期,就必须实现如下所示的IDispose接口.
幸好,FileStrram类实现了IDisposable接口
。在实现中,会在FileStream对象的私有SafeFileHandle
字段上调用Dispose
, 现在就能修改代码来显式关闭文件,而不是等着未来某个GC的发生.
// 一般的写法
static void Main(string[] args)
{
//创建要写入临时文件的字节
Byte[] bytesToWrite=new byte[]{1,2,3,4,5};
//创建临时文件
FileStream fs=new FileStream("temp.dat",FileMode.Create);
//将字节写入临时文件
fs.Write(bytesToWrite,0,bytesToWrite.Length);
//写入结束后显示关闭文件
fs.Dispose();
// 以下代码能正常工作
File.Delete("temp.dat");
}
并非一定要调用Dispose才能保证本机资源得以清理. 本机资源的清理最终总会发生,调用Dispose只是控制这个清理动作的发生时间. 另外,调用Dispose
不会将托管对象从托管堆删除,只有在垃圾回收后,托管堆中的内存才会得以回收。 这意味你即使Dispose
了托管对象过去用过的任何本机资源,也能在托管对象上调用方法, 只是不会成功而已,会抛出异常. 不会对造成对内存的破坏.
但是当你显式调用Dispose
后,再调用它的方法并不会执行成功,比如再操作对象写入更多数据,会提示无法访问已关闭文件。 会抛出ObjectDisposeedException
.
如果决定显示调用Dispose,强烈建议将调用放到一个finally块中。这样可以保证清理代码得以执行。也可以使用using语句,简化编码。
// 更好的写法
static void Main(string[] args)
{
//创建要写入临时文件的字节
Byte[] bytesToWrite=new byte[]{1,2,3,4,5};
//创建临时文件
FileStream fs=new FileStream("temp.dat",FileMode.Create);
try
{
//将字节写入临时文件
fs.Write(bytesToWrite,0,bytesToWrite.Length);
}
finally
{
if (fs!=null)
fs.Dispose();
}
// 写入结束后显示关闭文件
// 总以下代码能正常工作
File.Delete("temp.dat");
}
垃圾回收系统有许多好处: 无内存泄露, 无内存损坏, 无地址空间碎片化以及缩小的工作集. 还有同步,GC确实能作为线程同步机制使用,因为GC会终结对象.
C#语言提供一个using
语句,允许用简化的语法来获得和上述代码相同的结果.
// 使用using的写法,最简洁的写法
static void Main(string[] args)
{
//创建要写入临时文件的字节
Byte[] bytesToWrite=new byte[]{1,2,3,4,5};
//创建临时文件
using(FileStream fs=new FileStream("temp.dat",FileMode.Create))
{
//将字节写入临时文件
fs.Write(bytesToWrite,0,bytesToWrite.Length);
}
// 写入结束后显示关闭文件
// 总以下代码能正常工作
File.Delete("temp.dat");
}
using语句
初始化一个对象, 并将它的引用保存到一个变量中,然后在using语句
的大括号内访问该变量. 编译这段代码时, 编译器自动生成try块
和finally块
. 在finally块
中, 编译器生成代码将变量转型为一个IDisposable
并调用Dispose方法
, 显而易见, using语句
只能用于那些实现了IDisposable接口的类型.