引用计数与垃圾收集之比较
本质上来说,引用计数策略和垃圾收集策略都属于资源的自动化管理。所谓自动化管理,就是在逻辑层不知道资源在什么时候被释放掉,而依赖底层库来维持资源的生命期。
而手工管理,则是可以准确的知道资源的生命期,在准确的位置回收它。在 C++ 中,体现在析构函数中写明 delete 用到的资源,并由编译器自动生成的代码析构基类和成员变量。
所以,为 C++ 写一个垃圾收集器,并不和手工管理资源冲突。自动化管理几乎在所有有点规模的 C++ 工程中都在使用,只不过用的是引用计数的策略而非垃圾收集而已。也就是说,我们使用 C++ 或 C 长期以来就是结合了手工管理和自动管理在构建系统了。无论用引用计数,还是用垃圾收集,软件实现的细节上,该手工管理的地方我们依旧可以手工管理。
为什么要用资源生命期自动管理?
让我们来看面向对象,如果一切皆对象,每个对象的生命期就应该由自己负责,我们是可以直接准确的死亡时间的。可惜,有很多东西不是纯粹的对象。最重要的一个就是对象容器。它们除了自身的属性,还保持了对一组同类对象的引用。
一个对象可以分别被几个容器引用,这使得容器区别于猫猫狗狗这些对象实体。因为容器引用一个东西不等于这个东西是这个容器的一部分(有时候可以,有时候不行)。当我们把希望整个世界分成一个个对象时,所有的原子被分到各层的对象上后,就会发现有零零总总的概念无法用对象提取。引用而非拥有,这是无法回避的。
面向对象的本质在于,对许多对象提取出共性放在一起处理。这样,各式容器的使用就是无可避免的了。
也正是如此,对象自己并不知道自己是否已经可以宣告死亡。除非了解自己和别的对象的联系(这种关系不是对象)。资源可以是对象,而自动化管理正是管理的这些对象和对象之间的关系。
引用计数就是最容易实现的一种方案:记录对象被引用的次数,而不具体记录是谁引用了它。这样,降低了建立和解除引用的代价。但是,有得必有失。在引用计数的过程中,我们也丢失了重要的信息:到底是谁引用了自己。所以,引用计数在处理间接引用的问题上代价增加。
对象死亡的判定是:对象和这个世界还有没有联系,无论是直接的还是间接的。所以,一个对象即使还有另外的对象直接引用它,它也可能已经脱离了世界。为了解决这个问题,使用引用计数的系统,必须在对象和世界脱离联系时,通知和它有关联的对象。对象的销毁代价增加,就是引用计数策略的短板。
对象的销毁频率,取决于对象的平均生存时间。而对象的生存时间,一方面受对象粒度的影响,往往对象粒度越细,对象平均生存时间越短(虽然表面上没有直接联系,但是实际设计时往往会导致这个结果);另一方面,我们往往会把容器和引用关系也实现成一种对象(概念上本不应该是对象)。比如说许多自动维持引用计数的智能指针就是一个小容器,里面保持了对一个对象唯一的引用,它就被实现成一个小对象。
通常,对象本身的性质并不随自己在内存空间中的位置改变而改变。但是引用关系(通常用指针来实现)却和内存地址相关。C++ 缺乏一种对象在内存中移动的语义表达,等价物是,在新的内存块中拷贝构造一个新对象,并销毁原有的。
另一方面,程序的运行序中,函数调用造成的堆栈上的嵌套作用域也可以看成一个个容器,机器指令穿行于这些作用域间,临时构造出的对对象的引用(智能指针),就被放置于这些作用域内。函数调用越频繁,这些作用域的创建和销毁也就越频繁。
这些导致了 C++ 必须依赖大量的 inline 函数,让编译器了解更多的上下文信息,方能减轻小对象(智能指针)创建销毁的负担。 STL 库也必须为其做一些优化,例如 stl port 中,对 POD 类型就做了特例化处理。可惜,智能指针不是 POD ,让编译器聪明到合并执行序列中的引用加减,难度太大(考虑到多线程因素,除非编译器可以知道线程的信息,否则几乎不可能实现)。
C++ 在实现面向对象的编程上,比 C 提供了许多便利。其中之一就是,在描述一个对象是另一个对象的一部分时,通过构造和析构函数机制,可以自动化的维护这相关部分的生命期。但它没能在语言上解决的是,当两者之间只是引用关系时,生命期如何处理。前者,我们有几乎唯一的简洁明了的解决之道;而后者根据实际需要可以有多种选择,顾而 C++ 在语言层面不提供一致解决方案。可惜的是 C++ 却一直每能提供一个简洁好用,带有普适性的 GC 库。大家都偏向于更为容易实现的引用计数的方案,这个结果跟具体实现的复杂度有关。毕竟在实现 gc 的时候,C 缺乏必要的语言支持(而 C++ 在实现层面,是从 C 的基础上发展而来)。
再来看看垃圾收集,比较成熟的算法基于标记清除(或标记整理)或其变体。简单说,就是由收集器框架记录下对象和对象之间的联系(这些联系信息存放的位置不重要,可以在对象的内存布局空间上,也可以在独立的地方,关键在于这些信息可以被收集器访问)。确定一个世界的根,定期的从这个根开始遍历这个世界,把有关联的对象标记起来,最后回收没有被标记的对象。
从算法上来看,建立对象和对象之间的联系的时间代价和引用计数的时间代价数量级上是一致的,都是 O(1) 。但实际实现时,前者的代价通常要大一些。空间代价上也是前者略大,但也没有数量级上的差别。
而 GC 管理的对象,在销毁时的代价要小的多。它不需要通知和它有关联的对象。
这就是为什么,许多使用 GC 的软件有时候比使用引用计数的软件运行效率还高那么一点的缘故。
可是,GC 有一个额外的时间代价来源于标记的过程。完成完整的一次清理过程,必然遍历到世界中每一个活着的对象。代价是 O(N) ,N 随着对象总体数量的增加而增加。所以我们应该减少被 GC 管理的对象的数量,在这一点上,手工管理依然有意义。即,明确一个对象是另一个对象的组成部分时,可以考虑用手工管理的方式。
另一个糟糕的地方是,在实现时,我们往往把对象间的关联信息放在了对象本身的内存布局空间中,遍历这个世界中的对象意味着访问所有对象的内存。当虚拟内存空间大于实际物理内存空间时,这意味着页面交换。我觉得,很大程度上,java 或 C# 这样的语言搭建起来的庞大系统偶尔运行缓慢,根本原因就在这里。当然,这些是可以被改进的。并非算法本身的问题。
可以这样说,GC (garbage collection) 把 RC (reference counting) 中那些短期对象的销毁代价转嫁到了一次性的标记清除过程。这把逻辑处理和资源管理正交分解了。这种被分解的问题,会随着硬件的进步更容易提高性能(比如多核的发展)。但是,在较小规模的软件或独立模块中,这个优势并不会太明显。反而 GC 本身远高于 RC 的复杂性,会成为其软肋。
对于不需要面向对象的软件,甚至连资源自动化管理都不需要。这时,无论是 GC 还是 RC 都无用武之地。
我做的那个简陋的垃圾收集器,也只是想做些简单的尝试,为 C 或 C++ 语言构建软件时多一些选择。
Comments
Posted by: 塞外浪子 | (18) August 22, 2010 12:32 AM
Posted by: starshine | (17) August 20, 2010 04:51 PM
Posted by: sjinny | (16) June 17, 2008 01:02 PM
Posted by: Anonymous | (15) June 17, 2008 10:08 AM
Posted by: analyst | (14) June 16, 2008 11:06 PM
Posted by: Cloud | (13) June 16, 2008 06:41 PM
Posted by: analyst | (12) June 16, 2008 05:45 PM
Posted by: big | (11) June 16, 2008 04:43 PM
Posted by: Xiaofeng | (10) June 15, 2008 11:13 PM
Posted by: sjinny | (9) June 15, 2008 10:46 PM
Posted by: Xiaofeng | (8) June 15, 2008 10:39 PM
Posted by: Cloud | (7) June 15, 2008 02:00 PM
Posted by: sjinny | (6) June 15, 2008 12:45 PM
Posted by: nothanks | (5) June 15, 2008 12:42 PM
Posted by: Anonymous | (4) June 15, 2008 11:56 AM
Posted by: Cloud | (3) June 15, 2008 12:03 AM
Posted by: sjinny | (2) June 14, 2008 10:52 PM
Posted by: 魏献华 | (1) June 14, 2008 09:03 PM