返回

托管堆和垃圾回收

托管堆基础

对象在内存中的生命周期

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

  1. 调用IL指令newobj,为代表资源的类型分配内存(一般使用c# new操作符来完成)
  2. 初始化内存,设置资源的初始状态并使资源可用。类型的实参构造器负责设置初始状态。
  3. 访问类型成员来使用资源。
  4. 摧毁资源的状态以进行清理
  5. 释放内存。垃圾回收器独自负责这一步。

手动管理内存容易产生的问题

如果程序员需要手动管理内存,这个看似简单的模式就会成为导致大量编程错误的元凶之一。例如:

  1. 程序员忘记释放不再需要的内存而导致内存泄漏
  2. 试图访问已经释放的内存导致程序错误和安全漏洞

使用GC后的内存管理

现在,只要写的是可验证的、类型安全的代码(不要用unsafe关键字),应用程序就不可能会出现内存被破坏的情况。内存仍有可能泄漏,但不像以前那样是默认行为。

现在内存泄漏一般是因为在集合中存储了对象,但不需要对象的时候一直不去删除。

使用需要特殊清理的类型时,编程模型还是像刚才描述的那样。只是有时需要尽快清理资源,而不是非要等GC介入。可在这些类中调用一个额外的方法(Dispose),按照自己的节奏清理资源。另一方面,实现这样的类需要考虑到较多问题。一般只有包装了本机资源(文件、套接字和数据库连接等)的类型才需要特殊清理。

从托管堆分配资源

CLR要求所有对象都从托管堆分配。进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR还要维护一个指针,我把它称作NextObjPtr。该指针指向下一个对象在堆中的分配位置。刚开始的时候,NextObjPtr设为地址空间区域的基地址。

一个区域被非垃圾对象填满后,CLR会分配更多的区域。这个过程一直重复,直至整个进程地址空间都被填满。所以,你的应用程序的内存受进程的虚拟地址空间的限制。32位进程最多能分配1.5Gb,64位进程最多能分配8Tb。

C#的new操作符导致CLR执行以下步骤

  1. 计算类型的字段(以及从基类型继承的字段)所需的字节数。
  2. 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针同步块索引。对于32位应用程序,这两个字段各自需要32位,所以每个对象要增加8字节。对于64位应用程序,这两个字段各自需要64位,所以每个对象要增加16字节。
  3. CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在NextObjPtr指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象引用。就在返回这个引用之前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。

对于托管堆,分配对象只需要在指针上加一个值——速度相当快。由于托管堆在内存中连续分配这些对象,所以会因为引用的“局部化”(locality)而获得性能上的提示。

垃圾回收算法

应用程序调用new操作符创建对象时,可能没有足够地址空间来分配该对象。发现空间不够,CLR就执行垃圾回收。

引用计数算法

至于对象生存期管理,有的系统采用的是某种引用计数算法, Microsoft自己的组件对象模型(component Object Model,COM)用的就是引用计数.

这种系统中,堆上的每个对象都维护着一个内存字段来统计程序中多少“部分”正在使用对象。随着每一部分到达代码中某个不再需要对象的地方,就递减对象的计数字段。计数字段变成0,对象就可以从内存中删除了。

该算法存在的循环Bug

许多引用计数系统最大的问题是处理不好循环引用。例如在GUI应用程序中,窗口将容纳对子ui元素的引用,而子ui元素将容纳对父窗口的引用。这种引用会阻止两个对象的计数器达到0,所以两个对象永远不会删除,即使应用程序本身不再需要窗口了。

引用跟踪算法

鉴于引用计数垃圾回收期算法存在的问题,CLR改为使用一种引用跟踪算法引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用托管堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量都称为

引用跟踪算法流程

  1. CLR开始GC时,首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态。
  2. 然后,CLR进行gc的标记阶段。在这个阶段,CLR遍历堆中所有对象,将同步块索引字段中的一位设为0。这表明所有对象都应删除。
  3. 然后,CLR检查所有活动,查看他们引用了哪些对象。这正是CLR的gc称为引用跟踪gc的原因。如果一个跟包含null,CLRr忽略这个根并继续检查下个

任何如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1。一个对象被标记后,CLR会检查那个对象中的,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了因为循环引用而产生的死循环

上图展示了一个堆, 其中包含几个对象, 应用程序的根(变量)直接引用了对象A,C,D,F. 这4个对象都以及被标记,标记``D时, 垃圾回收期发现这个对象含有一个引用H的字段,造成H也被标记. 标记过程会持续到所有根(变量)检查完毕.

检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个跟在引用它。我们说这种对象是可达的,因为应用程序代码可通过仍在引用它的变量抵达它。未标记的对象是不可达的,因为应用程序中不存在使对象能被再次访问的根。

  1. CLR知道哪些对象可以幸存,哪些可以删除后,就进入GC的压缩阶段(碎片整理,变的更紧凑)。在这个阶段,CLR对堆中已标记的对象进行“乾坤大挪移”,压缩所有幸存下来的对象,使他们占用连续的内存空间。这样做有许多好处:
    • 首先,所有幸存对象在内存中紧挨在一起,恢复了引用的“局部化”,减小了应用程序的工作集,从而提升了将来访问这些对象时的性能。
    • 其次,可用空间也全部是连续的,所以这个地址空间区段得到了解放,允许其他东西进驻。
    • 最后,“压缩”意味着托管堆解决了本机(原生)堆的空间碎片化问题
  2. 引用幸存对象的现在引用的还是对象最初在内存中的位置,而非移动后的位置,被暂停的线程恢复执行时,将访问旧的内存位置,会造成内存损害。作为压缩阶段的一部分,CLR还要从每个减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象:只是对象在内存中变换了位置。

内存不足

如果CLR在一次gc之后回收不了内存,而且进程中没有空间来分配新的gc区域,就说明该进程的内存已耗尽。此时,视图分配更多内存的new操作符会抛出OutOfMemoryException。应用程序可捕捉该异常并从中恢复。但大多数应用程序都不会这么做:相反,异常会成为未处理异常,Windows将终止进程并回收进程使用的全部内存.

静态字段引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地向集合添加数据项。静态字段使集合一直存活,而集合对象使所有数据项一直存活。

垃圾回收和调试

一旦根离开了作用域,它引用的对象就会变得"不可达",CG会回收其内存;不保证对象在方法的生命周期中至始至终地存活。这会对程序产生有趣的影响。

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对象, 所以被回收了。

解决方法:

// 不正确修改方式
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是基于的垃圾回收器,他对你的代码做出做出了以下几点假设

  1. 对象越新,生存期越短。
  2. 对象越老,生存期越长。
  3. 回收堆的一部分,速度快于回收整个堆。

概括来说就是托管对将对象划分为三代,每一代有预分配内存空间,分配对象空间从第0代开始,内存空间满了就调用一次针对第0代的GC,去除垃圾对象,然后这一代升级为1代,继续向第0代分配对象。如果第1代对象空间满了,针对第1代进行GC,1代中剩下对象升级到2代。循环往复,最高2代

托管堆在初始化时不包含对象。添加到堆的对象称为第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的垃圾回收器时自调节的.

最终结果就是, 垃圾回收器会根据应用程序要求的内存负载来自动优化.

垃圾回收触发条件

  1. CLR在检测第0代超过预算时触发一次GC。这是GC最常见的触发条件

  2. 代码显式调用System.GC的静态Collect方法

    • 代码可显式请求CLR执行回收,虽然Microsoft强烈反对这种请求,但有时情势比人强。
  3. windows报告低内存情况

    • CLR内部使用WIN32函数监视总体内存使用情况,在低内存情况下,强制垃圾回收以释放死对象.
  4. CLR正在卸载AppDomain

    • 一个AppDomain卸载时,CLR认为其中一切都不是根,所以执行涵盖所有代的垃圾回收。
  5. CLR正在关闭

    • CLR在进程正常终止时关闭(相反的是外部终止,例如任务管理器). 关闭期间,CLR认为进程中的一切都不是, CLR不会试图压缩或释放内存, window将回收进程的全部内存.

大对象

还有另一个性能提升值得注意。CLR将对象分为大对象小对象。本章到目前为止说的都是小对象。目前认为85000字节以上的对象是大对象。CLR以不同方式对待大小对象

  1. 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。
  2. 目前版本的GC不压缩大对象,因为在内存中移动他们代价过高。
  3. 大对象总是第二代,绝不可能是0代或1代。所以只能为需要长时间存活的资源创建大对象。分配短时间存货的大对象会导致第二代被更频繁的回收,会损害性能。大对象一般是大字符串(比如XML或Json)或用于I\O操作的字节数组。

在很大程度上视大对象若无物, 可以忽略它们的存在. 仅在出现解释不了的情况时(比如地址空间碎片化)才对它进行特殊处理.

垃圾回收模式

基本模式

CLR启动时会选择一个GC模式,进程终止前该模式不会改变。有两个基本GC模式

  1. 工作站

    该模式针对客户端应用程序优化gc。gc造成的延时很低,应用程序线程挂起时间很短,避免使用户感到焦虑。该模式中,gc假定机器上运行的其他应用程序都不会消耗太多的cpu资源。

  2. 服务器

    该模式针对服务器端应用程序优化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还支持两种子模式:并发(默认)非并发。在并发方式中,垃圾回收器有一个额外的后台线程,它能在应用程序运行时并发标记对象。使用并发模式有利于增强性能,但会增大应用程序的工作集,应用程序消耗的内存通常会比使用非并发垃圾回收器多。

为了告诉CLR不要使用并发回收器,可以创建包含gcConcurrent元素的应用程序配置文件。如下:

<configuration>
  <runtime>
    <gcConcurrent enabled="false"/>
  </runtime>
</configuration>

GC配置

GC模式是针对进程配置的, 进程运行期间不能更改. 但是可以使用GCSetting类的GCLatencyMode属性对垃圾回收进行某种程度的控制.

LatencyMode模式详细解释下,一般用它执行一次短期的,时间敏感的操作,再将模式设回普通的BatchInteractive, 在模式设为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()DefaultForced模式一般用于调试、测试和查找内存泄漏。

什么情况使用强制回收

例如,加入刚才发生了某个非重复性的事件,并导致大量旧对象死亡,就可考虑手动调用一次collect方法。由于是非重复性的,垃圾回收期基于历史的预测可能变得不准吃,所以这时调用Collect时合适的。由于调用Collect会导致代的预算发生调整,所以调用它不是为了改善应用程序的响应时间,而是为了减小进程工作集。

处理大量对象的服务器应用程序

监视应用程序的内存使用

可在进程中调用几个方法来监视垃圾回收期。具体地说,gc类提供了一下静态方法,可调用它们查看某一代发生了多少次垃圾回收,或者托管堆中的对象当前使用了多少内存。

 int32 CollectionCount(int generation);
 int64 GetTotalMemory(Boolean ForceFullCollection);

监视内存作用

为了评估特定代码块的性能,我经常在代码块前后写代码调用这些方法,并计算差异。这使我能很好地把握代码块对进程工作集的影响,并了解执行代码块时发生了多少次垃圾回收。数字太大,就知道应该花更多的时间调整代码块中的算法。还可了解大度的AppDomain使用了多少内存。

图形化监视器

安装.NET时会自动安装一组性能计数器,为clr的操作提供大量实时统计数据。这些统计数据可通过windows自带的perfmon.exe工具或者系统监视器activeX控件来查看。还有一个很出色的工具可以分析内存和应用程序的性能。

使用需要特殊清理的类型

大多数类型有内存就能正常工作,但有的类型除了内存还需要本机资源。

例如,System.IO.FileStream类型需要打开一个文件(本机资源)并保存文件的句柄。然后,类型的ReadWrite方法用句柄操作文件。

包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源的泄漏(因为GC对此一无所知),这当然是不允许的。所以,CLR提供了称为终结(finalization)的机制,允许对象在被判定为垃圾之后,但在对象回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字)的类型都支持终结。CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,gc会从托管堆回收对象。

