菜鸟之旅——.NET垃圾回收机制
.NET的垃圾回收机制是一个非常强大的功能,尽管我们很少主动使用,但它一直在默默的在后台运行,我们仍需要意识到它的存在,了解它,做出更高效的.NET应用程序;下面我分享一下我对于垃圾回收机制(GC)的学习心得。
GC的必要性
我们知道程序会需要向内存堆使用new请求内存,然后将请求的内存初始化并使用,使用完毕之后,变清理资源和释放内存,等待别的程序来请求使用;对内存资源的管理方式,现在存在这么几种管理方式:
1、手动管理:C、C++
2、计数管理:COM
3、自动管理:.NET、JAVA、PHP
现在的高级语言基本上都实现了自动管理内存,这是因为手动管理内存会因为人为的原因产生以下问题:
1、开发人员忘记释放请求的内存,造成内存泄漏,若是内存泄露过多,则可能会造成内存溢出,导致程序无法运行;
2、应用程序访问已释放的内存,造成数据读取错误。
由此可见,手动去管理堆里面的内存可靠程度,会因开发人员的不同而不同,在C++因指针而出现的问题可不少;而且易出现Bug等乱七八糟的问题,影响系统稳定性,所以自动化管理内存是必要的。
GC的工作原理
通用概念
回收时机
当应用程序分配新的对象,GC的代的预算大小已经达到阈值,比如GC的第0代已满;
代码主动显式调用System.GC.Collect();
其他特殊情况,比如,windows报告内存不足、CLR卸载AppDomain、CLR关闭,甚至某些极端情况下系统参数设置改变也可能导致GC回收。
应用程序根
应用程序根(application root):根(root)就是一个存储位置其中保存着对托管堆上一个对象的引用,根可以属性下面任何一个类别
- 全局对象和静态对象的引用
- 应用程序代码库中局部对象的引用
- 传递进一个方法的对象参数的引用
- 等待被终结(finalize,后面介绍)对象的引用
- 任何引用对象的CPU寄存器
代
垃圾回收器将托管堆(heap)里面的对象划分为3个代(一般为3代),可以使用GC.MaxGeneration()方法来进行查询当前系统所支持的最大代数:
1、G0 小对象(Size<85000Byte):新分配的小于85000字节的对象
2、G1:在GC中幸存下来的G0对象
3、G2:大对象(Size>=85000Byte);在GC中幸存下来的G1对象
当一个对象被new的时候,它的代为0,经过一次回收之后,若该对象没有被回收,则代上升,变为1,若每次回收都幸存下来,则代都会上升,最大代为操作系统所支持的最大代。
因为将对象以代划分,并且可以单独回收某一个世代,避免回收整个托管堆,提升性能。一个基于代的垃圾回收器有一下特点:
1、对象越新,生存期越短;
2、对象越老,生存期越长;
3、回收堆的一部分,速度快于回收整个堆。
工作过程
标记对象
在垃圾回收的第一步就是标记对象:垃圾回收器会认为托管堆中的所有对象都是垃圾,然后垃圾回收器会去检查所有的应用程序根,遍历每个根所引用到的对象,将其标记为活动的(live ),所有的根对象都检查完之后,有标记的对象就是可达对象,未标记的对象就是不可达对象,不可达对象就是回收的目标。
弱引用对象则不在考虑范围之内,所以一定会被回收掉的。
销毁对象,释放内存
在经过第一步的对象筛选之后,回收没有被引用的对象,就是不可达对象,GC调用对象默认的终结器Finalize(),销毁对象之后,将内存也释放掉。
同时,还存在引用的对象,就是可达对象的世代变为下一个世代。
压缩堆内存
经过第二步的销毁对象和释放内存之后,幸存下来的对象在堆中的排列可能是不连续的,这时在堆中存在非常多的内存碎片,程序在new对象的时候都是请求一段连续的内存,则内存碎片可能就无法再次利用(虽然没有被使用),造成内存资源的浪费,所以垃圾回收的最后一步就是压缩内存:将垃圾回收后幸存的对象移动到一起,并且将各个对象的引用更新到对象新的位置上,保证对象引用的正确性。
注:从这里看得出,在压缩堆内存的时候,所有相关线程必须暂停,因为压缩时不能保证对象引用的正确性,所以在垃圾回收的时候,GC会劫持所有相关线程,在回收完毕之后,被劫持的线程才会正常工作,所以垃圾回收势必会影响一定的性能,所以慎用System.GC.Collect()。
Finalize()与Dispose()
上面说到,GC在回收对象的时候是调用对象的终结器Finalize()来实现的,那么,就简单的总结一下Finalize()与Dispose()吧:
1、调用者:
Finalize只能由GC调用
Dispose由开发人员显示调用,也可以使用use区块,在程序离开区块使自动调用Dispose方法
2、调用时机:
Finalize由于是GC调用的,所以调用时机是垃圾回收的时候调用,时机不确定
Dispose由于是显示调用,所以调用时机是确定的,在调用方法的时候就调用了
3、目的:
这里的目的主要说是Dispose出现的目的;
首先是.NET存在托管资源和非托管资源,一般来说,非托管资源数量有限,比较珍贵,在使用完毕之后,希望能够释放掉,那么将释放非托管资源的方法写到终结器Finalize里面也是可以的,但是由于Finalize的调用时机不确定,导致释放资源不及时,那么有限的非托管资源很快就被占用完毕,所以,为了能够及时的释放掉这类资源,我们需要能够显示调用的方法,这就是Dispose。
Finalize主要是为了GC释放托管资源和销毁对象,释放内存
Dispose主要是为了释放托管和非托管资源和销毁对象,释放内存
注:不必担心资源的重复释放问题,就算是重复释放,.NET也做好了相应措施来处理,不会抛出异常。
下面贴一个MSDN推荐的标准的Dispose实现方式
class Class : IDisposable { // 标识:是否释放托管资源 private bool disposed = false; // 显示调用的方法 public void Dispose() { Dispose(true); // 将对象从垃圾回收器链表中移除, // 从而在垃圾回收器工作时,只释放托管资源,而不执行此对象的析构函数 GC.SuppressFinalize(this); } // 受保护的释放资源方法 protected virtual void Dispose(bool disposing) { if (!this.disposed) { if (disposing) { // 此处写释放托管资源的方法 } disposed = true; // 此处写释放非托管资源的方法 } } ~Class() { // 这里是防止忘记显示调用Dispose(),在GC进行垃圾回收的时候进行释放非托管资源 Dispose(false); } }
总结
GC所带来的便利是不言而喻的,但是这是付出一定的系统性能来实现的:在垃圾回收的时候GC会劫持所有相关的线程,并且会有一定的时空开销,所以在平时开发过程中注意一些良好的开发习惯可能会对GC有一些积极的影响。
1、尽量不要new很大的对象,大对象(>=85000Byte)直接归为G2代,GC回收算法从来不对大对象堆(LOH)进行内存压缩整理,移动大对象将会消耗更多的CPU时间,也更容易造成内存碎片。这里也可以将大对象或者生命周期长的对象进行池化。
2、不要频繁的new生命周期短的小对象,这可能会导致频繁的垃圾回收,这里可以考虑使用结构体放在栈中来代替,或者也可以使用对象池化来优化。
3、不推荐使用对象池化的解决方案,它比较笨重和容易出错,设计一个高性能稳定的对象池并不容易。
4、降低对象之间的纵向深度,GC在回收过程中,会先顺着根来进行对象遍历和标记,减少深度可以加快遍历速度;若系统中各个类之间的关系错综复杂,那么考虑一下设计方案是否合理。
当然注意的地方还有不少,最后贴一篇博客,这里介绍了如何编写高性能的.NET代码,其中的GC介绍非常详细: