目录
.Net内存管理机制
条目12 推荐使用成员初始化器而不是赋值语句
条目13 正确地初始化静态成员变量
条目14 尽量减少重复的初始化逻辑
条目15 使用using和try/finally清理资源
条目16 避免创建非必要的对象
条目17 实现标准的销毁模式
条目18 区分值类型和引用类型
条目19 保证0为值类型的有效状态
条目20 保证0值类型的常量性和原子性
GC(垃圾收集器)将替你控制托管内存。与原生环境不同,在.Net中不用担心内存泄漏、悬挂指针、未初始化指针等问题。不过GC并不是万能的,对于非托管的资源清理,如文件句柄、数据库连接、GDI+对象、COM对象以及其他系统对象等。此外,若是使用了事件处理函数和委托在对象间建立了连接,那么还可能造成对象超过预期地长时间驻留在内存中。那些将在请求结果时才会真正执行地查询有时也会导致同样的问题。因为查询将把需要绑定的局部变量用闭包封装起来,而这些绑定的变量只有在查询结果离开作用域之后才会被释放。
因为GC将管理内存,所以一些设计模式也会更易于实现。无论是循环引用、简单关系还是复杂的对象图,GC都可以处理。GC的“标记并压缩”算法能够高效地分析出这些关系,并将不可达的那部分对象从内存中清除。GC会从应用程序的跟对象开始,通过对对象树形结构的遍历来判定一个对象是否可达,而并不像COM那样跟踪对每个对象的引用。
释放内存是GC的责任。因为.Net Framework开发者不需要释放这些对象,所以复杂的对象图之间的引用也不会造成任何问题。应该按照何种顺序释放这个对象图中的各个对象更不需要由开发者担心,这是GC的工作。GC通过判断某一个对象(图)是否为垃圾来简化这个操作过程。当不需要某个对象时,应用程序自然不会再继续保存这个对象的引用。GC能够了解某个实体目前是否仍然被应用程序的某些活动对象所引用。对于那些没有被任何活动对象直接/间接引用的对象,GC会将其判断为垃圾。
GC将在其专门的线程中运行,默默地为程序清除不再使用的内存。GC也会在每次运行时压缩托管堆。压缩托管堆能够将当前仍旧在使用的对象放在连续的内存中,因此空余空间也会是一块连续的内存。图2-1演示了某个托管堆在一次垃圾收集前后的状态。每次GC操作之后,所有的空余内存都将形成一大块连续的空间。
可以看到,托管堆上的内存管理完全是GC的职责。而其他的系统资源则要由开发者负责管理。.Net提供了两种控制非托管资源生命周期的机制:终结器和IDisposable接口。终结器存在着很多问题,好在IDisposable接口能以一种影响更小的方式及时地将资源返回给系统。
终结器将有GC调用。调用发生在对象成为垃圾之后的某个时间。无法确定其发生的具体时间,只是知道这将发生在对象不可达之后。
依赖终结器还会带来性能上的问题。需要执行终结器的对象将会给GC带来额外的性能开销。当GC发现某个对象属于垃圾,但该对象需要执行终结器时,就不能将其直接从内存中移除。首先,GC将调用其终结器,而终结器并不在执行垃圾回收的线程上执行。GC将把所有需要执行终结的对象放在专门的队列中,然后让另外一个线程来执行这些对象的终结器。这样GC可以继续执行其当前的工作,从内存中移除垃圾对象。而在下一次的GC调用时,才会从内存中移除这些已被终结的对象。
.Net的GC为了优化其执行,还引入了“代”的概念。这个概念可以让GC更快的找到那些更有可能是垃圾的对象。自上一次垃圾收集以来,新创建的对象属于第0代对象。而若是某个对象在经历过上一次垃圾收集之后仍旧存活,那么将成为第1代对象。两次及以上垃圾收集后仍没有被销毁的对象就变成了第2代对象。这样的分代方式是为了能将局部变量和应用程序生命周期中一直使用的对象分开对待。第0代大多属于局部变量。而成员变量和全部变量则会很快成为第1代直至第2代对象。
GC将通过减少检查第1代和第2代对象的次数来优化执行过程。在每个周期中,GC都会检查第0代对象。大概10个周期的GC中,会有1次去同时检查第0代和第1代对象。大概100个周期的GC中,会有一次同时检查所有的对象。再回到终结器的代价上来,可以看到,一个需要终结的对象可能会比普通对象多停留9个GC周期。而若是再次GC的时候仍然没有完成终结操作,那么该对象将继续被提升为第2代。对于第2代对象,往往需要100次以上的GC周期才会有机会被清除。
通常来说类都有不止一个构造函数。随着时间推移,成员变量的增加,构造函数个数也会不停地增加。预防这种情况的最好办法是,在声明变量的时候就进行初始化,而不是在每个构造函数中进行。
这些初始化器会被添加到编译器自动生成的默认构造函数中。
初始化器可以看做是构造函数中初始化语句的另一种表示。初始化器生成的代码会插入到构造函数代码的前面执行。初始化器将在为类型执行调用基类构造函数之前执行,其顺序与类中成员变量被声明的顺序保持一致。
在如下3中情况中,你应该避免使用初始化器语法:
实际上创建了两个List,第一个变成了垃圾。
成员初始化器是保证类型中成员变量均被初始化的最简单方法——无论调用的是哪一个构造函数。初始化器将在所有构造函数执行之前执行。使用这种语法也就保证了你不会添加新的构造函数时遗漏掉重要的初始化代码。综上,若是所有的构造函数都要将某个成员变量初始化成同一个值,那么应该使用初始化器的语法。
C#提供了静态初始化器和静态构造函数来用于静态成员变量的初始化。
静态构造函数是一个特殊的函数,将在其他所有方法执行之前以及变量或属性被第一次访问之前执行。可以用这个函数来初始化静态变量,实现单例模式或执行类可用之前必须进行的任何操作。
和实例初始化一样,也可以使用初始化器语法来替代静态的构造函数。若只是需要为某个静态成员分配空间,那么不妨使用初始化器的语法。而若是要更复杂一些的逻辑来初始化静态成员变量,那么可以使用静态构造函数。
在应用程序作用域内,在你的类型被第一次访问之前,CLR会自动调用静态构造函数。只能定义一个静态构造函数,且不能接受任何参数。
使用静态构造函数而不是静态初始化器最常见的理由就是处理异常。在使用静态初始化器时,我们无法自己捕获异常。而在静态构造函数中却可以做到。
编写构造函数很多时候是个重复性的劳动,如果你发现多个构造函数包含相同的逻辑,可以将这个逻辑提取到一个通用的构造函数中。这样既可以避免代码重复,也可以利用构造函数初始化器来生成更高效的目标代码。C#编译器将把构造函数初始化器看做是一种特殊的语法,并移除掉重复的变量初始化器以及重复的基类构造函数调用。这样使得最终的对象可以执行最少的代码来保证初始化的正确性。
构造函数初始化器允许一个构造函数去调用另一个构造函数。而C# 4.0添加了对默认参数的支持,这个功能也可以用来减少构造函数中的重复代码。你可以将某个类的所有构造函数统一成一个,并为所有的可选参数指定默认值。其他的几个构造函数调用某个构造函数,提供不同的参数即可。
使用构造函数链,让一个构造函数调用声明在同一个类中的另一个构造函数,而不用创建一个公用的辅助方法。因为创建公用的辅助方法生成的目标代码效率将大打折扣。编译器将在构造函数中添加一系列代码,及所有变量的初始化器,还会调用基类的构造函数。要是使用这种公共的辅助方法的话,编译器就无法替你移除重复的代码。
创建某个类型的第一个实例时所进行的初始化操作顺序:
(1)静态变量设置为0
(2)执行静态变量初始化器
(3)执行基类的静态构造函数
(4)执行静态构造函数
(5)实例变量设置为0
(6)执行实例变量初始化器
(7)执行基类中合适的实例构造函数
(8)执行实例构造函数
同样类型的第二个以及以后的实例将从第5步开始执行,因为类的构造器仅会执行一次。此外第6步和第7步将被优化,以便构造函数初始化器使编译器移除重复的指令。
所有非托管资源的类型必须显式地使用IDisposable接口的Dispose()来释放。所有封装或使用了非托管资源的类型都实现了IDisposable,这些类型在终结器中也会调用Dispose(),以便在忘记的时候仍能保证正常释放资源。不过这些资源将在内存中停留更长时间,应用程序也会变成资源消耗大户。
若想使用一个可销毁的对象,那么using语句能够以最简单的方式保证你的对象可以正常销毁。using语句将生成一个try/finally块,包裹住分配的对象。
这两段代码生成的IL完全一致:
释放可销毁对象常常有一个细微的不同。有些类型在提供了Dispose方法的同时,还提供了Close方法。它们的区别在于Dispose方法不仅仅是清理资源,它还告知GC该对象不再需要被终结。Dispose将调用GC.SuppressFinalize()方法,而Close则一般不会。因此,即使已经不需要被终结,但对象仍旧在终结队列中。
GC能够帮助很好地管理内存,也会以一种非常高效的方式来移除内存中的垃圾对象。不过不管有多高效,分配和销毁在堆上的对象总会花费掉时间。若是在某个方法中创建了太多的引用对象,那么将会对程序的性能产生严重的影响。
几种尽量降低程序中创建对象数量的方法。
(1)将常用的局部变量提升为成员变量,例如
(2)提供一个类,存放某个类型常用实例的单例对象
(3)创建不可变类型的最终值。System.String类就是不可变的,在构造一个字符串后,其内容不能被修改。当尝试修改某个字符串内容时,实际上创建了一个新的字符串,从前的字符串就变成了垃圾。
string类的+=操作符会创建一个新的字符串对象并返回。这个方法不会进行字符替换、拼接等修改现有字符串的操作。
尽量使用string.Format方法或StringBuilder类进行字符串的拼接。
StringBuilder是一个可变的字符串类,用来创建不可变的string对象。它允许在创建出一个不可变的string对象之前,对其内容进行修改维护。
GC可以高效地管理应用程序使用的内存。不过创建和销毁堆上的对象仍旧需要时间。因此,因尽量避免创建过多的对象,不要创建那些非必须的对象,也不要在局部方法中创建太多的引用对象。可以考虑将局部变量提升为成员变量,或者为最常用的类型实例提供静态对象。此外,还可以考虑为不可变类型提供可变的创建对象。
.Net Framework中使用了一种标准的销毁非托管资源的模式。这个标准的模式能够在使用者正常调用时通过IDisposable接口释放掉非托管资源,也会在使用者忘记的情况下使用终结器释放。这个模式和GC配合,可以保证仅在最糟糕的情况下才调用终结器,尽可能降低其带来的性能影响。
在GC运行时,它会立即清理掉那些没提供终结器的垃圾对象。而提供了终结器的垃圾对象会停留在内存中,被添加到一个叫做“终结队列”的地方。GC会使用另一个线程来执行队列中对象的终结器。终结器完成工作后,这些垃圾对象才能从内存中清理出去。为了保险起见,必须为使用了非托管资源的类型提供一个终结器。
避免终结带来的性能影响的一些步骤:
(1)释放所有非托管资源
(2)释放所有托管资源,包括释放事件监听程序
(3)设定一个状态标志,表示该对象已经被销毁。若是销毁后再次调用对象的共有方法,那么应该抛出ObjectDisposed异常
(4)跳过终结操作,调用GC.SuppressFinalize(this)即可
关于销毁、清理方法一条建议:只能释放资源,不得在Dispose方法中执行别的操作。
在托管环境下,无需为每个类型都编写一个终结器。仅对于那些存放了非托管资源或某个成员实现了IDisposable的类型才要这样做。即使你需要的只是Disposable接口,而不是终结器,也要完整的模式。否则,派生类就不得不在标准的DIspose模式之外自成体系,增加其复杂性。
C#中,class对应引用类型,struct对应值类型。
C#不是C++,不能将所有类型定义成值类型并在需要时对其创建引用。C#也不是Java,不像Java中那样所有的东西都是引用类型。
值类型无法实现多态,因此其最佳用途就是存放数据。引用类型支持多态,因此用来定义应用程序的行为。
一般情况下,我们习惯用class,随意创建的大都是引用类型,若下面几点都肯定,那么应该创建struct值类型:
1)该类型主要职责在于数据存储吗?
2)该类型的公有接口都是由访问其数据成员的属性定义的吗?
3)你确定该类型绝不会有派生类型吗?
4)你确定该类型永远都不需要多态支持吗?
用值类型表示底层存储数据的类型,用引用类型来封装程序的行为。这样,你可以保证类暴露出的数据能以复制的形式安全提供,也能得到基于栈存储和使用内联方式存储带来的内存性能提升,更可以使用标准的面向对象技术来表达应用程序的逻辑。而倘若你对类型未
来的用途不确定,那么应该选择引用类型。
你应该让0成为一个合法状态,用最适合做默认值的选项表示0。保证0也是有效状态的一部分,即使这谈不上完美。
作为标志使用的枚举 (即添加了Falgs特性)应该总是将None设置为0。
很多人使用标志枚举执行按位与操作。而0值将会带来很严重的问题。例如,若Flag的值为0,那么如下的测试永远不能成功:
使用标志枚举时,要保证0为有效值,且让其表示“所有标志都没有设置”的情况。
常量性的类型使得我们的代码更加易于维护。不要盲目地为类型中的每一个属性都创建get和set访问器。对于那些目的是存储数据的类型,应该尽可能地保证其常量性和原子性。