终极基类System.Object定义了受保护的虚方法Finalize。垃圾回收期判定判定对象是垃圾后,会调用对象的Finalize方法(如果重写)。C#团队认为Finalize在编程语言中需要特殊语法。因此,c#要求在类名前添加~符号来定义Finalize方法.

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

用ILDasm.exe查看得到的程序集,c#编译器实际是在模块的元数据中生成了名为Finalizeprotected override方法。查看Finalize的IL,会发现方法主体的代码被放到一个try块中,在finally块中则放入了一个base.Finalize调用.

C++有析构器. C#早期称此为析构器, 但是后来Finalize方法的工作方式与C++析构器完全不同, 会产生混淆. 所以称为终结方法.

使用Finalize的注意事项

  1. 被视为垃圾的对象在垃圾回收完毕后才调用Finalize方法. 所以这些对象的内存不是马上被回收的, 因为Finalize方法可能要执行访问字段的代码.
  2. 可终结对象在回收时必须存活, 造成它被提升到另一代. 使对象活得比正常时间长. 这增大了内存耗用, 应尽可能避免终结.
  3. 可终结对象被提升时, 其字段引用的所有对象也会被提升, 因为它们必须继续存活. 所有, 要尽量避免为引用类型的字段定义可终结对象.
  4. Finalize方法的执行时间是控制不了的. 应用程序请求更多内存时才可能发生GC, 而GC完之后才运行Finalize.
  5. CLR不保证Finalize方法的调用顺序, 在Finalize方法中不要访问定义了Finalize方法的其他类型的对象, 那些对象可能已经终结. 但可以访问安全地访问值类型的实例, 或者访问没有定义Finalize方法的引用类型的对象.
  6. 调用静态方法也要当心,这些方法可能在内部访问已经终结的对象,导致静态方法的行为变得无法预测。
  7. CLR用一个特殊的、高优先级的专用线程调用Finalize方法来避免死锁。如果Finalize方法阻塞,该特殊线程就调用不了任何更多的Finalize方法。 这是非常坏的情况,永远回收不了可终结对象占用的内存,就会一直泄露内存. 抛出未处理异常则进程终止,没办法捕捉该异常.

