翻译自Maoni's WebLog 文章Using GC Efficiently – Part 1,Maoni是微软CLR Performance组的成员
本文的目标是解释一些东西的代价好让你可以更好使用托管内存-而不是解释GC本身-只是解释如何使用它而已。我假设绝大多数人对于使用垃圾收集感兴趣,而不想自己实现一个。本文假设读者对GC有基础的了解,如果你需要一些关于GC的背景知识,Jeff Richter写了两篇非常好的MSDN文章,1和2。
首先我会关注工作站类型的GC(Wks GC),然后我会解释服务器类型的GC(Svr GC)与前者的区别和你该用哪个(但是通常情况下你不需要选择,而我也会解释为什么你不需要)。
代龄 (Generations)
有三个代龄的原因是:我们希望一个经过良好优化的软件,大部分的对象在Gen0中就失效了,在一个服务器程序中,和每个请求(Request)关联的内存分配都应该在请求结束后失效。And the in flight allocation requests will make into Gen1 and die there(这句没能理解,请高手指点)。 实际上Gen1就是年轻的对象和年长的对象之间的缓冲而已。当你在性能监视器中查看每一代中的对象集合数量时,你应该希望看到Gen2和Gen0的比率非常低。Gen1的集合数量相对并不重要。收集Gen1的对象并不比收集Gen0的代价昂贵许多。
GC段(GC segments )
首先我们看看GC怎么从操作系统获取内存吧。GC按段来保留(reserve)内存。每个段16M。当执行引擎(EE,Execution Engine)开始执行时,我们保留初始段--一个用于小对象堆,另一个用于大对象堆(LOH,large object heap)。
内存按需进行调拨(commit)和释放(decommit)。当我们用完了保留的内存段会调拨。每次完全收集之后如果某个段没有被使用,就会被删除。
LOH始终在它自己的段中存在-大对象和小对象被区分对待,这样它们就不和小对象共享段了。
分配(Allocation)
当你从GC堆中分配内存时,它到底消耗什么资源呢?如果我们不需要进行GC,分配就是1)将指针前移2)为新对象清除内存。对于可终结对象(finalizable object),还有一步额外的操作,它们还会被加入一个GC需要监视的队列。
注意我说了“如果我们不需要进行GC”-这意味着分配的消耗和分配的大小成比例。你分配的越少,GC需要做的工作越少。如果你需要15个字节,它就请求15个字节;不要像使用malloc的时候那样进位到32个字节或者更大的块大小。这里有一个阀值,当阀值被超过时,GC操作被触发。你应该尽力使这种触发发生的越少越好。
另一个GC堆和NT堆的区别是:一起分配的对象位置始终保持在一起。
每个在GC堆中分配的对象有一个8字节的头(同步块+方法表指针)。
就像我提到的一样,大对象的对待方式不一样,所以你应该使用不同的模式来分配它们。我会在大对象那一节讨论它。
收集(Collection)
首先,(垃圾)收集到底是什么时候发生的(换句话说,GC是什么时候被触发的)?当下面三种情况中的一种发生是,GC被触发
1) 内存分配导致Gen0的阀值被超出;
2) System.GC.Collect被调用;
3) 系统处于低内存状态;
通常情况下都是由于1)引起的。当你分配了足够的内存之后,就触发了一次GC。分配只会在Gen0中发生。每次GC完成之后,Gen0就变成空的了。新的分配会再度填满Gen0,然后下一次GC就发生了,以此循环往复。
你可以避免2)的情况,只要不调用CG.Collect就行了-如果你在写应用,通常情况下你永远都不需要调用它。BCL(Base Class Libraries)是唯一的需要显式调用它的地方(也是在非常有限的地方)。当你在应用程序中调用它的时候,问题在于它比你期望的要更加频繁的被调用(很容易发生),性能将会降低,由于GC在它们预期的时间之前被调用,而本来GC的调用是为最佳性能调优过的。
3) 的情况是被系统中的其他进程影响,所以你不能做过多的控制,除了优化自己的程序之外没什么可以做的。
我们来讨论一下上面这些都意味着什么。首先,GC堆是你的工作集的一部分。它消耗私有内存页。在理想状态下,分配出来的对象永远都是在Gen0就变得无用(意味着它们都在Gen0被回收而且没有任何完全收集会发生),这样你的GC堆永远不会超出Gen0的大小。实际情况几乎永远都不可能如此完美。所以你真的需要控制你的GC堆尺寸。
其次,你希望控制花在GC上的时间。这意味着1)更少的GC次数和2)更少的高代龄GC次数。收集高代龄的对象比收集低代龄对象的代价要昂贵的多,因为收集高代龄的对象包括该代龄以及所有低于该代龄的所有对象。你分配的对象要么是临时的对象(很快就无用了,在Gen0就被收集,收集的代价非常低),要么是属于Gen2的,非常长命的对象。对于后者来说,常见的场景是程序启动时预分配的对象--例如,在订单系统中,你会为所有的分类目录分配内存而且它们在整个应用的生命周期中都会存活。
CLRProfiler是查看GC堆非常好的工具,可以看到里面有什么对象和哪个对象保持有它的引用,使它成为活的对象。
如何组织你的数据(How to organize your data)
1) 值类型 vs 引用类型
就像你知道的那样,值类型是分配在栈上的,而引用类型是分配在GC堆上的。所以人们会问,你如何决定什么时候使用值类型,什么时候使用引用类型。在性能方面来说的话,答案通常是“看情况”。值类型不会触发GC但是如果你的值类型经常被包装(box)的话,包装操作比从头创建一个引用类型的实例要昂贵的多;另外当值类型被作为参数传递的时候,它们需要被复制。另一方面,如果你有一个类型成员不多,使它成为引用类型增加了一个指针大小的内存占用(加上引用类型本身多占的内存)。我们看到过很多内部代码,使用值类型来提高性能,同时减小工作集大小。所以这个决定依赖于你的类型的使用模式。
2) 引用丰富的对象(Reference rich objects)
如果一个对象引用了太多其他对象,它给分配和收集都增加了压力。每个内嵌的对象需要8个字节的额外空间。由于分配的代价和分配的大小成比例,分配的代价就变高了。在收集时,构建对象图时也会消耗更多的时间。
3) 可终结对象
我会单独用一节来谈论更多的细节,但是现在,我们需要知道的最重要的事情是当一个可终结对象被终结时,它所引用的所有对象都是存活的,这也增加了GC的开销。所以你需要把可终结对象和其他对象分得越开越好。
4) 对象位置
当你分配对象的子对象时,如果子对象的生存时间和父对象基本相同,他们应该被同时分配以使他们在GC堆中的位置也是相近的。
大对象(Large Objects )
当你要去生成一个85000字节(85k)的对象时,它会被分配在LOH(Large Object Heap)上。LOH段永远不会被压缩-只会被清扫(使用空闲列表)。但是这是一个编程时不应该依赖的底层实现细节-如果你分配了一个你不希望被移动的大对象,你应该自己来钉住它(而不是依赖于GC的实现)。
大对象只会在全面垃圾收集时被回收,所以回收它们是昂贵的。有时你会发现在一次全面收集之后Gen2的堆大小没有大的变化。那就意味着这次收集是由LOH触发的(你可以在性能监视器里面查看LOH的大小来判断是否如此)。
使用大对象的一个好的方法是,分配一个对象,然后重用它,这样你就不会触发更多的全面垃圾收集。如果你希望一个大对象既能保存100k的数据,又能保存120k的数据,那就先分配120k的,然后重用它。分配大量的临时大对象是一个坏主意,因为你会在全面垃圾收集上花费大量的时间。
第一部分就到此告一段落。在后面的文章里面我会介绍关于Pinning,终结(finalization),GCHandles,服务器类型的GC(Svr GC)和其他的内容。