一直以来我很少讨论这样的主题,今天我也不打算长篇累牍的介绍这个细节,但是我还是认为其中有一些重要的细节是值得每个程序员看一下的。
为啥要看一下?
作者和他的同时经常被问及关于GC如何工作的问题。但是,这些内部细节对程序员而言并不是必知的。一个程序员不应该关心FrameWork背后的细节,啥啥啥的。。。这不是自寻烦恼么?
如果你是个公交车司机,你绝对不用知道怎样去制造它的引擎。会踩刹车和油门的基本动作基本上就够了。但是如果你是个F1赛车手,那你就会想对你的赛车有足够的了解。所以你得首先明白你的工作需要,如果你仅仅是对web程序或者WinForm做些小的修改和添加,那你就不需要知道的太多(知道太多的结果,你知道的)。如果你想做些更底层的工作,那我倒是建议你可以看看这篇文章。
托管堆的纠结的前世今生
为了效率,托管堆会试着去找出一些对象设置为“重要的”,这些“重要的”被定义为不需要经常去检查的对象,就像被存放在资料室里的老档案。因为有些对象很有可能伴随着整个程序的运行周期而存在,而有些用完了即可以释放。所以一个对象存在的时间越长,那GC检查它的周期将会越长。这个算法的实现思想是将托管堆分为三代,即零,一,二代。一个对象创建的时候会被放入0代内存,随着生存周期的越来越长,它将被逐渐从0代移动到2代。举个这样的例子:
- 经理要求你打电话给一个顾客。你对数字的记忆力超群,所以你就一直把电话号码记在脑子里(0代内存)。
- 第一次打过去,没人接电话。所以你得过一会儿再试着打过去。这是经理又给你另外一个顾客的电话。
- 你的工作经验告诉你能够记住的电话号码个数最多为10个,所以一旦经理给了你一个新的电话号码,而你记忆中的号码个数刚好到10个的时候,你就得把一个电话写在纸上(1代内存)。
- 一天下来不断的有电话号码被写到纸上。
- 你把电话写满了整整一张纸,纸上的号码已经凌乱不堪。所以你得把这些号码分下类,看看哪些是还可以再尝试几次的。你必须抛弃一些号码,并将剩下的保留到你的号码本上(2代内存)。
前面说了,当一个对象被创建的时候就被放入了0代内存。0代对象的初始容量决定于处理器的高速缓存大小。而后这个大小根据程序创建对象的频率会动态更改。一旦0代内存达到了容量的上限,GC首先检查0代中的每一个对象,并给一些不再使用的对象贴上“垃圾”的标签,并将它们移出0代。这是个清扫阶段。这次清扫中存留下来的对象将被移入1代内存。和0代一样,1代的容量也是根据程序创建对象的频率动态更改的。而一旦1代容量达到上限,GC又会清扫并且移动剩下的对象到2代,然后腾出的空位再清扫并移动0代内存。
一种良好的GC分配比例是100(0代):10(1代):1(2代),正常情况下0代做一次垃圾回收话费的时间是毫秒级的。1代的GC代价为30毫秒左右。2代的GC只在程序空闲的时候执行。
大对象托管堆
GC中一个很重要的概念叫“大对象托管堆”(LOH)。这里被存放所有大于85Kb的对象。LOH在每次“需要申请新的内存段的时候”(下一段会介绍)被回收。LOH和0,1,2代内存回收机制不同,LOH回收行为会触发0,1,2代内存的回收。
问题是我经常创建一些十分复杂的类,这些类看上去应该都超过了85Kb的大小了,它们都是LOH么?不一定。举例来说一个数据集(Dataset)一般包含了非常多的数据,但是数据集对象在内存中都仅仅只是保存数据的引用。你认为一个指针的数组有可能超过85Kb么?
内存段
托管堆一般被分成几个段。每一段的大小根据系统配置文件来决定。如果你的内存段大小将会达到64MB,否则的话是32MB。而LOH所在内存段为16M。只有2代内存和LOH会占用几个段的内存。
GC是怎么工作的?
以下是GC的工作流程:
- GC检查LOH上每个对象的引用计数。如果为0,这个对象将被标记。
- 所有LOH上被标记的对象都被释放。
- LOH不会做内存碎片整理。
- 扫描并标记2代内存上的对象。
- 释放2代被标记的对象。
- 对2代内存做碎片整理。
- 扫描并标记1代内存上的对象。
- 释放1代被标记的对象
- 对1代内存做碎片扫描。
- 更改2代内存的结束地址,将1代留下的对象放入2代中。
- 扫描并标记0代内存。
- 释放0代内存中被标记的对象。
- 对0代内存做碎片整理。
- 更改1代内存结束地址,将0代留下的对象放入1代中。
一些小的建议
这个主题可以很大,但是这里说些比较重要的
尽量不要把对象塞进1代内存
虽然1代内存看上去比2代内存性能要好,但是请记住2代内存设计目的是,仅仅存放那些生存周期贯穿整个程序运行时的对象。而0代内存的作用就是存放那些中间过程产生的对象。换句话说程序设计必须中间过程的对象生存周期尽量短,以至于除了必要的2代对象之外,其他的对象能够在0代内存执行GC前尽量释放。
不要调用GC.Collect()
这点重申一百遍都不为过!你过去,现在以及将来都不应该调用到GC.Collect()方法。只有一个情况下你可以使用它:测试!GC被设计成为自组织的智能机器。如果你试着去破坏它的平衡性,你就该想想那些腐败的政府,当众抠鼻孔。。。任何你想得到的恶心的场景。
避免大对象
如果你的程序不需要超过85kb的类,那就请尽量不要创建出大于85kb的类。如果真的需要,你应该认真的考虑如何提高这些对象的再利用率。
不要使用终结器(finalizers)
当你的对象有终结器的话它的终结函数(finalize)应该被手动调用,之后对象就会消亡。看起来这很自然而优雅。但是很不幸的是真实情况是你的对象只会被移动到下一代内存当中,因为他并没有准备好被回收。这意味着所有实现了终结器的对象真正消亡的时间最早也是在1代内存中了,并且很有可能是在2代内存。
关于GC,其实还有很多没说明白。不过你可以去别的博客上转转,看看其他N人是怎么说的。
本文完。