摘抄:.NET垃圾回收和资源管理
2009-12-22 09:48 Virus-BeautyCode 阅读(596) 评论(0) 编辑 收藏 举报感谢作者:
Venus神庙
重新学.Net[六]——垃圾回收和资源管理[上]
一直觉得C++的资源管理让人很痛苦。我不得不看很多经验性手册,以保证能很好的进行内存等资源的管理。相比之下,.Net(CLR)引入了垃圾回收机制(GC),来完成托管堆资源的回收,这毫无疑问,大大减轻了开发人员的负担。但是,天下没有绝对免费的午餐,要想清楚地了解GC的运行,很好的掌握资源的管理工作(特别是涉及到非托管资源的时候),对我这种AI没有突破性进展的人来说,并不是一件很容易的事情。
恩。废话不说,来具体看看CLR的资源管理机制。首先,来看看CLR对内存(托管堆)的分配。最简单的说法就是,托管堆是被连续的一块一块分配出去的。具体一些,托管堆每次会将空堆开头的那块分配出去,然后指向新空堆的开头,方式就和栈分配类似。如果,托管堆只有这样一个分配过程的话,其速度无疑是快的可怕,那做.Net程序员也太幸福了。只可惜,内存是有限的,必须在合适的时候启动GC来进行无效对象(就是没人用了的)的回收。
GC对托管堆内存的回收是一个复杂的过程。在用一句话描述的话,就是每次启动GC,它会回收一部分无根的对象。所谓无根的对象,就是指在当前执行域中没有变量能再使用的对象。比如:
object a=new object();
a=null;
这时候原来初始化a的时候new出来的对象就成了无根对象,就是可回收的对象了。GC知道什么是无根对象吗?恩,在大部分的时候,我们要相信同志,相信它不会漏网一个无根对象也不会将一个有根的对象当无根对象干掉。再具体看看回收的算法,这会有些复杂了。简单的说GC的回收算法是建立在新对象生存期短的假设下的(这个假设是很靠谱的,考虑一下我们写的代码就可以明白,最外层的代码总是被用很久,一个for里面的变量往往用过就抛弃了)。所以它会优先回收那些比较新的对象占用的内存。更具体一些,它采用一种被称为代龄算法的回收算法,默认将所有堆中的对象分成0,1,2三个代龄,代龄越大的对象越老,越可能被继续使用,刚分配来的都为0代。每个代龄对象的总空间都有一个阈值,CLR会根据GC的执行情况动态调整这个阈值。当0代对象占用的空间超过阈值的时候,GC会启动来回收内存,先回收0代的空间,然后提升0代为1代,如果1代也满了则回收1(否则就结束了),依此类推(我也有一个疑问,2代区域满了该怎么办,是及时扩大还是先放在虚拟内存中等会扩大)。当然当然,这是一个简单的描述,算法中会考虑很多因素,可能会采取一系列的优化措施,这些细节对于大多数人来说是不需要了解的(我想了解也了解不了,呵呵)。从这个算法中我们也可看出,GC机制特别适合于大量临时对象被创建,又全部被销毁的场合,所以GC在Asp.net中的性能表现特别的突出。
上面描述的是一个宏观的过程,既考虑整体上托管堆是如何被分配和回收的。让我们再考虑具体一个对象的分配和回收。假设这个对象叫faint(^_^)。首先,faint在托管堆被分配了,这时候它为0代。很不幸,在faint还没有提升到1代的时候,它就被抛弃了,成了一个无根的对象。这时候GC老大起来收内存了,faint当然没有逃脱老大锐利的眼神。关键时刻来临了,老大会问它一个生死攸关的问题(其实不是即时判断的,而是事先用一个结构保存好了的),你的Finalize方法是不是原来的Object.Finalize()(不好意思说的那么恶心,Finalize是对象回收时被调用的一个方法,在Object类中有默认的实现,如果faint的祖先中有一个重写了Finalize方法,这个方法就不算是原有的Object.Finalize方法了),如果是,那么当场被干掉,不复存在;如果不是,faint会依然被放在堆中,在下一次GC启动的时候再被干掉(早死和晚死的问题)。故事写的很不好,需要强调的是,如果一个类在继承的结构中被重载过Finalize方法,它不会在两次GC启动后被回收。了解这个情况,在很多时候会为你顿悟埋下伏笔。
还有一个问题,就是CLR何时会启动GC?如何保证GC运行过程中,内存分配的状况不会被改变?CLR采取的策略是在合适的时机(叫安全点)劫持当前线程,启动垃圾回收线程,此时所有的其他线程被挂起,等待回收的完成。毫无疑问,这时一个性能损失大户,可喜的消息时微软会不断努力减少这种开销,至少对一般的使用不会感到这种开销的存在。另外,不只是GC会负责回收内存(也就是调用Finalize函数),当CLR卸载AppDomain或CLR关闭等时候,CLR也会遍历所有对象的Finalize函数,以便回收所有内存空间。
看了半天我们发现,这都是系统做的事情,自动分配内存,自动判断无根对象,自动启动GC,自动调用回收算法。我们有办法改变吗?答案当然是有的。你可以改变代龄阈值(没玩过,好像可以吧),最普遍的是调用GC.Collect()要求启动GC回收内存(有时候需要调用两次GC.Collect(),想一想是为什么,答案上面有哦^_^),当然很多时候是不建议这样做的,因为会带来性能损失。除非是你确实有大内存需要回收,并且这时候正好在执行一项很耗时的工作,GC的耗时可以被很好的掩盖(考虑一下,把一个长相很谦虚的mm,扔到XX影视学院和XX理工学校都会产生什么效果吧,=。=!!,呵呵,一个玩笑)。
垃圾回收不只是上面这些简单的过程。还有很多像弱类型,并发式垃圾回收之类的内容。如果有兴趣,可以好好阅读圣经《.Net框架程序设计》中对相关内容的描述,可能需要一点耐心,但绝对受益匪浅。
了解这些过程对于一般开发人员有什么用呢?至少可以谋得心里安慰,好歹知道自己new出来的东西是怎么牺牲的^_^。呵呵,当然还有其他更多的好处。比如,我现在就很明确写类似于faint=null,这样代码的意义了。这是在帮助系统明确我的对象已经无根可以回收了(不用等待跳出有效域了)。还有我也不会乱写GC.Collect()来戏弄CLR了。另外,在后面会说其他非托管资源的管理工作,也许理解这些会有所帮助的。
PS:写那么多体力告罄了,只能分个上下,关于非托管资源的回收明天再写好了。。。
重新学.Net[七]——垃圾回收和资源管理[下]
在前面说了GC的工作原理。需要注意的是,GC只能回收托管堆中的资源。其他一些非托管资源,比如文件资源,缓冲区,互斥体之类,无法通过GC自动回收。必须通过开发人员自己编程实现对其的回收(有时候会觉得CLR的资源管理也会比较麻烦,因为它有一部分自动的,有用一部分手动的,但和C++比比,我们应该很知足了^_^)。
很自然的一种编码方式是将回收资源的函数写入终结函数Finalize中,GC启动回收托管资源的时候顺便把非托管资源一并回收了(有时候我会简单得说GC回收了,指的就是在GC启动时正好执行该代码一并回收去了)。但是,这会带来很多的问题。最大的问题是时机问题。开发人员不能决定GC何时启动(包括调用GC.Collect()也不行),这使得无法确定非托管资源何时会被释放,也无法决定不同对象占用的资源的释放顺序。
因此,我们需要按照一种被称为Dispose的模式来进行非托管资源的管理,先看一段比较经典的Dispose模式编码:
public class UnsafeSourceHolder : IDisposable
{
private IntPtr buffer;
private SafeHandler resource;
private bool disposed;
public UnsafeSourceHolder()
{
buffer = ...;
resource = ...;
disposed = false;
}
protected virtual void Dispose(bool disposing)
{
if (disposed) return;
ReleaseBuffer(buffer);
if (disposing)
{
if (resource != null) resource.Dispose();
}
disposed = true;
}
~UnsafeSourceHolder()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public void Close()
{
Dispose();
}
public void DoSomething()
{
if (disposed) throw new ObjectDisposedException("不能使用被释放的资源");
}
}
从上面的代码中,我们来看看Dispose模式的关键之处。首先,如果一个类(上例中的UnsafeSourceHolder )中包含非托管资源(在上例中,buffer和resource均代表非托管资源),它需要实现 IDisposable接口,这个接口中只有一个public void Dispose()方法。当然,如果你不实现该接口(比如,把Dispose()中的内容放到Close()中去),在理论上是没有任何问题的。但,这样做至少有两个不好的地方:
1.你失去了和调用人说话的一种语言(我们默认调用人看到一个类实现了IDisposable接口就认为该类中含有非托管资源),调用人可能不知道这个类中包含非托管资源;
2.你失去了某些方便的语法支持(比如C#中的using会自动帮助你调用该接口的Dispose方法)。
因此这样做是必须的。而为什么还要有一个Close()方法呢,原因也有两个:
1.调用人会比较习惯Close()这样的命名;
2.CLR将Dispose()方法作为一个显性接口方法,你必须这样调用:(IDisposable i).Dispose(),比较麻烦。
恩,所以综合考虑,当一个类中有托管资源的时候,你需要实现IDisposable接口,并暴露上面两个API供调用者使用。
说到了调用,我们再来看看,一个调用该类对象的人,有几种触发资源回收的方式。一种就是显性的调用Dispose或Close方法(或利用语法偷偷调用),通知回收资源。另外一种,是让GC启动自动回收资源。什么?我说过GC无法回收非托管资源,恩,是的,但是我已经重载了(相当于)Finalize()方法。什么?你没看到,那么请注意~UnsafeSourceHolder()这个函数。这可不是C++的析构函数,在上例中写了此函数,相当于写如下代码:
protected override void Finalize()
{
try
{
Dispose();
}
finally
{
base.Finalize();
}
}
之所以按貌似析构函数的方法来写,是因为很多开发人员不会按规则写入上的代码,以至于终结函数抛出异常,导致进程Down掉,或资源泄露。因此,请你按这种让许多C++程序员会感到异常不爽的方式来写(我个人觉得这种设计有点画蛇添足了,一个不会写try-finally这样代码的开发人员,你如何确定它会写析构函数那样的代码??)。
但是但是,这样我们仔细看看代码我们可以发现。这两种调用方式是有所区别的,真实对非托管资源进行处理的是Dispose(bool disposing)函数,Dispose()方法调用Dispose(true),而GC调用的是Dispose(false)。这有什么区别呢?讲清楚这个问题,我们需要将非托管资源分成两类(这些是我自己的说法,可能会有bug哦^_^),一类是只被一个对象享用的非托管资源,在上例中以buffer表示。另一类是被许多对象享用的非托管资源,在上例中以resouce表示。自己独享的资源当此对象销亡时必须释放,所以在GC调用时会考虑释放该资源。但另一类被共享的资源则不能由GC来释放,因为GC释放次序是不定的,无法确认释放这个资源后是否有其他对象在使用该资源。而了解这个的,只有调用者自己。因此只能依赖于调用者自己手动调用Dispose()或Close()来释放该资源。也就是说,手动的调用,会释放该对象使用的所有非托管资源,而自动调用只能释放该对象独享的非托管资源。
但,如果调用者出现错误,在释放了非托管资源后,又再次使用它,该怎么办?这时候,要求对象能够告诉调用者:Are you craze?。在上例中,用disposed变量来记录资源的使用状态,在非法使用时抛出ObjectDisposedException异常(这也是Dispose模式推荐的一部分内容)。
在上面代码中,还有一句GC.SuppressFinalize(this);这时告诉GC不要自动回收该对象。这里的原因我也不是很清楚,有说是已显性回收该对象没必要再自动回收了。我个人觉得不对,因为显性回收的只是非托管资源而已,托管资源并未回收。查了下MSDN和一些书,觉得更好的解释应该是如果Dispose回收时正好执行垃圾回收会产生冲突(因为垃圾回收启动时导致的线程挂起,并不会影响非托管资源的回收,这样会从两个地方回收该资源产生冲突),所以要调用GC.SuppressFinalize使得垃圾回收机制会在一段时间内不再尝试回收该对象。请注意,这是一段时间内而不是永远。
上面就是Dispose模式的一个基本结构,简单总结一下有如下一些要点:
1.实现IDisposable接口。
2.提供Close()函数,内部调用Dispose()函数。
3.实现一个Dispose(bool disposing)函数,处理不同的非托管资源。
4.实现析构函数(加个伪字好了^_^),使其能在GC启动时自动回收。
5.当调用已释放资源时抛出ObjectDisposedException异常(同时要允许多次清除同一资源)。
6.使用GC.SuppressFinalize函数,防止清理冲突。
当然还可以根据情况进一步的改良它的实现(比如在资源回收时加锁),但它的整体思路和基本接口不应该改变。当你实现的类中有设计非托管资源的管理时,请按照该模式来实现。但,当你类中没有非托管资源的时候,请不要没事找事,想一想上中那个需要顿悟的地方哦^_^。