物以类聚:对象也有生命
相关文章链接
物以类聚:对象也有生命
对象的"生"到"死"一直是.NET编程中的难点,如果没有正确地理解对象的生命周期,程序很容易就会出现"内存泄露"(Memory Leak)等异常,最终系统崩溃。很多人错误地认为调用了对象的Dispose方法之后,对象就"死亡"了,这些错误的观点往往会给系统带来致命的BUG。本章将从对象生命周期、对象使用资源管理等方面来说明编程过程中我们应该注意哪些陷阱,从而最大程度减少代码异常发生的几率。
4.1 堆和栈
堆(Heap)和栈(Stack)是.NET中存放数据的两个主要地点,它们并不是首先在.NET中出现,C++中就有堆和栈的概念,只是C++中的堆需要人工维护,而.NET中的堆则由CLR管理。在.NET中,我们也称堆为"托管堆"(Managed Heap),这里的"托管"与本书前面说到的托管代码中的"托管"意思一样,都指需要别人来辅助。
栈主要用来记录程序的运行过程,它有严格的存储和访问顺序,而堆主要存储程序运行期间产生的一些数据,几乎没有顺序的概念。堆跟栈的区别见下图4-1:
图4-1 堆与栈存放数据的区别
如上图4-1,堆中的数据可以随机存取,而栈中的数据必须要按照某一顺序存取。
栈主要存放程序运行期间的一些临时变量,包括值类型对象以及引用类型对象的引用部分,而堆中主要存放引用类型对象的实例部分。注意这里的引用类型对象应该包含两个部分:引用和实例,程序通过前者去访问后者。
图4-2 引用类型对象
我们说的一个引用类型对象其实包含两个部分,图4-2中的"引用"类似C++中的指针,存放在栈中,而图中的"实例"则存放在堆中。C#语言中的值类型有类似bool、byte、int等等,而引用类型包括一些class、interface以及delegate等等。
1 //Code 4-1 2 class A 3 { 4 //… 5 } 6 class Program 7 { 8 static void Main() 9 { 10 int local1 = 0; 11 A local2 = new A(); 12 bool local3 = true; 13 } 14 }
上面代码Code 4-1中int和bool为值类型,所以local1和local3被分配在栈中,而A为引用类型(class),所以A类对象引用local2分配在栈中,A类对象实例分配在堆中,如下图4-3:
图4-3 栈和堆中的数据存储
如上图4-3,程序中如果需要访问A类对象时,必须通过它的引用local2去访问。
值类型对象与引用类型对象的引用如果属于另一个引用类型的成员,那么它们也是可以分配在堆中,
1 //Code 4-2 2 class A 3 { 4 //… 5 } 6 class B 7 { 8 //… 9 A a; 10 int aa; 11 public B() 12 { 13 a = new A(); 14 aa = 1; 15 } 16 //… 17 } 18 class Program 19 { 20 static void Main() 21 { 22 int local1 = 0; 23 B local2 = new B(); 24 bool local3 = true; 25 } 26 }
上面代码Code 4-2中,local1和local3依旧分配在栈中,由于a和aa属于引用类型B的成员,所以它们会随B对象实例一起,分配在堆中,下图4-4为此时栈和堆中的数据分配情况:
图4-4 栈和堆中的数据分配情况
如图4-4中所示,虽然aa是值类型,a是对象引用,但是它们都是分配在堆中。另外A类对象实例也是分配在堆中。
栈中的数据会随着程序的不停执行自动存入和移除,堆中的数据则由CLR管理,它们两者不是同步的,也就是说,可能栈中的一个对象引用已经从栈中移除,但是它指向的对象实例却还在堆中。
注:
[1]关于值类型与引用类型,请参见本书第三章介绍的"数据类型"。
[2]引用类型对象包括"对象引用"和"对象实例"两部分,这种叫法可能跟其它地方不一样,本书中统一称栈中的为"对象引用",称堆中的为"对象实例"。另外,堆跟栈的本质都是一段内存块,本节以上几幅配图中,堆的"杂乱无章"只是为了说明堆中数据存储和访问可以是随机的,突出与栈的区别。
4.2 堆中对象的出生与死亡
栈中的对象由系统负责自动存入和移除,正常情况下,跟我们程序开发的关联并不大。本节主要讨论堆中对象的生命期。
4.2.1 引用和实例
上一节中结尾说到过,对于引用类型对象而言,栈中的引用和堆中的实例并不是同步的。栈中引用已经被移除,堆中的实例可能还存在,我们该怎么判断一个对象的生命周期呢?是依据栈里面的引用还是堆中的实例?
答案当然是堆里面的数据,因为CLR管理堆中对象内存时,是根据该对象的当前引用数目(包括栈和堆中),如果引用数目为零,那么CLR就会在某一个时间将该对象在堆中的内存回收。换句话说,堆中的对象实例存在的时间永远要比栈中的对象引用存在的时间长。下图4-5描述了对象实例与对象引用存在时间:
图4-5 对象引用与对象实例的生存时间对比
图4-5中对象实例生存时间要比对象引用生存时间长,由于在代码中我们只能通过引用去访问实例,所以当引用从栈中移除后(图中"可达"变"不可达"处),对象虽然没有死亡,但是也基本没什么用处。
如果再也没有任何一个引用(包括栈和堆中)指向堆中的某个实例,那么CLR(具体应该是CLR中的GC,下同)就会在某一个时间点对该实例进行内存回收,CLR的此举动便是第二章中提到的"管理对象托管资源"(堆中内存属于托管资源,由CLR管理)。需要注意的是,CLR回收内存的时间点并不确定,并不是实例的引用数目一为零,CLR马上就对该实例进行内存回收,因此图4-5中堆中对象"不可达"状态持续的时间不确定,有可能很短,也有可能很长。CLR将对象实例的内存回收后,堆中可能会出现不连续块,这时CLR还有类似硬盘的"碎片整理"功能,它能合并堆中不连续部分。
图4-6 堆中对象的生到死
图4-6中对象引用R3存放在栈中,指向堆中的D对象实例;当栈中R3移除后(注意此时R4必须移除),堆中的D就处于"不可达"状态,D也成为了CLR的回收目标;当D被CLR回收后,D的位置就空出来,堆中出现不连续块;CLR随后进行堆空间整理,合并不连续内存块,这便是对象一次从生到死的全过程。注意上图只是为了说明堆中D的生命周期,所以堆中其它对象实例的引用情况没有完整绘制出来。另外,如果堆中还有其它的引用指向D,就算R3从栈中移除,D还是不能成为CLR的回收目标,如下图4-7:
图4-7 栈中的引用与堆中的引用同时存在
图4-7中R3和B中的某一个成员同时指向D,就算R3从栈中移除,D还是处于"可达"状态,不会成为CLR的回收目标。
很明显,我们程序中如果要操作堆中的某个对象实例,只能在该对象实例处于"可达"状态时,只有这个时候对象引用还在,其它任何时候都不行。换句话说,我们能够控制堆中对象实例的时间是有限制的。
4.2.2 析构方法
CLR还有另外一个功能,它在准备回收一个"不可达"对象实例在堆中的内存之前,会调用这个对象实例的析构方法,调用时间点如下图4-8:
图4-8 CLR调用析构方法时间点
图4-8中CLR调用不可达对象的析构方法是在它回收内存之前,换句话说,对象死亡前一刻,CLR还会调用它的析构方法,如此一来,我们真正能够操作堆中对象实例的机会又多了一处,之前说到的当对象实例处于"可达"状态时,我们可以通过对象引用访问它,现在,当对象处于"不可达"状态,我们还可以在析构方法中编写代码操作它。由于CLR回收内存的时间点不确定,所以它调用析构方法的时间点也不确定。"不可达"对象的析构方法迟早要被调用,但是调用时间我们不可控。
图4-9 CLR调用析构方法时间不确定
图4-9中显示CLR调用析构方法的时间点不确定。
虽然CLR调用析构方法的时间不确定,但是还是为我们提供了一个额外操作堆中对象实例的机会。下图4-10中网格部分表示我们可以在代码中操作堆中对象实例的时间段:
图4-10 可操作堆中对象的时间段
由于CLR是根据"可达"还是"不可达"来判断是否应该回收对象实例的内存,因此,如果我们在析构方法中又将某个引用指向了对象实例,那么等析构方法执行完毕后,这个对象实例又从"不可达"状态变成了"可达"状态,CLR会改变原来的计划,不再把该对象实例当成回收的目标,这个就是所谓的"重生"。"重生"是指堆中对象实例在将要被CLR回收前一刻,状态由"不可达"变成了"可达"。下图4-11显示了一个堆中对象实例的重生过程:
图4-11 对象重生
理论上,一个对象可以无限次重生,但是为了避免编程的复杂性,"对象重生"在编程过程中是不提倡的,也就是说,我们应该谨慎编写析构方法中的代码,不提倡在析构方法中再次引用堆中对象实例。
注:后面我们可以知道,正常情况下,析构方法除了用来释放自己使用过的非托管资源之外,其余什么都不应该负责。
4.2.3 正确使用对象
实际编程过程中,引用类型对象使用频率居高,导致堆中对象实例数量巨大,如果不正确地使用对象,很容易造成堆中对象实例占用内存不能及时被CLR回收,引起内存不足。编程中我们应该遵循以下两条规则:
(1)能使用局部变量尽量使用局部变量,也就是将引用类型对象定义成方法执行过程中的临时变量,不要把它定义成一个类型的成员变量(全局变量);
局部变量存储在栈中,方法执行完毕后,会直接从栈中移除。如果把一个引用类型对象定义成局部变量,方法执行完毕后,对象引用从栈中移除,堆中的对象实例很快就会由"可达"状态变为"不可达"状态,从而成为CLR的回收目标。
1 //Code 4-3 2 class A 3 { 4 //… 5 } 6 class B 7 { 8 A _a; 9 public B() 10 { 11 _a = new A(); 12 } 13 //… 14 } 15 class C 16 { 17 B _b; 18 A _a; 19 public C() 20 { 21 _a = new A(); 22 } 23 public void DoSomething() 24 { 25 _b = new B(); 26 //deal with _b 27 //or use local member 28 //B b = new B(); NO.3 29 //deal with b 30 } 31 //… 32 } 33 class Program 34 { 35 //… 36 static void Main() 37 { 38 int local1 = 0; 39 C c = new C(); 40 c.DoSomething(); //NO.1 41 int local2 = 1; //NO.2 42 // do something else 43 //END 44 } 45 }
上述代码Code 4-3中,C的DoSomething方法中默认使用的是成员变量(全局变量)_b,当DoSomething方法返回之后,_b指向的对象实例依旧处于"可达"状态。执行DoSomething方法前和执行DoSomething方法之后,栈和堆中的情况分别如下图4-12:
图4-12 栈和堆中的数据分配
上图4-12左边为执行c.DoSomething方法时,也就是代码中NO.1处,栈跟堆中的数据情况;图中右边为c.DoSomething方法执行完毕返回后,也就是代码中NO.2处,栈跟堆中的数据情况。可以看出方法执行前后,堆中的B对象实例都是处于"可达"状态,根本原因便是指向B对象实例的引用是C的成员变量,只有C被CLR回收了之后,B才由"可达"状态变为"不可达"状态。如果我们将DoSomething方法中的_b改为局部变量(代码中注释部分NO.3处),情况则完全不一样:
图4-13 栈跟堆中的数据分配
上图4-13左边为执行c.DoSomething方法时,也就是代码中NO.1处,栈跟堆中的数据情况,可以看出,指向B对象实例的引用b保存在栈中;图中右边为c.DoSomething方法执行完毕返回后,也就是代码中NO.2处,栈跟堆中的数据情况,方法执行完毕返回后,栈中的b被移除,B对象实例处于"不可达"状态,立刻成为CLR回收的目标。
(2)谨慎使用对象的析构方法。析构方法由CLR调用,不受我们程序控制,而且容易造成对象重生,除了下一节中介绍的管理对象非托管资源外,析构方法几乎不能用作其它用途。
4.3 管理非托管资源
非托管资源和托管资源一样,都是对象在使用过程中需要的必备条件。在.NET中,对象使用到的托管资源主要指它占用堆中的内存,而非托管资源则指它使用到的CLR之外的资源(详见第二章关于托管资源和非托管资源的介绍)。非托管资源不受CLR管理,因此管理好对象使用的非托管资源是每个程序开发人员的职责。
当一个对象不再使用时,我们就应该将它使用的非托管资源释放掉,归还给系统,不然等到CLR将它在堆中的内存回收之后,这些非托管资源只能等到整个应用程序运行结束之后才能归还给系统。那么什么时候是我们释放对象非托管资源的最佳时机呢?
4.3.1 释放非托管资源的最佳时间
前面讲到过,我们能够操作堆中对象实例的机会有两个,一个是该对象实例处于"可达"状态时,即有对象引用指向它;第二个是在析构方法中。因此,我们可以在这两处释放对象的非托管资源。
由于析构方法调用时间不确定,所以我们最好不要完全依赖于析构方法,也就是说,只要我们不再使用某个对象,就应该在程序中马上释放掉它的非托管资源。为了避免忘记此操作而导致的非托管资源泄露,我们可以在析构方法中同样也写好释放非托管资源的代码(作为释放非托管资源的备选方案)。
1 //Code 4-4 2 class A 3 { 4 //… 5 public A() 6 { 7 //… 8 } 9 public void DoSomething() 10 { 11 //do something here 12 } 13 public void ReleaseUnManagedResource() 14 { 15 DoRelease(); 16 GC.SuppressFinalize(this); //NO.1 17 } 18 private void DoRelease() 19 { 20 //release unmanaged resource here 21 } 22 ~A() 23 { 24 DoRelease(); 25 } 26 } 27 class Program 28 { 29 static void Main() 30 { 31 A a = new A(); 32 a.DoSomething(); //NO.2 33 a.ReleaseUnManagedResource(); 34 } 35 }
代码Code 4-4中A类型使用了非托管资源,提供了一个公开ReleaseUnmanagedResource方法,程序中使用完A类型对象后,立刻调用ReleaseUnmanagedResource方法释放它的非托管资源,同时,为了防止程序中没有调用ReleaseUnmanagedResource方法而导致的非托管资源泄露,我们在析构方法中调用了DoRelease方法。注意GC.SuppressFinalize方法(NO.1处),它请求CLR不要再调用本对象的析构方法,原因很简单,既然非托管资源已经释放完成,那么CLR就没必要再继续调用析构方法。
注:CLR调用对象的析构方法是一个复杂的过程,需要消耗非常大的性能,这也是尽量避免在析构方法中释放非托管资源的一个重要原因,最好是彻底地不调用析构方法。
如果调用a.DoSomething(NO.2处)抛出异常,那么后面的a.ReleaseUnManagedResource就不能执行,因此可以改进代码:
1 //Code 4-5 2 class Program 3 { 4 static void Main() 5 { 6 A a = new A(); 7 try 8 { 9 a.DoSomething(); 10 } 11 finally 12 { 13 a.ReleaseUnManagedResource(); 14 } 15 } 16 }
将对a对象的操作放入try/finally块中,确保a.ReleaseUnManagedResource一定执行。
4.3.2 继承与组合中的非托管资源
面向对象编程(OOP)中有两种扩展类型的方法,一种是继承,另外一种便是组合。二者都可以以原有的类型为基础创建一个新的类型,这就产生了一个问题,如果是继承,派生类中使用了非托管资源,基类中也使用了非托管资源,这两种怎么统一管理?如果是组合,类型本身使用了非托管资源,类型中的成员对象也使用了非托管资源,这两种又怎么统一管理?如果继承与组合两者结合起来,又该怎么去管理它们的非托管资源呢?
在继承模式中,我们可以将释放非托管资源的方法定义为虚方法,派生类中只需要重写该虚方法,在方法里面添加释放派生类的非托管资源代码,再调用基类中释放非托管资源的方法即可。上一小节中类型A的代码改为:
1 //Code 4-6 2 class ABase 3 { 4 //… 5 public ABase() 6 { 7 //… 8 } 9 public void ReleaseUnManagedResource() 10 { 11 DoRelease(); 12 GC.SuppressFinalize(this); //NO.1 13 } 14 protected virtual void DoRelease() 15 { 16 //release ABase's unmanaged resource here 17 } 18 ~ABase() 19 { 20 DoRelease(); 21 } 22 } 23 class A:Abase 24 { 25 public A() 26 { 27 //… 28 } 29 protected override void DoRelease() 30 { 31 // release A's unmanaged resource here 32 base.DoRelease(); //NO.2 33 } 34 }
代码Code 4-6中Abase和A类型都使用到了非托管资源,A类型重写了父类Abase的DoRelease虚方法,在其中释放A类型的非托管资源,然后再调用父类的DoRelease方法去释放父类的非托管资源(NO.2处)。
注:虚方法DoRelease必须声明为protected,因为派生类需要调用基类的该方法。
基类和派生类非托管资源关系如下图4-14:
图4-14 基类与派生类非托管资源关系
在组合模式中,一个类型可能有许多成员对象,这些成员对象也可能使用到了非托管资源。如果该类型对象释放非托管资源,那么其成员对象也应该释放它们各自的非托管资源,因为它们是一个整体,
1 //Code 4-7 2 class A 3 { 4 //… 5 public A() 6 { 7 //… 8 } 9 public void DoSomething() 10 { 11 //do something here 12 } 13 public void ReleaseUnManagedResource() 14 { 15 DoRelease(); 16 GC.SuppressFinalize(this); //NO.1 17 } 18 private void DoRelease() 19 { 20 //release A's unmanaged resource here 21 } 22 ~A() 23 { 24 DoRelease(); 25 } 26 } 27 class B 28 { 29 //… 30 A _a1; 31 A _a2; 32 public B() 33 { 34 //… 35 _a1 = new A(); 36 _a2 = new A(); 37 } 38 public void DoSomething() 39 { 40 //do something here 41 } 42 public void ReleaseUnManagedResource() 43 { 44 DoRelease(); 45 GC.SuppressFinalize(this); //NO.2 46 } 47 private void DoRelease() 48 { 49 //release B's unmanaged resource here 50 //then release children unmanaged resource 51 _a1.ReleaseUnManagedResource(); //NO.3 52 _a2.ReleaseUnManagedResource(); //NO.4 53 } 54 ~B() 55 { 56 DoRelease(); 57 } 58 }
代码Code 4-7中B类型中包含两个A类型成员_a1和_a2,_a1和_a2都需要释放非托管资源,由于它们两个跟B类型是一个整体,所以在B类型释放非托管资源的时候,我们也应该编写释放_a1和_a2的非托管资源代码(NO.3和NO.4处)。
图4-15 组合模式中的非托管资源
上图4-15中一个对象可以包含许多成员对象,这些成员对象又可以包含更多的成员对象,图中每个对象都有可能使用了非托管资源,我们的职责就是在parent释放非托管资源的时候,将它下级以及下下级(甚至更多)的所有成员对象的非托管资源全部释放。
注:图4-15中的parent是相对的,也就是说,parent也有可能成为另外一个对象的成员对象。纵观程序中各个对象之间的关系,几乎都是这种结构,我们列举出来的只是其中的一小部分,图中childN也可能成为另外一个parent。
继承与组合同时存在的情况就很简单了,将两种释放非托管资源的方法合并,代码如下(不完整):
1 //Code 4-8 2 class A:ABase 3 { 4 //… 5 B _b1; //member 6 B _b2; //member 7 public A() 8 { 9 //… 10 _b1 = new B(); 11 _b2 = new B(); 12 } 13 public override void DoRelease() 14 { 15 // 1.release A's unmanaged resource 16 // 2.release member's unmanaged resource 17 // 3.release ABase's unmanaged resource 18 19 ReleaseMyUnManagedResource(); //NO.1 20 _b1.ReleaseUnManagedResource(); //NO.2 21 _b2.ReleaseUnManagedResource(); 22 base.DoRelease(); //NO.3 23 } 24 private void ReleaseMyUnManagedResource() 25 { 26 //… 27 //release A's unmanaged resource here 28 } 29 }
代码Code 4-8中,A类型派生自ABase类型,同时A类型中包含_b1和_b2两个成员对象,在A类型内部,我们重写DoRelease虚方法,首先释放A中的非托管资源(NO.1),然后释放成员对象的非托管资源(NO.2),最后释放基类的非托管资源(NO.3)。
注意,类型ABase的内部结构跟A类型一样(只要它们的最顶层基类中有ReleaseUnManagedResource公开方法和对应的析构方法),类型B的内部结构也跟A类型一样,也就是说,每个类型的内部结构都与A类型一样。另外,代码中NO1. NO2以及NO3的顺序是可以改变的,换句话说,非托管资源的释放顺序也是可以改变的。
实例代码对应的非托管资源释放顺序如下图4-16:
图4-16 非托管资源的释放顺序
图4-16中顺序号为非托管资源的释放顺序,对于每一个单独的对象而言,都是遵循"自己-成员对象-基类"这样的一个顺序。图4-16中非托管资源的释放顺序并不是固定的。
4.4 正确使用IDisposable接口
上一节讲到了管理对象非托管资源的方法。如果一个类型需要使用非托管资源,那么我们可以这样去做:
(1)在类型中提供类似ReleaseUnManagedResource这样的公开方法,当对象不再使用时,开发者可在程序中人工显式释放非托管资源;
(2)编写类型的析构方法,在析构方法中编写释放非托管资源的代码,防止开发者没有人工显式释放非托管资源而造成资源泄露异常。
既然这些都是总结出来管理非托管资源的有效方法,那么我们在编程过程中就应该把它当做一个规则去遵守。.NET类库中有一个IDisposable接口,几乎每个使用非托管资源的类型都应该实现该接口。
4.4.1 Dispose模式
"Dispose模式"便是管理对象非托管资源的一种原则,微软官方发布类库中所有使用了非托管资源的类型都遵循了这一原则。该模式很简单,定义一个IDisposable接口,该接口包含一个Dispose方法,所有使用了非托管资源的类型均应该实现该接口,类似代码如下:
1 //Code 4-9 2 interface IDisposable 3 { 4 void Dispose(); 5 } 6 class ABase:IDisposable 7 { 8 //… 9 bool _disposed = false; //check if released or not 10 public bool Disposed 11 { 12 get 13 { 14 return _disposed; 15 } 16 } 17 public ABase() 18 19 { 20 21 //… 22 23 } 24 public void Dispose() //NO.1 25 { 26 if(!_disposed) 27 { 28 Dispose(true); 29 GC.SuppressFinalize(this); 30 _disposed = true; 31 } 32 } 33 protected virtual void Dispose(bool disposing) 34 { 35 if(disposing) 36 { 37 //release member's unmanaged resource 38 } 39 //release ABase's unmanaged resource 40 //no base class 41 } 42 ~ABase 43 { 44 Dispose(false); //call the virtual Dispose method,maybe override in derived class 45 } 46 } 47 class A:ABase 48 { 49 //… 50 public A() 51 { 52 //… 53 } 54 protected override void Dispose(bool disposing) 55 { 56 if(disposing) 57 { 58 //release member's unmanaged resource 59 } 60 //release A's unmanaged resource 61 base.Dispose(disposing); //NO.2 62 } 63 } 64 65 class B:A 66 { 67 //… 68 public B() 69 { 70 //… 71 } 72 public void DoSomething() 73 { 74 if(Disposed) //if released, throw exception 75 { 76 throw new ObjectDisposedException(...); 77 } 78 // do something here 79 } 80 81 protected override void Dispose(bool disposing) 82 { 83 if(disposing) 84 { 85 //release member's unmanaged resource 86 } 87 //release B's unmanaged resource 88 base.Dispose(disposing); //NO.3 89 } 90 }
代码Code 4-9中Abase类实现了IDisposable接口,并提供了两个Dispose方法,一个不带参数的普通方法和一个带有一个bool类型参数的虚方法。如果程序中人工显式调用Dispose()方法去释放非托管资源,那么同时会释放所有成员对象的非托管资源(disposing参数为true);如果不是人工显式调用Dispose()方法释放非托管资源而是交给析构方法去负责,那么就不会释放成员对象的非托管资源(disposing参数为false),这样一来,所有成员对象都得由自己的析构方法去释放各自的非托管资源。
ABase类型的所有派生类,如果使用到了非托管资源,只需要重写Dispose(bool disposing)虚方法,在其中编写释放自己使用的非托管资源代码。如果有必要(disposing为true),则释放自己成员对象的非托管资源,最后再调用基类的Dispose(bool disposing)虚方法(NO.2和NO.3)。另外对象中每一个方法执行之前需要判断自己是否已经Disposed,如果已经Disposed,说明对象已经释放非托管资源,大多数时候该对象不会再正常工作,因此抛出异常。
注:析构方法只需要在ABase类中编写一次,其派生类中不需要再有析构方法。如果派生类中有析构方法,也一定不能再调用Dispose(bool)虚方法,因为析构方法默认是按照"底层-顶层"这样的顺序依次调用,多个析构方法多次调用Dispose(bool),会重复释放非托管资源,引起不可预料异常。
前面提到过,为了确保程序中人工显式释放非托管资源的代码在任何情况中一定执行,需要把代码放在try/finally块中。C#中还有一种更为简洁的写法,只要我们的类型实现了IDisposable接口,那么就可以这样编写代码:
1 //Code 4-10 2 using(A a = new A()) 3 { 4 a.DoSomething(); 5 }
这段代码Code 4-10编译之后相当于:
1 //Code 4-11 2 A a = new A(); 3 try 4 { 5 a.DoSomething(); 6 } 7 finally 8 { 9 a.Dispose(); 10 }
这样就能确保a对象使用完毕后,a.Dispose方法总能够被调用。在使用FileStream、SqlDataConnection等实现了IDisposable接口类型的对象时,我们几乎都可以这么使用:
1 //Code 4-12 2 using(FileStream fs = new FileStream(…)) 3 { 4 //deal with fs 5 }
在实际开发过程中,我们经常能遇见应用了"Dispose模式"的场合,比如Winform开发中,新建一个窗体类Form1,Form1.Designer.cs中默认生成的代码如下:
1 //Code 4-13 2 protected override void Dispose(bool disposing) 3 { 4 if (disposing && (components != null)) 5 { 6 components.Dispose(); //NO.1 7 } 8 // add your code to release Form1's unmanaged resource //NO.2 9 base.Dispose(disposing); //NO.3 10 }
因为Form1派生自Form,Form又间接派生自Control,Control派生自Component,最后Component实现了IDisposable接口,换句话说,Form1间接实现了IDisposable接口,遵循"Dispose模式",那么它就应该重写Dispose(bool disposing)虚方法,并在Dispose(bool disposing)虚方法中释放成员对象的非托管资源(NO.1处)、释放本身使用的非托管资源(NO.2处)以及调用基类的Dispose(bool disposing)虚方法(NO.3处)。
注:Form1中使用了非托管资源的成员对象几乎都派生自Component类型,并存放在components容器中,这些代码基本都由窗体设计器(Form Designer)自动生成,本书第七章中有讲到。
总之,记住如果一个类型使用了非托管资源,或者它包含使用了非托管资源的成员,那么我们就应该应用"Dispose模式":正确地实现(间接或直接)IDisposable接口,正确的重写Dispose(bool disposing)虚方法。
4.4.2 对象的Dispose()和Close()
很多实现了IDisposable接口的类型同时包含Dispose()和Close()方法,那么它们究竟有什么区别呢?都是用来释放非托管资源的吗?
事实上,它两没有绝对的确定关系,有的时候Close的字面意思更形象。比如一个大门Gate类,使用Close作为方法名称比使用Dispose更直白,因此有时候把Dispose()方法屏蔽,用Close()方法代替Dispose()方法,作用跟Dispose方法完全一样。
1 //Code 4-14 2 class Gate:IDisposable 3 { 4 //… 5 public Gate() 6 { 7 //… 8 } 9 void IDisposable.Dispose() //implement IDisposable explicitly 10 { 11 Close(); 12 } 13 public void Close() 14 { 15 if(!_disposed) 16 { 17 Dispose(true); 18 GC.SuppressFinalize(this); 19 _disposed = true; 20 } 21 } 22 //other methods 23 }
上面代码Code 4-14中Close方法就起到与Dispose方法一样的作用,意思便是释放非托管资源。另外还有一些情况Close()与Dispose()方法同时存在,但是作用却并不一样。
总之,凡是谈到Dispose(),与它的字面意思一样,意思是释放非托管资源,Dispose()方法调用后对象就不能再使用。而谈到Close(),它有时候与Dispose()的功能一样,比如FileStream类型,而有的时候却跟Dispose()不一样,它仅仅表示"关闭"的意思,说不定还会有一个Open()方法与它对应,比如SqlConnection。
不管是Dispose还是Close,我们需要注意一点,如果释放了对象的非托管资源,那么这个对象就不能再使用,否则就会抛出异常。我们调试代码的时候偶尔会碰到类似"无法使用已经释放的实例"的错误信息,意思便是不能再使用已经释放非托管资源的对象,原因很简单,非托管资源是对象正常工作时不可缺少的一部分,释放了它,对象肯定不能正常工作。下图4-17表示人工显式释放对象非托管资源在对象整个生命周期的时间点位置:
图4-17 释放非托管资源时间点
图4-17中调用对象Dispose()方法是在对象处于"可达"状态时。Dispose()方法调用之前,对象可以正常使用,调用之后,虽然仍然有引用指向它,但是对象已经不能再使用。在图4-17中"无效"区域操作对象时,大部分都会抛出异常。
注:对象释放非托管资源后,也就是调用了Dispose()或者Close()方法之后,不代表该对象死亡,这时候还是可以有引用指向它,继续访问它,虽然此时所有的访问几乎都已无效。
4.5 本章回顾
本章开头介绍了编程中"堆"和"栈"的概念,它们是两种不同的数据结构,程序运行期间起着非同一般的作用;之后着重介绍了存放在堆中对象以及它的生命周期,该部分的配图比较多也比较详细,读者可以仔细阅读配图,相信能更清楚地理解对象在堆中的"从生到死";之后介绍了.NET对象使用到的"非托管资源"以及怎样去正确地管理好这些资源,章节最后提到了"Dispose模式",它是管理对象非托管资源的有效方式,同时还解释了为什么某些类型同时具备Close()和Dispose()方法。对象生命期是.NET编程中的重点,清楚了解对象的"生"到"死"是能够编写出稳定程序的前提。
4.6 本章思考
1."当栈中没有引用指向堆中对象实例时,GC会对堆中实例进行内存回收"这句话是否准确?为什么?
A:不准确。因为GC不但会检查栈中的引用,还会检查堆中是否有引用。因此,只有当没有任何引用指向堆中对象实例时,GC才会考虑回收对象实例所占用的内存。
2.如果一个类型正确地实现了IDisposable接口,那么调用该类型对象的Dispose()方法后,是否意味着该对象已经死亡?为什么?
A:调用一个对象的Dispose()方法后,并不表明该对象死亡,只有GC将对象实例占用的内存回收后,才可以说对象死亡。但是通常情况下,调用对象的Dispose()方法后,由于释放了该对象的非托管资源,因此该对象几乎就处于"无用"状态,"等待死亡"是它正确的选择。
3.如果一个类型使用了非托管资源,那么释放非托管资源的最佳时机是什么时候?
A:当对象使用完毕后,就应该及时释放它的非托管资源,比如调用它的Dispose()方法(如果有),对象的非托管资源释放后,对象基本上就处于"无用"状态,因此一般不能再继续使用该对象。为了防止遗忘手动释放对象的非托管资源,我们应该在对象的析构方法中编写释放非托管资源的代码,这样一来,假如我们没有手动释放对象的非托管资源,GC也会在适当时机调用析构方法,对象的非托管资源总能正确被释放。