综上所述,Finalize方法问题较多,使用需谨慎。记住他们是为了释放本机资源而设计的。强烈建议不要重写ObjectFinalize方法。相反,使用Microsoft在FCL中提供的辅助类。这些辅助类重写了Finalize方法并添加了一些特殊的CLR“魔法”(之后提到)。你从这些辅助类派生自己的类,从而继承CLR的“魔法”。

SafeHandle

创建封装了本机资源的托管类型时,应该先从System.runtime.interopServices.SafeHandle这个特殊基类派生出一个类。

public abstract class SafeHandle : CriticalFinalizerObject, IDisposable
    {
        //这是本机资源句柄
        protected IntPtr handle;

        protected SafeHandle(IntPtr invalidHandleValue, Boolean ownsHandle)
        {
            handle = invalidHandleValue;
            //如果ownsHandle为true,那么这个从SafeHandle派生的对象被回收时,本机资源会被关闭
        }

        protected SafeHandle(IntPtr invalidHandleValue)
        {
            handle = invalidHandleValue;
        }

        //显式释放资源
        public void Dispose(){Dispose(true);}

        //默认的Dispose实现(如下所示)正是我们希望的。强烈建议不要重写这个方法
        protected virtual void Dispose(Boolean disposing)
        {
            //这个默认实现会忽略disposing参数
            //如果资源已经释放,那么返回
            //如果ownsHandle为true,那么返回
            //设置一个标志来指明该资源已经释放
            //调用虚方法ReleaseHandle
            //调用GC.SuppressFinalize(this)方法来阻止调用Finalize方法
            //如果ReleaseHandled返回true,那么返回
            //如果走到这一步,就激活ReleaseHandleFailed托管调试助手(MDA)

        }
        //派生类型要从写这个方法以实现释放资源的代码
        protected abstract Boolean ReleaseHandle();

        //默认的Dispose实现(如下所示)正是我们希望的。强烈建议不要重写这个方法
        ~SafeHandle(){Dispose(false);}

        public void SetHandleAsInvalid()
        {
            //设置标志来指出这个资源已经释放
            //调用GC.SuppressFinalize(this)方法来阻止调用Finalize方法
        }

        public Boolean IsClosed {
            get { //返回指出资源是否释放的一个标志}
        }
        public abstract Boolean IsInvalid
        {
            //派生类要重写这个属性
            //如果句柄的值不代表资源(通常意味着句柄为0或-1),实现应返回true
            get;
        }

        //以下三个方法设计安全性和引用计数
        public void DangerousAddRef(ref Boolean success){}
        public IntPtr DangerousGetHandle(){}
        public void DangerousRelease(){}

    }

SafeHandle类有两点需要注意:

  1. CLR以特殊方式对待这个类及其派生类,具体地说,CLR赋予这个类一下三个很酷的功能
    • 首次构造任何CriticalFinalizerObject派生类型的对象时,CLR立即对继承层次结构中所有的Finalize方法进行JIT编译。这样确保对象被确定为垃圾之后,资源肯定会得到释放。不提前编译的话,无法保证释放(内存紧张时,CLR可能找不到足够的内存来编译Finalize方法,这会阻止Finalize方法执行,造成本机资源泄漏)。
    • CLR是在调用了非CriticalFinalizerObject派生类型的Finalize方法之后,才调用CriticalFinalizerObject派生类型的Finalize。这样,托管资源类就可以在他们Finalize方法中成功地访问CriticalFinalizerObject派生类型的对象,例如fileStream类的Finalize方法可以放心地将数据从内存缓冲区flush(冲洗到别处)到磁盘,它知道此时磁盘文件还没有关闭。
    • 如果appdomain被一个宿主应用程序强行中断,CLR将调用CriticalFinalizerObject派生类型的Finalize方法。宿主应用程序不再信任它内部运行的托管代码时,也利用也利用好这个功能确保本机资源得到释放。
  2. SafeHandle是抽象类,必须有另一个类从该类派生并重写受保护的构造器、抽象方法ReleaseHandle以及抽象属性IsInvalid的访问器方法。
  3. 与本机代码相互操作时,SefaHandle派生类将获得CLR的特殊对待。
  4. SafeHandle派生类最后一个值得注意的功能是防止有人利用潜在的安全漏洞。问题起因是一个线程可能试图使用一个本机资源,而另一个线程试图释放该资源。这可能造成句柄循环使用漏洞。SafeHandle类防范这个安全隐患的方法是使用计数器。

使用包装了本机资源的类型

你现在知道了如何定义包装了本机资源的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异常
 }

很遗憾的是,生成并运行上述代码,大多数情况都是不呢个运行的。问题在于File的静态Delete方法要求Windows删除一个任然打开的文件。

类如果想允许使用者控制类所包装的本机资源的生存期,就必须实现如下所示的IDisposale接口。

public interface IDisposable{
	void Dipose();
}

幸好,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了托管对象过去用过的任何本机资源,也能在托管对象上调用方法, 只是不会成功而已,会抛出异常. 不会对造成对内存的破坏.

使用using来显示调用Dispose

如果决定显示调用Dispose,强烈建议将调用放到一个finally块中。这样可以保证清理代码得以执行。也可以使用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接口的类型.

StreamWriter和FileStream的一个问题讨论

System.IO.FileStream类型允许用户打开文件进行读写。为提高性能,该类型的实现利用了一个内存缓冲区只有缓冲区满时,类型才将缓冲区中的数据刷入文件。FileStream类型只支持字节的写入。写入字符和字符串可以使用一个System.IO.StreamWriter,如下所示

FileStream fs=new FileStream("temp.dat",FileMode.Create);
StreamWriter sw=new StreamWriter(fs);
sw.Write(Hi There);
// 不要忘记写下面这个Dispose调用
// 由于StreamWriter对象实现了IDisposable接口, 所以可以使用C#的using语句
// 调用Dispose,StreamWriter对象将数据flush到Stream对象并关闭该对象
sw.Dispose();
// 调用StreamWriter 的Dispose会关闭FileStream
// FileStream无需显示关闭
// 不需要再FileStream上显式调用Dispose,因为StreamWriter会帮你调用, 非要显式调用Dispose
// FileStream会发现对象已经清理过了, 所以方法什么都不做而直接返回.

