你的位置:首页 > ASP.net教程

[ASP.net教程]【C#进阶系列】20 托管堆和垃圾回收


托管堆基础

一般创建一个对象就是通过调用IL指令newobj分配内存,然后初始化内存,也就是实例构造器时做这个事。

然后在使用完对象后,摧毁资源的状态以进行清理,然后由垃圾回收器来释放内存。

托管堆除了能避免错误使用已经被释放的内存,也会减少内存泄漏,大多数类型都无需资源清理,垃圾回收器会自动释放资源。

当然也有需要立即清理的,比如一些包含了本机资源的类型(如文件、套接字和数据库连接等),可在这些类中调用一个Dispose方法。(当然有的类对这个方法封装了一下,可能是别的名字比如断开数据库连接的close)

在托管堆上分配资源

CLR要求所有对象都从托管堆分配。

进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR还维护一个指针,即NextObjPtr。该指针指向下一个对象在堆中的分配未知。

一个区域被非垃圾对象填满后,CLR会分配更多的区域,这个过程不断重复,直到整个进程地址空间都被填满。32位进程最多分配1.5G,64位进程最多分配8TB。

当创建一个对象时,首先会计算该对象类型字段(包括基类)所需字节数,加上对象的开销所需的字节数(即类型对象指针和同步块索引)。

然后CLR会检查区域中是否有分配对象所需字节数大小的内存。如果托管堆有,那么就在NextObjPtr指向的地址放入对象,且NextObjPtr会加上对象占用的字节数得到新值,即下个对象放入时的地址。

通过垃圾回收器(GC)回收资源

CLR在创建对象时发现没有足够内存分配该对象,那么就会执行垃圾回收。

CLR在进行垃圾回收时,首先会暂停所有线程,

标记阶段:然后CLR会遍历堆中所有的引用对象,将同步块索引字段中的一位设为0,表示所有对象都要删除。然后检查所有的活动根(即所有引用类型的字段以及方法的参数和局部变量),查看它们引用了哪些对象。任何根引用了堆上的对象,那么CLR就标记那个对象,将同步块索引字段中的位设为1,如果对象已经被标记为1了,那么就不再重新检查对象的字段。标记为1的也就是被引用的对象,称为可达的,标记为0的就是不可达的。此时CLR就知道了哪些对象可以删除,哪些对象不能删除。

压缩阶段:CLR对堆中已标记的对象进行搬移内存位置(且对象所有的根的引用也自然会跟着变动),使得被标记的对象紧密相连,即占用连续的内存空间。这样不仅减小了应用程序的工作集,从而提升了访问性能,还得到了大量的未占用内存空间,并且解决了内存碎片化的问题。

最后,恢复所有线程。

静态字段引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地往集合添加数据项。静态字段使集合对象一直存活,而集合对象使所有数据项一直存活。因此应该尽量避免使用静态字段。(或者参照前面的玩法,当我们不用静态变量的时候,可以立马置为null,那么垃圾就会被回收)。

有一个神奇的垃圾回收特例——Timer。原因是它会每隔一段时间去调用回调函数,但是根据之前学的垃圾回收玩法可以知道当Timer的变量离开了作用域,且没有其它函数引用了Timer对象,那么在垃圾回收时Timer就会被回收掉。也就不会去执行回调函数了。(所以说慎用Timer,这里有这么一个大坑)

代:提升性能

CLR的GC是基于代的垃圾回收器。它对代码做了如下假设:

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

第0代:新添加到堆的对象称为第0代对象,垃圾回收器从未检查过它们。

第1代:第0代对象经过一次垃圾回收,但是并没有被当做垃圾释放掉,那么就会在压缩阶段一起放入第1代对象区域。

第2代:第1代对象又经过了一次垃圾回收,但是并没有被当做垃圾释放掉,那么就会在压缩阶段一起放入第2代对象区域。没有第3代,第2代放着的就是经过了2次和2次以上垃圾回收的对象。

第0代内存区域满了就会进行垃圾回收,此时不仅会回收第0代的区域,还会去判断第1代区域是否也满了,满了也回收第1代,不满的话即时第1代里面有不可达的对象,那么也不会回收第1代。

CLR初始化时,会为这三代分别选择内存预算,以此判断什么时候该回收了。但是CLR的垃圾回收器是自调节的。

也就是说

如果垃圾回收器发现第0代回收后存活下来的对象很少,那么就会减少第0代的预算,这样的话垃圾回收就会发生得更频繁了,然而垃圾回收器每次做的事更少了,这减小了工作集。如果没有一个存活的话,连压缩都免了。

如果垃圾回收器发现第0代回收后存活下来的对象很多,那么就会加大第0代的预算,这样的话垃圾回收就会发生得不频繁了,然而垃圾回收器每次回收的内存要多得多。(如果没有回收到足够的内存,那么垃圾回收器会执行一次完整回收,如果还是没有足够内存,那么就会抛出OutOfMemoryException异常)。

上面是用第0代举例,第1、2代也如是。 

垃圾回收触发条件

CLR在检测第0代超过预算时会触发一次GC,这是GC最常见的触发条件,还有其它的触发如下:

  • 代码显示调用System.GC的静态Collect方法
  • Windows报告低内存情况
  • CLR正在卸载AppDomain
  • CLR正在关闭

