垃圾回收机制及析构器原理解析
前言
当学习到Web API中摸索原理时,对于其中有关垃圾回收只是有点印象并未深入去了解其原理并且对索引器用的也很少,所以利用放假期间好好回顾下已经忘记或者遗漏的知识,温故而知新大概就是这道理吧,虽然园子中关于这两者的文章也是多不胜数,但笔者也有自己独特的见解。
垃圾回收机制
我们知道.NET Framework中的对象是创建在托管堆中的,但是像C、C++等其他底层语言中的对象是创建在非托管堆中的,所以在这类语言中就会出现编程人员忘记去释放已经没有用的对象,同时编程人员也可能会去试图访问已经释放的对象,最终基于此导致的结果就是出现难以预料的Bug,但是在.Net中这种情况就得到极大的改善,我们不用去担心回收对象的工作,.NET Framework会自动进行处理。像C++和C#中垃圾回收的处理可以用一个例子来进行形象的比喻,那就是【吃饭】问题:在高中和大学吃饭最大区别在于,高中你吃完饭你得自己去洗碗,没人会为你代劳,在大学就不同,当你吃完饭,抹抹嘴上的油,拍拍屁股就可以走人,洗碗的事情你不用去担心,这些工作有那些阿姨替你完成了,当然这也就养成了人们懒惰的习惯。上述中的高中就好比C++语言,而大学就好比于C#语言,这样对比就显得C#更加高级了。由于在.NET中垃圾回收的处理已经不用我们去担心,这样一来使得我们只需更加专注于业务的处理,而非这些细枝末节,但是了解其原理对于写出高性能的代码那将是大有裨益的。
下面就垃圾回收我们举一个简单例子:
我们建立一个控制台应用程序,添加入下方法:
static int Method(int j) { return j * 10; }
接着在主程序中进行调用:
static void Main(string[] args) { string empty = string.Empty; string hello = "Hello World"; for (int i = 0; i < 10; i++) { empty += i.ToString() + " "; } empty += Method(10).ToString() + " "; Console.ReadKey(); }
我们根据主程序调用来进行描述,我们知道运行程序时,因为empty和hellp是引用对象,所以会在托管堆中分配内存,创建这两个对象并赋值等,接下来就是for循环,因为i是值类型,所以首先会在堆栈中创建一个i的变量,直到跳出for循环的的作用域,此时变量i就将进行释放,紧接着进入调用Method方法并传递一个10,此时会首先在堆栈中创建一个j的变量,并对其进行操作,直到调用完这个方法,此时变量j就会被马上释放,当最后执行完主程序的作用域的最后一个大括号此时引用对象empty和hello将变成垃圾对象。
对于值类型即在堆栈上的垃圾只有脱离了其作用域就会弹出堆栈自动释放,而引用类型即在托管堆中的对象还会存在于托管堆中,它在等待垃圾回收器在某个特定的时刻来对它进行回收。下面我们就来讲讲垃圾回收机制
原理讲解
如图所示,垃圾回收机制示意图:
当托管堆进行初始化时,托管堆中并不包括任何对象,当添加对象到托管堆中时,此对象称为第0代对象,我们如上述图片所给,在托管堆中第0代中创建A-G七个对象,创建的这五个对象是依次创建的,也就意味着这七个对象在内存中连续的,同时.NET Framework在初始化第0代对象时,会给定其合适的大小,一般情况下它的空间是256k,将其空间定为256k的原因是可以将其完全载入到CPU的二级缓存中,使得内存压缩速度非常的快。
当程序运行一段时间后,假设上述中E和G已经变成了垃圾对象,我们用如下图片来表示:
即使现在E和G变成了垃圾对象但是此时并未被回收掉,但是接下来我们又创建了对象H,此时第0代对象已经被装满,此时才开始进行垃圾回收,因为此时回收垃圾对象E和G后,在D和F之间将会存在间隙,所以此时会进行内存的压缩,来保证对象的连续性。经过第一轮垃圾对象回收后,此时第0代对象将变成第1代,然后将创建的对象H添加到第0代中,如下所示:
接着我们又在第0代中创建了H-N对象,如下图所示:
由上可知,此时第0代已经被占满,随着程序的运行,此时假设B、H、以及J相继变成了垃圾对象,此时我们又创建了一个O对象,此时就要进行第二轮垃圾回收,此时垃圾回收器必须决定先要检查是第一代对象还是第0代对象,我们之前有讲过,第0代对象的容量为256k,而第一代对象的容量为2兆,此时垃圾回收器会检查第一代的对象,在上述中,第一代对象小于2兆,所以就仅仅检查第0代对象,而忽略对第一代对象的检查大大提高垃圾回收器的性能。如下所示:
如上所示解析来将继续在第0代对象创建O-R四个对象,随着程序的运行,上面的F、O以及P变成了垃圾对象,如下图所示:
此时我们在托管堆上又创建了对象S,假设此时第0代对象有已经被占满即超过其预算容量空间,此时将启动第三次的垃圾回收,而此时第一代的依然小于2兆此时仍然只需回收第0代的垃圾对象,并进行内存压缩,如下图所示:
此时也在第0代中创建了对象S-V,随着程序的运行第一代的垃圾对象也开始增多并且第一代的垃圾对象也有,假设为L、M、N以及S和U,如下图所示:
此时我们创建一个W对象,此时第0代对象的容量已经被占满,所以无法容纳W对象,此时将进行第四次的垃圾回收,假设在进行第三次垃圾回收时,第一代的空间已经超过了2兆,又由于垃圾回收刚刚完成,此时并不会马上对第一代的空间进行垃圾回收,当进行第四次垃圾回收时,此时发现第一代的空间已经超过了2兆,此时会对第一代和第0代的垃圾对象进行回收。所以此时首先将对第一代进行垃圾回收,此时要注意的是,对第一代的垃圾进行回收后,第一代的对象将提升为第二代,同理第0代的对象将会提升为第一代,第0代空缺。如下图所示:
随着程序继续运行此时假设D、Q和R变为垃圾对象,如下图所示:
由于第二代对象的容量空间为10兆,也就是只要第二代对象未满10兆都不会进行垃圾回收,很显然,第二代进行垃圾回收的频率在这三代中是最小的,而第0代进行垃圾回收的频率是最高的,同时我们也能知道:预算容量空间越高,其进行垃圾回收的频率越低。.NET Framework的垃圾回收机制是自行调节的机制,但是我们了解其原理对于我们写出性能高的代码是密不可分的。
析构函数
析构函数与构造函数区别:一个类只能有一个析构器、析构器不能被继承或重载、析构器不能被调用,它是自行调用、析构器不能带修饰符或参数
原理解析
我们定义一个类以及定义其析构函数,如下:
public class Person { ~Person() { } }
我们通过反编译工具来查看此析构器最终编译成什么:
最终编译成了Finalize方法实现析构器,如下:
public class Person { // Methods public Person(); protected override void Finalize(); } Expand Methods
我们再看看其IL代码,如下:
.method family hidebysig virtual instance void Finalize() cil managed { .maxstack 1 L_0000: nop L_0001: nop L_0002: leave.s L_000c L_0004: ldarg.0 L_0005: call instance void [mscorlib]System.Object::Finalize() L_000a: nop L_000b: endfinally L_000c: nop L_000d: ret .try L_0000 to L_0004 finally handler L_0004 to L_000c }
由上可知,我们写的析构器是放在try{}finally{}结构里面,在try{}里面出来析构器,在finally{}里面调用基类的Finalize()方法。
下面我们在上述基础上进行改造,如下:
public class Person { public Person() { Console.WriteLine("Person类被创建"); } ~Person() { Console.WriteLine("Person类被释放"); } }
我们在控制台主程序中进行调用,如下:
new Person();
很显然此时实例化构造函数,但是未进行指向对象,所以一旦被创建就会被释放,我们来看看其结果:
从其对象被创建和被释放分别执行了构造函数和析构函数,这样想来析构函数也并无可研究之处,既然拿出来讲,必要其可讲之处,我们刚才查看了析构器本质是调用了Finalize方法,很显然,当该对象变为垃圾对象时此时会执行析构函数也就是会自动它的Finalize方法。但是Finalize方法工作的原理是怎样的呢?这个是我们需要研究的问题。下面我们来看看Finalize的原理。
Finalize工作原理
在垃圾收集器的内部此方法有两个数据结构,一个是 Finalization List 终结链表,另一个是 Freachable Quene 终结可达队列,下面我们继续用示意图来讲解
我们假设此时在托管堆中的G、K、L、M以及P实现了析构器,也就意味着他们最终会调用Finalize()方法,在这五个对象被创建时CLR能够检测到定义了Finalize()方法,所以将会把托管堆中对象的指针添加到终结链表中并指向这几个实现了析构器的托管堆对象,这也就意味着在对这几个对象进行垃圾回收时首先得调用Finalize方法。随着程序的执行,K、L以及M对象变成可回收的垃圾对象,同时B和D对象也变为垃圾对象,假设此时托管堆中第0代容量空间已满,此时将对B和D进行垃圾回收,如下图所示:
有人就说了,K、L和M也是垃圾对象了呀,为什么不能进行垃圾回收呢?很显然,此时在终结链表中存在这对其对象的引用,所以无法进行回收,此时该终结链表可回收的对象将会到第二个数据结构中即终结可达队列中并依然指向执行托管堆中的对象,当然了终结链表也会进行内存压缩。如下图所示:
在CLR中有个特殊的高优先级的线程,它专门用来调用Finalize()方法,当Freachable Queue为空时,该线程处于睡眠状态,当其有对象时该线程将被唤醒,当被唤醒后会执行这个队列中每个对象所对应的Finalize()方法,并最终将列表进行清空,如下所示:
此时K、L和M才是真正意义上变成了垃圾回收对象,等待下一次进行垃圾回收时将可能释放这三个对象,从上面我们叙述过垃圾回收机制,垃圾回收机制分为三个代,当第0代对象被回收后将自动变为第一代对象,但是第一次进行内存的释放可能要进行许多次垃圾回收才能被真正的释放。
从以上叙述我们可以总结出以下几点结论:
-
托管堆中内存的释放和析构函数的执行分别属于两个不同的线程。
-
带有析构函数的对象其生命周期会变长,由上知会进行两次垃圾回收处理才能被释放,如此一来将导致程序性能的下降。
-
若一个对象引用了其他对象时,当此对象不能够被释放时,则其引用对象也就无法进行内存的释放,也就意味着带有析构函数的对象和其引用对象将从第0代提升到第一代,毫无疑问将影响程序的性能。
综上所述,建议是不要实现其析构函数,这将大大降低程序的性能。
那么问题来了,哪些情况下将导致Finalize()方法的调用呢?
-
第0代对象被回收时
-
显式调用GC.Collect()方法
-
Windows报告内存不足
-
CLR卸载应用程序域
-
CLR程序关闭
当我们利用上述Person类进行两次调用构造函数时看看结果如何:
new Person(); new Person();
此时结果如下:
当我们调用两次构造函数时此时就变成了垃圾对象,但是第一次调用完构造函数后并未进行内存的释放,而是等待应用程序域关闭时执行Finalize()方法一起被释放,若我们想创建就被释放,我们只需手动调用 GC.Collect() 方法即可。
通过以上得知,我们最好不要实现对象的终结器,但是在实际开发中比如文件、网络连接、套接字等非托管资源,CLR是无法实现的,这就要我们想手动写代码来实现了,同时将其代码放在终结器中来保证资源能够被释放。
那么问题就出来了,我们使用终结器会大大降低程序的性能但是要释放那些非托管资源必须在终结器中实现,那该如何是好呢?
为了释放资源必须要实现IDisposable接口,如下:
public interface IDisposable { void Dispose() }
那么问题来了,对于非托管资源的释放,到底是如何利用Finalize()方法和Dispose()方法来进行的呢?
接下来就要讲到这二者实现非托管资源的释放模式了,如下图所示:
由上知,当释放非托管资源时我们应该显式的去实现Dipose()方法或者Close()方法,但是万一我们忘记显式去调用方法,此时还有一条退路,CLR会自动调用Finalize()方法,很显然调用Finalize()方法会大大降低程序的性能,没关系,上述释放模式关键的一点是通过手动释放调用Dispose()方法可以阻止Finalize()方法的调用,换言之,上述通过手动释放既释放了非托管资源又加快了程序运行的速度,毫无疑问,这是一种完美的解决方案。
接下来我们通过如下图来看看释放模式的具体实现:
当一个类需要使用到非托管资源时,此时我们实现以上三个方法即Dipose()、Close()以及析构函数中的finalize()方法,此时接下来以上三个方法会调用Dispose()方法的重载方法并带有一个布尔值的disposing参数,Close()和Dispose()方法调用该方法中参数为true时,而析构函数中finalize()方法则是该方法中的参数为false,所以依据方法的参数不同来调用不同的方法, 但是我们需要注意的是当释放托管资源时并没有释放托管堆中的内存,要知道托管堆中内存的处理永远都交由垃圾回收器来进行回收,所以上述所说的释放托管资源具体指的是当我们定义的类引用了其他包含了非托管资源的类,我们则调用其他非托管资源的Dispose()方法,这样就保证了不管是类本身的非托管资源还是类引用其他类的非托管资源都通过手动调用得到了释放,但是当调用disposing为false时即调用finalize()方法时则不会去释放类引用的其他类的非托管资源,其原因主要是各个类调用finalize()方法的不确定性。当采用了手动释放模式后接下来将会阻止finalize()方法的调用,最终达到提高程序性能的目的,
微软对于此释放模式给出了一个范例,下面是此例子:
class BaseClass : IDisposable { // Flag: Has Dispose already been called? bool disposed = false; // Public implementation of Dispose pattern callable by consumers. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } // Protected implementation of Dispose pattern. protected virtual void Dispose(bool disposing) { if (disposed) return; if (disposing) { // Free any other managed objects here. // } // Free any unmanaged objects here. // disposed = true; } ~BaseClass() { Dispose(false); } }
一下子写出来似乎难以理解,下面我们来自己写手动释放模式并对照上述所给释放模式的具体实现,以此来更加深刻的去理解其原理:
public class MyDispose : IDisposable { public void Dispose() { Dispose(true); } public void Close() { Dispose(true); } ~MyDispose() { Dispose(false); } private void Dispose(bool disposing) { } }
我们新建一个MyDipose类,并实现IDisposable接口,同时添加Close()和析构函数,通过上述我们知道,Close()和Dispose()方法是通过Dispose()方法的重载并参数为true,而析构函数调用Dispose()方法参数为false来分别调用各自的方法来实现资源的释放,对照上述图片,肯定不难理解。
接下来继续,为了防止非托管资源被多次释放我们用一个私有属性dispoed来标志资源是否已经被释放,然后在重载的Dispose方法中来判断disposing的真假,如果为真,则释放引用对象的非托管资源,也就是调用引用对象的Dispose()方法,如果为false就释放类本身的非托管资源,依据叙述,代码修改如下:
public class MyDispose : IDisposable { private bool disposed = false; public void Dispose() { Dispose(true); } public void Close() { Dispose(true); } ~MyDispose() { Dispose(false); } private void Dispose(bool disposing) { if (!this.disposed) { if (disposing) { Console.WriteLine("调用引用对象的Dispose()方法"); } Console.WriteLine("释放类本身的非托管资源"); disposed = true; } } }
在Dispose()方法的重载方法中,如果disposing参数为true则调用引用对象的Dispose()方法即既会释放其引用对象的非托管资源又会释放类本身的非托管资源,但是需要注意的是在释放其引用对象的非托管资源时只需调用其Dispose()方法即可,不要做任何其他事情,如果disposing参数为false则会调用析构函数的终结器而不会去释放引用对象的非托管资源。最后将dispoed设为true来标志资源已经被释放,从而达到多次调用Close()和Dispose()方法而不会出现差错。
此时是不是就完了呢?还没完,我们是不是忘记了什么,对,当我们手动调用Dispose()和Close()方法时,则应该阻止finalize()方法的调用即当disposing参数为true时,阻止finalize()方法的调用时通过如下方法:
GC.SuppressFinalize(this);
通过其方法名也可得知,SuppressFinalize(禁止Finalize),此时this就是指向当前对象。此时最终改造完的代码如下:
public class MyDispose : IDisposable { private bool disposed = false; public void Dispose() { Dispose(true); } public void Close() { Dispose(true); } ~MyDispose() { Dispose(false); } private void Dispose(bool disposing) { if (!this.disposed) { if (disposing) { Console.WriteLine("调用引用对象的Dispose()方法"); } Console.WriteLine("释放类本身的非托管资源"); disposed = true; if (disposing) { GC.SuppressFinalize(this); } } } }
接下来我们进行调用来验证我们的代码:首先进行如下调用:
var p = new MyDispose();
打印如下:
由于我们没有显式去调用Dispose()和Close()方法,所以最终将调用析构函数中终结器方法,证明以上打印正确,接下来我们显式来调用:
var p = new MyDispose(); p.Dispose();
打印如下:
这就不用解释了,我们接下来只需验证多次调用Dispose()和Close()方法即可,如下:
var p = new MyDispose(); p.Dispose(); p.Dispose(); p.Close(); p.Close(); p.Dispose();
结果打印如下,验证正确:
上述至于为什么有了Dispose()方法还要用Close()方法,笔者猜想是,在此之前Close()是首先出世,当有了Dispose()方法,编程人员估计已经习惯了使用Close()方法,所以保留了通过Close()方法来调用。
在上述对析构函数原理进行反编译时,其内部实现是将finalize()代码块放在try{}finally{}里面,所以当我们如果显式去调用Dispose()方法或者Close()方法时,我们将其方法调用放在try{}finally{}中,例如,如下:
var p = new MyDispose(); try { Console.WriteLine("做你应该做的事情!"); } finally { p.Dispose(); }
通过try{}finally{}机制,无论代码调用成功与否最终还是会调用Dispose()方法来释放本地资源,也就意味着,这种机制保证了本地资源能够充分的被释放。最后打印如下:
通过using简化
从ASP.NET拖控件开始就知道微软可谓是用心良苦,以此来提高效率,殊不知,用高效率换来的却是.NET编程人员对于拖控件的完全依赖而不去探究其内部原理,所以从此也可看出,为了提高编程人员的生成效率,减少劳动程度,将上面用try{}finally{}用using来进行简化:
using (var p = new MyDispose()) { Console.WriteLine("做你应该做的事情!"); }
依然能成功打印出上述结果,当然使用using的前提是必须要实现Disposable接口,所以由此也可看出: