重温CLR(十五) 托管堆和垃圾回收

  本章要讨论托管应用程序如何构造新对象,托管堆如何控制这些对象的生存期,以及如何回收这些对象的内存。简单地说,本章要解释clr中的垃圾回收期是如何工作的,还要解释相关的性能问题。另外,本章讨论了如何设计应用程序来最有效地使用内存。

托管堆基础

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

1 调用IL指令newobj,为代表资源的类型分配内存(一般使用c# new 操作符来完成)

2 初始化内存,设置资源的初始状态并使资源可用。类型的实参构造器负责设置初始状态。

3 访问类型成员来使用资源。

4 摧毁资源的状态以进行清理

5 释放内存。垃圾回收器独自负责这一步。

       如果需要程序员手动管理内存(例如原生c++开发人员就是这样的),这个看似简单的模式就会成为导致大量程序错误的元凶之一。(内存泄漏和试图使用已释放的内存)

       现在,只要写的是可验证的、类型安全的代码,应用程序就不可能会出现内存被破坏的情况。内存仍有可能泄漏,但不像以前那样是默认行为。现在内存泄漏一般是因为在集合中存储了对象,但不需要对象的时候一直不去删除。

       为了进一步简化模型,垃圾回收期会自动释放内存。

       使用需要特殊清理的类型时,编程模型还是像刚才描述的那样。只是有时需要尽快清理资源,而不是非要等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指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。

       对于托管堆,分配对象只需要在指针上加一个值—速度相当快。在许多应用程序中,差不多同时分配的对象彼此间有较强的联系,而且经常差不多在同一时间访问。例如,经常在分配一个BinaryWriter对象之前分配一个FileStream对象。然后,应用程序使用BinaryWriter对象,而后者在内部使用FileStream对象。由于托管堆在内存中连续分配这些对象,所以会因为引用的“局部化”(locality)而获得性能上的提示。具体地说,这意味着进程的工作机会非常小,应用程序只需要使用很少的内存,从而提高了速度。还意味着代码使用的对象可以全部驻留在cpu的缓存中。结果是应用程序能以惊人的速度访问这些对象,因为cpu在执行大多数操作时,不会因为缓存未命中而被迫访问较慢的ram。

       根据前面的描述,似乎托管堆的性能天下无敌。但先别激动,刚才说的有一个大前提—内存无线,clr总是能分配新对象。但内存不可能无限,所以clr通过称为垃圾回收的计数删除堆中你的应用程序不再需要的对象。

垃圾回收算法

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

       提示:前面的描述过于简单。事实上,垃圾回收是在第0代满的时候发生的。本章后面会解释代。

       至于对象生存期管理,有的系统采用的是某种引用计数算法。事实上,Microsoft自己的组件对象模型(component Object Model,COM)用的就是引用计数。在这种系统中,堆上的每个对象都维护着一个内存字段来统计程序中多少“部分”正在使用对象。随着每一部分到达代码中某个不再需要对象的地方,就递减对象的计数字段。计数字段变成0,对象就可以从内存中删除了。许多引用计数系统最大的问题是处理不好循环引用。例如在gui应用程序中,窗口将容纳对子ui元素的引用,而子ui元素将容纳对父窗口的引用。这种引用会组织两个对象的计数器达到0,所以两个对象永远不会删除,即使应用程序本身不再需要窗口了。

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

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

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

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

       clr知道哪些对象可以幸存,哪些可以删除后,就进入GC的压缩阶段。在这个阶段,clr对堆中已标记的对象进行“乾坤大挪移”,压缩所有幸存下来的对象,使他们占用连续的内存空间。这样做有许多好处。首先,所有幸存对象在内存中紧挨在一起,恢复了引用的“局部化”,减小了应用程序的工作集,从而提升了将来访问这些对象时的性能。其实,可用空间也全部是连续的,所以这个地址空间区段得到了解放,允许其他东西进驻。最后,压缩意味着托管堆解决了本机堆空间碎片化问题

       在内存中移动了对象之后有一个问题亟待解决。引用幸存对象的根现在引用的还是对象最初在内存中的位置,而非移动后的位置,被暂停的线程恢复执行时,将访问旧的内存位置,会造成内存损害。这显然不能容忍的,所以作为压缩阶段的一部分,clr还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象:只是对象在内存中变换了位置。

       压缩好内存后,托管堆的NextObjPtr指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。

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

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

垃圾回收和调试

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

 

       运行代码,会发现timerCallback方法只被调用一次,因为方法调用了GC.Collect()强制执行了一次垃圾回收。由于main方法再也没有引用过t变量,所以Timer对象被回收了。。。。     

在main方法最后加t=null 并运行,会发现仍然只被调用一次,这是因为,jit编译器优化了代码,将局部变量或参数设为null,等价于根本不引用该变量,jit编译器将这行代码优化掉了。。。

       在main方法最后加上t.Dispose(),并执行,会发现方法被正确地重复调用,t对象必须存活,才能在它上面调用Dispose实例方法。真是讽刺,要显示要求释放计数器,它才能活到被释放的那一刻。

       注意:所有非timer对象都会根据应用程序的的需要而自动存货。timer是一个比较特殊行为。

代:提升性能

       clr的gc是基于代的垃圾回收期,他对你的代码做出做出了以下几点假设

1 对象越新,生存期越短。

2 对象越老,生存期越长。

3 回收堆的一部分,速度快于回收整个堆

       大量研究证明,这些假设对于现今大多数应用程序都是成立的,它们影响了垃圾回收期的实现方式。

       托管堆在初始化时不包含对象。添加到堆的对象称为第0代对象。简单地说,第0代对象就是那些新构造的对象,垃圾回收期从未检查过它们。

       clr初始化时为第0代对象选择一个预算容量(以kb为单位)。如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。垃圾挥手之后,未被引用的对象将会被回收,其他对象称为第一代对象,并重新压缩位置,使对象相邻。一次垃圾回收之后,第0代就不包含任何对象了。然后会重复上面的逻辑。

       开始一次垃圾回收时,垃圾回收期还会检测第一代占用了多少内存。(第一代也有预算)由于本例第一代占用内存为很少,所以忽略第一代的对象,加快了垃圾回收的速度。

       显然,忽略第一代对象能提升垃圾回收期的性能。对性能有更大提振作用的是现在不必遍历托管堆中的每个对象。如果根或对象引用了老一代的某个对象,垃圾回收期可以忽略老对象内部的所有引用,能在更短的时间内构造好可达对象图。当然,老对象的字段也有可能引用新对象。为了确保对老对象的已更新字段进行检查,垃圾回收期利用了jit编译器内部的一个机制。这个机制在对象的引用字段发生变化时,会设置一个对应的位标志。这样,只有字段发生变化的老对象才需检查是否引用了第0代新对象。

       注意:Microsoft的性能测试表明,对第0代执行一次垃圾回收,所花的时间不超过1毫秒。

       只有当第一代分配的内存达到预算时,才会进行第一代的内存回收,这时,仍被引用的幸存者将变为第二代。托管堆只支持三代:第0代、第一代和第二代。clr初始化时,会为每一代选择预算。然而,clr的垃圾回收器是自调节的。

垃圾回收触发条件

       前面说过,clr在检测第0代超过预算时触发一次GC。这是GC最常见的触发条件,下面列出其他条件

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

       虽然Microsoft强烈反对这种请求,但有时情势比人强。

2 windows报告低内存情况

3 clr正在卸载AppDomain

       一个AppDomain卸载时,clr认为其中一切都不是根,所以执行涵盖所有代的垃圾回收。

4 clr正在关闭

       clr在进程正常终止时关闭。关闭期间,clr认为进程中一切都不是根。对象有机会进行资源清理,但clr不会视图压缩或释放内存。整个进程都要终止了,windows将回收进程的全部内存。

大对象

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

1 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。

2 目前版本的gc不压缩大对象,因为在内存中移动他们代价过高。

3 大对象总是第二代,绝不可能是0代或1代。所以只能为需要长时间存活的资源创建大对象。分配短时间存货的大对象会导致第二代被更频繁的回收,会损害性能。大对象一般是大字符串(比如XML或Json)或用于IO操作的字节数组。

垃圾回收模式

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

1 工作站

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

2 服务器

       该模式针对服务器端应用程序优化gc。被优化的主要是吞吐量和资源里利用。GC假定机器上没有运行其他应用程序,并假定机器的所有cpu都可用来辅助完成gc。该模式造成托管堆被拆分成几个区域(section),每个cpu一个。开始垃圾回收时,垃圾回收期在每个cpu上都运行一个特殊线程;每个线程和其他线程并发回收它自己的区域。对于工作者线程行为一致的服务器应用程序,并发回收能很好地进行。

       应用程序默认以工作站GC模式运行。寄宿了CLR的服务器应用程序可请求clr加载服务器GC。但如果服务器应用程序在单处理器计算机上运行,clr将总是使用工作站gc模式。

       独立应用程序可创建一个配置文件告诉CLR使用服务器回收器。应用程序运行时,课查询GCSettings类的只读bool属性IsServerGc来询问clr它是否正在服务器GC模式中运行。

       除了这两种主要模式,gc还支持两种子模式:并发(默认)或非并发。在并发方式中,垃圾回收期有一个额外的后台线程,它能在应用程序运行时并发标记对象。

强制垃圾回收

       调用GC类的Collect方法强制垃圾回收。,可像方法传递一个代表最多回收几代的整数、一个GCCollectionMode以及制定并发或非并发回收的一个bool值。

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

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

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

       但是被视为垃圾的对象在垃圾回收完毕后才调用Finalize方法,所以这些对象的内存不是马上被回收的,因为Finalize可能要执行访问字段的代码,这造成它被提升到另一代,存活更长时间。clr用一个特殊的、高优先级的专用线程调用Finalize方法来避免死锁。如果Finalize方法阻塞,该特殊线程就调用不了任何更多的Finalize方法。

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

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

clr以特殊方式对待这个类及其派生类,具体地说,clr赋予这个类一下三个很酷的功能

1 首次构造任何CriticalFinalizerObject派生类型的对象时,clr立即对继承乘次结构中所有的Finalize方法进行jit编译。这样确保对象被确定为垃圾之后,资源肯定会得到释放。(内存禁止时,clr可能找不到足够的内存来编译Finalize方法,这回阻止Finalize方法执行,造成本机资源泄漏)。

2 clr是在调用了非CriticalFinalizerObject派生类型的Finalize方法之后,才调用CriticalFinalizerObject派生类型的Finalize。这样,托管资源类就可以在他们Finalize方法中成功地访问CriticalFinalizerObject派生类型的对象,例如fileStream类的finalize方法可以放心地将数据从内存缓冲区flush到磁盘,它知道此时磁盘文件还没有关闭。

3 如果appdomain被一个宿主应用程序强行中断,clr将调用CriticalFinalizerObject派生类型的finalize方法。宿主应用程序不再信任它内部运行的托管代码时,也利用也利用好这个功能确保本机资源得到释放。

       safeHandle是抽象类,必须有另一个类从该类派生并重写受保护的构造器、抽象方法releaseHandle以及抽象属性isInvalid的get访问器方法。

       大多数本机资源都用句柄(32位系统是32位值,64位系统是64位值)进行操作。所以safeHandle类定义了受保护IntPtr字段handle。safeHandle派生类非常有用,因为它们保证本机资源在垃圾回收时得以释放。safeHandle派生类另一个值的注意的功能是防止句柄循环使用,比如一个线程视图使用一个本机资源,另一个线程试图释放该资源。SafeHandle类防范这个安全隐患的办法是使用引用计数。一旦某个safeHandle派生对象被设为有效句柄,计数器就被设为1。每次讲safeHandle配色对象作为实参创给一个本机方法,clr就会自动递增计数器。调用后,递减。

       现在,当一个线程视图释放safeHandle对象包装的本机资源时,clr知道它实际上不能释放资源,因为该资源正在由一个本机函数使用。本机函数返回后,计数器递减为0,资源才会得到释放。

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

       你现在知道了如何定义包装了本机资源的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);
    File.Delete("temp.dat");//抛出Io异常
}

