【C#进阶系列】21 托管堆和垃圾回收
托管堆基础
一般创建一个对象就是通过调用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的核心机制之旅,里面的很多东西确实没听过,感觉难度开始增大了。
在此过程中键盘莫名其妙坏了,并且两次关机废了写了一半的博客。今天才发现原来强行关机后再开机,浏览器中写了一半的博客是可以恢复的。