不需要再FileStream上显式调用Dispose,因为StreamWriter会帮你调用, 非要显式调用Dispose,FileStream会发现对象已经清理过了, 所以方法什么都不做而直接返回.

没有代码显式调用Dispose会发生什么? 在某个时刻,垃圾回收器会正确检测到对象是垃圾,并对其进行终结, 但垃圾回收器不保证对象的终结顺序. 所以, 如果FileStream对象先终结, 就会先关闭文件,然后StreamWriter对象终结时,会试图向已关闭的文件flush(冲入,写入)数据, 造成抛出异常. 如果StreamWriter对象先终结,数据就会安全写入文件.

Microsoft解决这个问题是让StreamWriter对象类型不支持终结器, 如果不调用Dispose方法,就永远不会将缓冲区的数据flush到FileStream对象. 数据肯定丢失.

GC为本机资源提供的其他功能

  1. 包装本机资源的托管对象只占用很少的内存,但是本机资源有时会消耗大量内存.例如位图, 如果大量分配数百个位图(CLR觉得这些对象占用的内存很少不需要去GC),但进程的内存将以一个恐怖的速度增长.

    为了修正这个问题,GC类提供了以下2个静态方法:

    public static void AddMemoryPressure(Int64 bytesAllocated);
    public static void RemoveMemoryPressure(Int64 bytesAllocated);
    

    如果一个类要包装可能很大的本机资源,就应该使用这些方法提示垃圾回收器实际需要消耗多少内存. 垃圾回收器会监视内存压力, 压力变大时,就强制执行垃圾回收.

  2. 有的本机资源的数量是固定的。引用程序能打开的文件数量也必须有限制。如果这些本机资源的数量有限,那么一旦试图使用超过允许数量的资源,通常会导致抛出异常。为了解决这个问题,命名空间System。Runtime.InteropServices提供了HandleCollector类;使用方法见书本。

