本章要讨论托管应用程序如何构造新对象,托管堆如何控制这些对象的生存期,以及如何回收这些对象的内存。简单地说,本章要解释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方法得以调用。但深究下去,会发现里面有很多逻辑。而且终结会造成一些应该被回收的对象升代。
这里不展开论述了,想深入了解可以去查看原文。