.net最佳实践二:使用finalize/dispose模式提升垃圾回收器性能
本文值得阅读吗?
通过本文你会理解如何通过finalize dispose模式提升GC算法的性能。下图显示完成本文后的对比。
介绍和目标
问一下每一个开发人员,在.Net类中清除非托管资源的最好位置在哪里?他们中的70%的人员会说放在析构函数。尽管看起来好象是最有希望的位置,但那对性能和内存消耗有巨大的影响。在析构函数中写清理代码会导致垃圾回收器再次调用,而且多次(multifold times)影响性能。
为了验证上面所说,我们先从理论开始,然后我们会真实的看到使用析构函数时如何影响性能。因此我们要理解世代的概念,然后再去看finalize dispose模式。
我相信本文会改变你关于析构函数、dispose 和 finalize处理的看法。
请随时到 http://www.questpond.com下载我的涵盖.NET、 ASP.NET、 SQLServer、 WCF、 WPF、WWF的免费500个问题和回答的电子书。
假设
本文使用CLR探测器来探测GC如何工作。如果你对CLR探测器不熟悉,在继续之前请先阅读DOTNET1.aspx。
感谢Jeffrey Richter 和 Peter Sollich 先生
让我们以感谢Jeffery Richter作为本文的开始,因为他深入的解释了垃圾回收算法如何工作。他曾经写过两个关于垃圾回收工作方式的传奇文章。我很想指出Jeffery Richter在MSDN杂志写的文章,但因为一些原因并没有在MSDN显示出来。所以我给出一个非官方的地址,你可以从http://www.cs.inf.ethz.ch/ssw/files/GC_in_NET.pdf下载PDF格式文章。
同时也感谢Peter Sollich先生,他是CLR性能框架师,为CLR探测器写了详细的帮助。当你安装CLR探测器时,请不要忘记阅读Peter Sollich写的详细帮助文档。在本文中我们会使用CLR探测器验证使用finalize对垃圾回收器性能的影响。
非常感谢你们的支持,如果没有读你们写的文章,我就不能完成这篇文章,无论何时我都很乐意听到你们阅读文章的评论。
垃圾回收器-幕后英雄
如在介绍中所说,把清理代码放在析构函数会导致垃圾回收器的两次调用。许多开发人员会耸耸肩说“我们真的需要去关心垃圾回收器(GC)在后台做了什么吗?”,对,如果你写合适的代码,我们确实不需要关心垃圾回收。垃圾回收器有保证你的应用程序不受影响的最好的算法。但是很多时候,你写代码的方式和在你代码中分配/清理内存资源的方式对垃圾回收算法产生了较大的影响。有时这种影响会导致垃圾回收器(GC)很差的性能,进而导致你应用程序很差的的性能。
因此我们先来看一下在垃圾回收器分配和清理内存时都执行了哪些不同的任务。
假如我们有三个类,类A调用了类B,类B调用了类C。
当应用程序第一次执行时,预定义内存分配给应用程序。当应用程序创建这3个对象时,它们被赋于一个内存栈上的地址。你可以从下图中看到对象创建之前和对象创建之后的内存的样子。如果还有一个对象D要创建,它会从对象C结束处分配地址。
在内部,垃圾回收器为了知道哪些对象是可达的要维护一个对象图。所有的对象属于主应用程序的根对象,根对象同样维护着哪些对象分配了哪些内存地址。如果一个对象使用了其他的对象,那么这个对象也要保存它使用的对象的内存地址。例如,在我们的示例中的对象A使用了对象B,所以对象A保存了对象B的内存地址。
现在假如对象A从内存中移除,那么对象A的内存被赋于了对象B,对象B的内存被赋于了对象C。内部的内存分配情况如下所示:
随同内存指针的更新,垃圾回收器需要确保它的内部对象图也随着新的内存地址更新了。因此对象图变成了如下所示的样子。对垃圾回收器有一些工作要做,它需要确保已经不再使用的对象已经从图中移除,并且还存在的对象的地址已经在对象树中全部更新了。
除应用程序自定义对象外,构成对象图表的还有.Net对象,那些对象的地址也是要更新的。.Net运行时对象的数量非常大,下图就是一个简单的Hello World控制台应用程序创建的对象的数量,对象的数量约有1000个,更新每一个对象的指针是一个很大的任务。
世代算法—今天、昨天和前天
GC(垃圾回收器)使用世代的概念来提升性能。世代的概念是基于人们处理事情的心理的方式。下面的几点指出人们是如何处理事情的,并且垃圾回收算法是按相同的方式工作:
- 如果你今天决定要做一些事情,那么很可能今天就把这些事做完。
- 如果一些事是昨天未决定的,那么很可能这些事情会给予比较低的优先级并且被再一次推迟。
- 如果一些事是前天未决定的,那么就有很大的可能性这个事被永远推迟。
GC以同样的方式思考并且使用下面的假设:
- 如果一个对象是新创建的,那么它的生命期可能很短。
- 如果一个对象是原来存在的,它可能会有更长的生命。
所以说,GC做了三个世代的支持(0代,1代和2代)。
0代包括所有新创建的对象,当应用程序创建对象时,这些对象首先被放入0代对象列表中。当0代对象装满时,GC需要运行以释放内存资源,GC开始构建图表并删除所有应用程序不再使用的对象。如果一个对象GC不能在0代删除,那么该对象会被提升为1代。如果在后面的迭代中一个对象不能在1代中删除,那么它会被提升为2代。.Net运行时支持的最大代是2代。
下面是当你运行CLR探测器时关于世代对象的一个简单显示。如果你对CLR探测器不了解,请先从DOTNET1.aspx了解CLR的基本知识。
那么,在优化中世代有什么帮助呢
作为世代中的对象,GC会对哪个世代的对象需要被清理做出选择。如果你记得,前面小节中我们讲过关于GC认定对象世代的假设,GC假设新对象具有更短的生命周期。换句话说,GC主要检查0代的对象,而不是所有世代的所有对象。
如果清理0代对象不能提供足够的内存,它将继而清理1代的对象,并依次继续。这个算法能大幅提升GC的性能。
关于世代的推论
- 如果有大量的对象在1代或2代区域则说明内存使用没有优化。
- 更大的世代1和世代2区域会导致GC算法性能更差。
使用终结器(finalize)/析构函数会导致更多的1代和2代对象
C#编译器会把析构函数翻译(重命名)为终结器。如果你使用IDASM查看IL代码,就会看到析构函数被重命名为终结器(finalize)。所以让我们先理解为什么实现析构函数会导致更多的对象进入1代和2代区域。现在来看处理器是如何工作的:
- 当新对象创建时,它们被放到0代。
- 当0代区域填满时,GC运行并清理内存。
- 如果对象没有析构函数,那么如果它们不再被使用,GC就把它们清理掉。
- 如果对象有终结(finalize)方法,GC就把它们放到终结队列中。
- 如果对象是可达的,它会被放置到Freachable队列中,如果对象是不可达的,内存将被收回。
- GC完成本次迭代工作。
- 下一次当GC开始工作时,它会进入Freachable队列检查对象是否可达,如果Freachable中的对象不可达,内存就会被声名为可收回的。
换句话说,有析构函数的对象会在内存中存活更长的时间。
让我们来看下实际的情况,下面是一个简单的有析构函数的类。
{
public clsMyClass()
{
}
~clsMyClass()
{
}
}
让我们用CLR探测器来监视创建100*10000个对象时的情况。
{
clsMyClass obj = new clsMyClass();
}
如果使用CLR探测器的内存地址报表,会看到大量的对象在1代。
现在去掉析构函数后再做一遍。
{
public clsMyClass()
{
}
}
你可以看到在0代对象大量增加,同时1代和2代对象很少。
如果做一对一的对比,结果如下图所示:
使用Dispose代替去掉的析构函数
我们可以去掉析构函数而在dispose方法中实现清理代码。为此要实现‘IDisposable’ 的接口方法,在这写我们的清理代码,并如下代码段所示调用终结方法。
‘SuppressFinalize’指示GC不要调用finalize方法,所以不会发生GC的二次调用。
{
public clsMyClass()
{
}
~clsMyClass()
{
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}
现在客户端要确保它要象如下所示调用dispose方法。
{
clsMyClass obj = new clsMyClass();
obj.Dispose();
}
下图是使用析构函数和使用dispose时的0代和1代对象如何分布的对比。你会看到0代内存分配有明显的提升,这标识着更好的内存分配。
如果开发人员忘记调用Dispose
这不是一个完美的世界,我们不能确保在客户端总是调用了dispose方法。这就是下面的小节中我们要使用Finalize / Dispose模式的原因。
关于这个模式在http://msdn.microsoft.com/en-us/library/b1yfkh5e(VS.71).aspx.有详细的实现。
下面看起来更象是如何实现finalize / dispose模式。
代码解释:
- 我们定义了一个带布尔参数的Dispose方法,该参数说明是从Dispose中调用还是从析构函数中调用。如果是从’Dispose’方法调用,则释放所有的托管和非托管的资源。
- 如果该方法是从析构函数中调用,则只释放非托管的资源。
- 在dispose方法中我们禁用了finilize的调用,并且用true参数调用了这个dispose方法。
- 在析构函数中我们使用false值调用dispose函数。换句话说,我们假定GC会处理好托管的资源并用析构函数调用来清理非托管资源。
换句话说,客户端没有调用dispose函数,析构函数会照顾清除非托管资源。
结论
- 不要在类中写空的析构函数。
- 如果你需要清除,使用带‘SupressFinalize’方法调用的finalize dispose模式。
- 如果类有公开的dispose方法,确保在客户端代码中调用它。
- 应用程序应该分配在0代区域中的对象比分配在1代和2代区域中的对象更多。如果在1代和2代区域中有更多的对象,标志着很差的GC算法执行。
源代码
可以在这里找到dispose模式的示例代码
License
This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)
About the Author
| He thinks he was born for only one mission and thats technology.Keeping this mission in mind he established www.questpond.com where he has uploaded 500 videos on WCF,WPF,WWF,Silverlight,Design pattern, FPA , UML , Projects etc. He is also actively involved in RFC which is a financial open source made in C#. It has modules like accounting , invoicing , purchase , stocks etc.
|
如果有什么地方译的不好,不合适,欢迎批评指正,谢谢。