终结的内部工作原理

终结器表面上很简单: 创建对象, 当它被回收时, 它的Finalize方法得以调用. 深究下去, 就会发现终结的门道很多.

简单来说就是在New重写Finalize方法的对象时,会有一个终结列表来引用他们在托管堆中的内存,当对象被释放但没有调用Finalize方法时就会被移动到freachable队列中;该队列保证对象不会被是为垃圾处理掉,当调用Finalize方法后将对象从freachable队列移除,缺少队列保护后就成为了垃圾,会被后续的垃圾回收清理掉。

  1. new操作符会从堆中分配内存

  2. 如果对象的类型重写了System.Object继承的Finalize方法(就认为此类和派生类是可终结对象).不重写,就会被CLR认为忽略掉.认为是不可终结对象.

  3. 类型构造器被调用之前,会将该对象指针放入终结列表(finalization list)中,这个终结列表是由垃圾回收器孔子的一个内部数据结构.

  4. 回收对象的内存前,应调用该对象的Finalize方法.

系统检测到CEFIJ对象定义了Finalize方法, 所以将指向这些对象的指针添加到终结列表中.

垃圾回收开始时,对象BEGHIJ被判定为垃圾, 垃圾回收器扫描终结队列查找这些对象的引用, 找到后从终结列表移除, 移除并附加到freachable队列(也是垃圾回收器的一个内部数据结构)

freachable 名字的由来: f 代表终结 finalization . freachable队列中的每个记录项都是对托管堆中应调用其Finalize方法的一个对象的引用. reachable 代表对象是可达的. 换言之: 可将freachable队列看成是像静态字段那样的一个根(引用对象). 所以freachable队列中的引用使它指向的对象保持可达,不是垃圾.

BGH占用的内存已经被回收, EIJ占用的内存暂时不能回收, 因为它们的Finalize方法还没有调用.

一个特殊的高优先级CLR线程专门调用Finalize方法. freachable队列为空时,线程睡眠,一但进来记录项,就会唤醒线程去执行, 然后调用Finalize方法后移除. 注意不应该在Finalize中访问线程的本地存储.

