-
最小化内存垃圾
Item 16 – Minimize Garbage
内存开销一直是影响软件效率的一大因素,由于.NET中垃圾收集器的引入,许多程序员对此更是耿耿于怀。一个可见的事实是许多程序员都发现使用.NET开发的托管应用程序“占用”的内存通常会比本地应用程序多一些。暂不论使用“任务管理器”查看到的内存“占用”本身就不是一个准确的数字。就算这个数字具有比较意义,“.NET应用程序占用内存多”这一事实背后也有很复杂的因素,并不都是垃圾收集器惹的祸,比如.NET庞大的运行时支持设施本身就会占用可观的内存。
虽然不必对.NET中垃圾收集器耿耿于怀,但也不意味着我们可以将所有内存管理的问题完全交给垃圾收集器,而无视内存管理这一问题本身的存在。垃圾收集器只能在很大程度上帮助我们更好地管理内存,而不能彻底屏蔽内存管理问题。毕竟所有对象都要经过分配、活动、销毁这些阶段。
因此,降低内存开销一个最直观的做法便是“尽可能地减少所创建的实例对象”。根据该原则,Bill Wagner先生在本条款中提出了以下几个技巧:
1.将频繁使用的引用类型局部变量实现为类型的字段。
2.使用Singleton模式来创建频繁使用的实例对象。
3.创建类似StringBuilder这样的可变类型来处理类似String这样具有常量性的类型。
这些都是通过对代码进行技巧性的处理,来尽可能地避免创建不必要的实例对象,从而降低内存的开销。这些做法通常也适用于其他编程语言,而且也有很多灵活的变体。比如在一个大的循环结构内,我们应该避免在其中创建实例对象,而尽可能地将创建实例对象的工作放在循环结构之外。
事实上,除了要“尽可能地避免创建不必要的实例对象”外,“创建什么样的对象——即如何设计类型”同样会影响内存的开销,并进而影响程序的效率。一些效率低下的隐患可能在类型设计时就埋了下来。其原因通常是类型的实例对象由于内部特殊的结构,从而延缓了垃圾收集器对其的回收。改善这个问题必须从类型设计入手。可惜的是Bill Wagner先生在本书中很少触及这个话题。
那么什么样类型的对象实例会延缓垃圾收集对其的回收呢?或者说反过来说,怎样的类型设计是“垃圾收集友好”的呢?基本上来讲,我们有以下几个原则。
1. 避免对象之间有过多的引用关系
2. 避免将老对象引用新对象
3. 利用分割设计,将终止化对象设计得尽可能小,避免在其中引用其他对象
这几个原则基于这样一个事实:.NET采用的是分代式的垃圾收集器,越“新”的对象,释放速度越快;越“老”的对象,释放速度越慢。而垃圾收集器判断对象一个重要的依据就是对象间的引用关系,即所谓的“对象图”。垃圾收集器每次进行内存回收之前,都要构建一个“可达对象图”,如果对象之间有过多的引用关系,那么构建这个图就要花费可观的代价。另外,在这样的对象图中,如果一个老对象引用了一个新对象,那么从某种意义上来讲,这个新对象也被“拖到了老一代”,从而延缓了内存的回收。终止化对象(即重写了Finalize方法的对象)也由于先天的属于“老一代”,因此在其中引用其他对象、或者设计得很大,都是“垃圾收集不友好”的做法。这时候应该进行分割设计,将终止化对象单独隔离出来。
值得指出的是,上述几种类型设计技巧通常并不适用于非.NET平台上的语言,因为这些技巧非常依赖于特定的垃圾收集模式(比如.NET的分代式垃圾收集器)。
除了类型设计需要考虑特定的垃圾收集模式外,在如何使用垃圾收集器时,还有一个误区需要避免,即调用GC.Collect()方法。很多初学者经常容易把GC.Collect()方法想象成C++中的delete操作符,因此很自然地想通过调用GC.Collect方法来释放内存。这样做对于.NET中的垃圾收集器是不合适的。这是因为GC.Collect不会精确地回收单个对象的内存,一旦执行就是回收一个代、甚至所有代的对象内存。而GC在做这样的回收时,需要花费相当的时间来构造一个可达的对象图。对于一个具有自学习和自调解能力的垃圾收集器来说,“什么时候进行收集、收集哪些代的对象”应该由垃圾收集器根据整个系统中的对象分布情况,以及内存占用情况来做判断,程序员绝大多数情况下无需进行任何干预。调用GC.Collect往往会打乱垃圾收集器自学习和自调节的过程,收到适得其反的效果。
最小化装箱与拆箱
Item 17 – Minimize Boxing and Unboxing
装箱与拆箱虽然是.NET中一个创新的名词,但却是一个彻头彻尾的旧技术——“新瓶装旧酒”好像是微软在技术界一贯的作风J。不过这并不影响人们对它进行三番五次的讨论,几乎在所有有关C#的书中,装箱与拆箱都被放到了显著的位置,Effective C#也不例外。
除了需要避免一些不必要的装箱与拆箱操作(比如将一些频繁的装箱操作变为一次装箱操作、比如在struct中重写所有ValueType和Object中的虚方法来减少struct被装箱的机会)外,对于某些“通常很难避免装箱与拆箱”的情况,.NET社区很早都有一种改善的做法——即“通过实现接口来修改托管堆中的装箱值类型实例”,这个技巧在本条款中得到了详细的讨论。不过我对这种做法一直持保留态度,至少我在项目中很少使用。因为我在设计上比较崇尚“单目标”原则——接口是用来定义组件间的契约合同的,不是用来解决装箱/拆箱的性能问题的。就像我看到在Java中通过实现Serializable接口来表达允许一个类可序列化,我同样感到不舒服(所幸的是.NET有了Attributes这么好的东西J):
class MyClass implements Serializable
{....}
如果真的是装箱和拆箱掉到了不可思议的循环语句里,我更喜欢将值类型直接修改为引用类型——而不是给它实现一个看起来很不舒服的接口。当然不舒服并不是错误,至少是一种可选的解决方法。
这些讨论都不错,不过如果能够跳出C#来看问题,也许别有一番景致。所谓“不知庐山真面目,只缘身在此山中”。装箱/拆箱本身没有错,既然.NET对象主要有两种存储方式(栈与托管堆),那么允许在两者之间进行转换是一个自然的需求。但问题是,C#在进行装箱之后就失去了它的类型信息。比如我们有一个MyStruct结构,装箱之后只能访问System.Object的那些成员,要访问MyStruct结构本身所具有的成员就必须再进行拆箱——当然拆完箱之后通常还需要再装箱J。“通过实现接口来修改托管堆中的装箱值类型实例”固然为装箱值类型实例提供了更强的类型信息,但毕竟不是解决问题的永久办法。
事实上,.NET是提供有“强类型的装箱值类型”的,只不过C#语言不支持这样的类型,目前只有C++/CLI(即2005版的Visual C++)支持这样的类型,例如对于一个结构类型MyStruct,我们可以在C++/CLI中这样来操作:
MyStruct^ s=gcnew MyStruct();
s->MyMethod();
第一行语句进行了装箱操作,但是随后对MyMethod()成员的访问则不需要C#中通常的“拆箱、拷贝到栈上,再装箱”的过程。
这里需要先解释一下“拆箱、拷贝到栈上,再装箱”。实际上,.NET中的装箱与拆箱操作并不完全是一对相反的操作。准确地讲,“拆箱+拷贝”才和“装箱”是一对相反的操作——换言之,装箱操作本身含有一个“将对象从栈上拷贝到托管堆上的过程”。事实上,拆箱操作本身没有太大效率问题,真正的效率问题是在“拷贝”上。
从类型层面来看,C#中没有类型可以表达“一个装箱值类型实例拆箱后的实例”——注意这里谈的是“只拆箱,而不拷贝”,因此不得不把它拷贝到栈上。而在C++/CLI中,内部指针(Interior Pointer)可以表达这样的类型——这样的类型既位于托管堆上,又具有MyStruct强类型信息。无需再拷贝到栈上来访问MyStruct的成员。自然也就免除了进一步装箱的必要。