delete方法要求windows删除一个仍然打开的问题,所以会抛出异常。

       幸好,fileStrram类实现了IDisposable接口。通过IDisposable接口来显示关闭文件,就可以修复这个问题。

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,强烈建议将调用放到一个finally块中。这样可以可以爱爆炸清理代码得以执行。也可以使用using语句,简化编码。

一个有趣的依赖性问题

       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调用
sw.Dispose();
//调用StreamWriter 的Dispose会关闭FileStream
//FileStream无需显示关闭

       注意,StreamWriter的构造器接收一个Stream对象引用作为参数,可以向他传递一个FileStream对象引用作为实参。在内部,StreamWriter对象会保存Stream对象引用。向一个StreamWriter对象写入时,它会将数据缓存在自己的内存缓冲区中,缓冲区满时,StreamWriter对象将数据写入Stream对象。通过StreamWriter对象写入数据完毕后应调用Dispose。这导致StreamWriter对象将数据flush到Stream对象并关闭该Stream对象。(不需要在FileStream上显示调用Dispose,因为StreamWriter会帮你调用,但如果非要显示调用,FileStream会发现对象已经清理过了,所以方法上面都不做而直接返回)

       如果代码没有显示调用Dispose,在某个时刻,垃圾回收期会检测到对象是垃圾,并对其终结回收。但是垃圾回收期不能保证对象的终结顺序。如果FileStream先终结,那么终结StreamWriter时,flush数据到底层FileStream会报错。Microsoft希望开发人员注意到这个数据丢失问题,并插入对Dispose的调用来修正。

终结的内部工作原理

       终结表明上很简单:创建对象,当它被回收时,它的finalize方法得以调用。但深究下去,会发现里面有很多逻辑。而且终结会造成一些应该被回收的对象升代。

       这里不展开论述了,想深入了解可以去查看原文。

原文地址:https://www.cnblogs.com/qixinbo/p/10731401.html