当一个对象不可达时, 垃圾回收器就视它为垃圾. 但是当垃圾回收器将对象引用从终结列表移至freachable队列时, 就不再被认为是垃圾, 不能回收它的内存. 对象复活了…

标记freachable对象时, 垃圾回收器将递归标记对象中的引用类型的字段所引用的对象; 所以这些对象也必须复活以便在回收过程中存活. 之后, 垃圾回收器才结束对垃圾的标识. 在这个过程中, 一些原本被认为是垃圾的对象复活了,然后垃圾回收器压缩(移动)可回收的内存, 将复活的对象提升到较老的一(这不理想), 现在,特殊的终结线程清空freachable队列,执行每个对象的Finalize方法.

在下一次对老一进行回收时,会发现已终结的对象称为真正的垃圾, 因为没有指向他们. freachable队列也不再指向它们. 所以在整个过程中, 可终结对象需要执行两次垃圾回收才能释放它们占用的内存. 在实际应用中可能不止两次, 可能被提升至另一了.

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

CLR为每个AppDomain都提供了GC句柄表(GC Handle table), 允许应用程序监视或手动控制对象的生存期. 这个表创建之初是空白的, 其中的记录项包含2中信息:

  • 对托管堆中的一个对象的引用
  • 如何监视或控制对象的标志flag

简单地说,为了控制或监视对象的生存期, 可调用GCHandle静态Alloc方法,并传递想控制/监视的对象的引用. 还可传递一个GCHandleType枚举标志,定义如下:

public enum GCHandleType
{
   Weak = 0,                  // 用于监视对象的存在
   WeakTrackResurrection = 1, // 用于监视对象的存在
   Normal = 2,                // 用于控制对象的生存期
   Pinned = 3                 // 用于控制对象的生存期
}
  • Weak : 监视对象的生存期, 具体地说,可以检测垃圾回收器在什么时候判断该对象在应用程序代码中不可达. 注意, 此时对象的Finalize方法可能执行,也可能没执行, 对象可能还在内存中.
  • WeakTrackResurrection : 与上面相同, 不同的是此时对象的Finalize方法已经执行,对象的内存已经回收.
  • Normal : 该标志允许控制对象的生存期. 是告诉垃圾回收器: 即使应用程序中没有变量(根)引用该对象. 该变量也必须留在内存中. 垃圾回收发生时, 该对象的内存可以压缩(移动). 不向Alloc方法传递任何GCHandleType标志.就默认用Normal.
  • Pinned : 与上面那条不同的是, 垃圾回收发生时, 该对象的内存不能压缩(移动). 这个功能用于需要将内存地址交给本机代码时,本机代码知道GC不会移动对象,所以能放心地向托管堆的这个内存写入.

GCHandle静态Alloc方法会在调用时扫描AppDomain的GC句柄表,调用GCHandleFree方法, 将IntPtr字段设为0, 使实例变得无效.

垃圾回收器如何使用GC句柄表, 当垃圾回收发生时, 垃圾回收器的行为如下:

在什么情况下使用, 具体看书.

fix语句的使用

关于强弱引用

开发人员刚开始学习弱引用时, 会马上想到它们在缓存情形中的用处. 例如: 构造包含大量数据的一组对象, 并创建这些对象的弱引用. 需要数据时就去检查这些弱引用, 看看包含这些数据的对象是否依然”健在”, 对象还在就直接使用对象; 这与程序就会有较好的性能, 但是如果发生垃圾回收, 包含数据的对象就会被销毁. 一旦需要重新创建数据,性能就会收到影响.

垃圾回收不是内存满或接近满时才发生的, 只要第0代满了,垃圾回收就会发生.

弱引用在缓存情形中确实能得到高效应用,但构建良好的缓存算法来找到内存消耗和速度之间的平衡点十分复杂. 简单的说: 你希望缓存保持对自己的所有对象的强引用, 内存吃紧就开始将强引用转换为弱引用. 通过调用WIN32的GlobalMemoryyStatusEx函数并检查返回MEMORYSTATUSEX结构的dwMemoryLoad成员做到这一点. 当大于80,内存空间处于吃紧状态.

Licensed under CC BY-NC-SA 4.0
0