以 Lua 为例,运行时的对象,要么存在于注册表间接引用的 table 中,要么存在于执行栈上(严格说来,注册表引用了主线程,执行栈在线程结构内)。当一个对象被一个 table 引用时,对于步进式垃圾收集,它需要一个 Barrier 来维持对象的可见性状态,这和递增引用计数的成本一致;不过对象从 table 中移除则不需要额外做递减引用计数的操作;我们可以认为在这个问题上,引用计数带来的成本仅仅是垃圾收集的两倍。但性能问题出在对象在执行栈上的操作。不光是函数调用和返回会在栈帧间传递对象的引用,任何一段代码都会在栈上反复移动对象。对于大部分静态语言,可以通过代码的静态分析,把加减引用的操作添加在必要的位置,然后再通过编译器优化,去掉不必要的操作。例如 C++ 的 RAII 机制,Objective-C 的 ARC 都是这么干的。但对于 Lua 来说,这就增加了太多的解释器的复杂度;即便生成了类似的代码,开销也无法忽略。对比 C++ ,它是通过大量 inline 函数才得以消除大部分 RAII 的冗余操作的,这在 Lua 这类动态语言中行不通。Lua 因为引用计数的额外开销问题选择了垃圾收集器。垃圾收集器并不负责内存分配释放,内存的底层管理是通过在创建 Lua 虚拟机时从外部注入的分配器完成的。虚拟机工作时所有产生的对象都被串在一个链表上组成一个集合,而被虚拟机根集间接引用的对象都会被保留,剩下的对象引用无法被根集引用,则会在恰当的时机回收。虚拟机的根集包括了注册表,以及原生类型的 metatable 。全局表、主线程、标准库的代码等等,都被注册表所引用。\
在 Lua 5.0 以前,Lua 使用的是一个非常简单的标记扫描算法。它从根集开始遍历对象,把能遍历到的对象标记为活对象;然后再遍历通过分配器分配出来的对象全集链表,把没有标记为活对象的其它对象都删除。
但是,Lua 5.0 支持 userdata ,它可以有__gc方法,当 userdata 被回收时,会调用这个方法。所以,一遍标记是不够的,不能简单的把死掉的 userdata 简单剔除,那样就无法正确的调用__gc了。所以标记流程需要分两个阶段做,第一阶段把包括 userdata 在内的死对象剔除出去,然后在死对象中找回有__gc方法的,对它们再做一次标记复活相关的对象,这样才能保证 userdata 的__gc可以正确运行。执行完__gc的 userdata 最终会在下一轮 gc 中释放(如果没有在__gc中复活)。 userdata 有一个单向标记,标记__gc方法是否有运行过,这可以保证 userdata 的__gc只会执行一次,即使在__gc中复活(重新被根集引用),也不会再次分离出来反复运行 finalizer 。也就是说,运行过 finalizer 的 userdata 就永久变成了一个没有 finalizer 的 userdata 了。\
GC 的性能表现对整个系统的性能表现影响重大。Go 语言早期就是因为 GC 问题而饱受诟病。如果我们把 GC 关闭,那么 CPU 就完全没有额外开销,但是会有极大的内存开销;如果我们每次分配新对象都运行一遍 GC ,那么就不会有任何额外的内存开销,但是 CPU 开销会完全不可接受(现在 Lua 保留着一个宏开关,可以不停的运行完整的 GC ,用来测试 GC 实现的正确性)。Lua 5.0 采用的是一个折中的方案:每当内存分配总量超过上次 GC 后的两倍,就跑一遍新的 GC 流程。但 Lua 5.0 这种会把整个虚拟机都停下来的 (Stop the World )的简单粗暴的 GC 实现,在实践中的问题非常明显,这导致 Lua 5.0 成为一个分水岭。5.0 之前的 Lua 多用于内嵌脚本,只充当系统中的底层模块间的粘合剂,而之后解决了大部分的 GC 停顿问题后,人们才逐渐让 Lua 承担更多工作。\
从 Lua 5.1 开始,Lua 实现了一个步进式垃圾收集器。这个新的垃圾收集器会在虚拟机的正常指令逻辑间交错分布运行,尽量把每步的执行时间减到合理的范围。一旦 GC 不能一次完成,它就无法把整个虚拟机看成一块静态数据加以分析。那么怎么办呢?我们就要借助。只要我们把所有对象的修改都监控起来,从垃圾收集器的角度来看,程序就只是一段段在修改它需要去回收的数据的东西,它不用管程序到底执行了什么,只要知道什么时候修改了什么。