大对象

CLR将对象分为大对象和小对象,以85000字节为界限。

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

目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。(可能会造成空间碎片)

大对象总是第2代,所以只能为需要长时间存活的资源生成大对象,否则若短时间存活的大对象放在第二代中,因为之前讲到一次回收过多内存,就会将代的预算减少,导致更频繁回收第2代,会损害性能。

垃圾回收模式

CLR启动时会选择一个GC模式,进程终止前该模式不会改变:

  • 工作站模式
    • 该模式针对客户端应用程序优化GC。GC造成延时很低,应用程序线程挂起时间很短,避免使用户感到焦虑。在该模式下,GC假定机器上运行的其它应用程序都不会消耗太多的CPU资源。 
  • 服务器模式
    • 该模式针对服务器端应用程序优化GC。被优化的主要是吞吐量和资源利用。GC假定机器上没有运行其它应用程序,并假定机器上所有的CPU都可以用来辅助完成GC。该模式造成托管堆被分为几个区域(section),每个CPU一个。开始垃圾回收时,垃圾回收器在每个CPU上都运行一个特殊线程,每个线程和其他线程并发回收它自己的区域。对于工作者线程(worker thread)行为一致的服务器应用程序,并发回收能很好地进行。这个功能要求应用程序在多CPU计算机上运行,使线程能真正地同时工作吗,从而获得性能上的提升。

应用程序默认以工作站GC模式运行。寄宿了CLR的服务器应用程序(比如ASP.NET和Sql Server)可请求CLR加载“服务器”GC,但如果是单处理器计算机上运行,CLR将总是使用工作站GC模式。

独立应用程序可在配置文件中,加上下面配置项告诉CLR使用服务器模式:

<configuration> <runtime>  <gcServer enabled="true"/> </runtime></configuration>

除了这两种模式,GC还支持两种子模式:并发(默认)和非并发。

在并发模式下,GC有一个额外线程,能在运行时并发标记对象。

而由另一个线程去判断是否压缩对象,GC可以更倾向于决定不压缩,有利于增强性能,但会增大应用程序工作集。使用并发垃圾回收器,消耗的内存比非并发更多。

加上以下配置项告诉CLR使用非并发模式:

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

使用需要特殊清理的类型

大多数类型只需要内存就可以了,然而有的类型还需要本机资源。比如System.IO.FileStream类型需要打开一个文件(本机资源)并保存文件的句柄。

包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源(GC对它一无所知)的泄漏,所以CLR提供了称为终结的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。

任何包装了本机资源(文件,网络连接,套接字,互斥体)的类型都支持终结。CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后GC会从托管堆回收对象。

C#的语法,跟析构函数差不多,但是所代表的意义不同

 public class Troy {    ~Troy() {      //这里的代码就是垃圾回收前执行的代码,这段代码会被放在一个try块中,而finally部分放的是base.Finalize    }  }

这个语法最后在IL代码里还是生成一个叫Finalize的方法。

被视为垃圾的对象在垃圾回收完毕后才调用Finalize方法,所以这些对象的内存不是马上被回收,因为Finalize方法可能要执行访问字段的代码。

可终结对象在回收时必须存活,造成它被提升到另一代,使对象活得比正常时间长。这增大了内存耗用,所以应尽量避免终结。

终结的内部原理

在创建新对象的时候,会在堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放入到一个终结列表里。

终结列表是一个由垃圾回收器控制的内部数据结构,列表的每一项都指向一个个对象——回收该对象的内存前应调用它的Finalize方法。

在每次要回收垃圾对象时标记阶段走完都会去扫描终结列表,如果存在垃圾对象的引用,该引用被移除终结列表,并附加到freachable队列。(此时对象将不再被认为是垃圾,不能回收其内存,被称为对象复活了)

freachable队列也是垃圾回收器的一个内部数据结构,队列中的每个引用所指向的对象都已经准备好调用Finalize方法了。

CLR用一个特殊的、高优先级的专用线程调用Finalize方法来避免死锁。

如果freachable队列为空,那么此线程睡眠,一旦不为空,此线程会被唤醒,将每一项都从队列中移除,并且同时调用每个对象的Finalize方法。

然后进入压缩阶段,将这些复活的对象提升到下一代。

然后清空freachable队列,并执行每个对象的Finalize方法。

到了下次执行垃圾回收时,因为终结列表已经没有这些对象的指针了,所以现在它们被认为是真正的垃圾了,也就会被释放。

整个过程中,执行了两次垃圾回收才释放掉内存,在实际的过程中,由于对象可能被提升至另一代,所以可能要求不止进行两次垃圾回收。

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

CLR为每个AppDomain都提供了一个GC句柄表,允许应用程序监视和手动控制对象的生存期。这个就太6了,感觉用不到,用得到的时候回来再看吧。

 

PS:

最近两章效率真是慢,一方面因为双休没看书和一些突发状况,另一方面也是因为已经开始了CLR的核心机制之旅,里面的很多东西确实没听过,感觉难度开始增大了。

在此过程中键盘莫名其妙坏了,并且两次关机废了写了一半的博客。今天才发现原来强行关机后再开机,浏览器中写了一半的博客是可以恢复的。