首先,一个术语:“垃圾收集”对不同的人意味着不同的东西,并且一些 GC 方案比其他方案更复杂。有些人认为引用计数是 GC 的一种形式,但我个人认为“真正的 GC”不同于引用计数。
使用 refcounts,有一个整数跟踪引用的数量,当 refcount 达到零时,您可以立即触发解除分配。这就是 CPython 实现的工作原理,以及大多数 C++ 智能指针的工作原理。CPython 实现添加了一个标记/清除 GC 作为备份,因此它非常类似于您描述的混合设计。
但是引用计数实际上是一个非常糟糕的解决方案,因为每次传递引用时都会产生(相对)昂贵的内存写入(加上内存屏障和/或锁,以确保线程安全),这种情况经常发生。在像 C++ 这样的命令式语言中,可以(只是很难)通过宏和编码约定来管理内存所有权,但在像 Lisp 这样的函数式语言中,这几乎是不可能的,因为内存分配通常是由于闭包中的局部变量捕获而隐式发生的。
因此,为 Lisp 发明了迈向现代 GC 的第一步也就不足为奇了。它被称为“双空间分配器”或“双空间收集器”,它的工作方式与听起来完全一样:它将可分配内存(“堆”)分成两个空间。每个新对象都从第一个空间分配,直到它太满,此时分配将停止,运行时将遍历引用图并仅将活动(仍被引用)对象复制到第二个空间。在活动对象被复制后,第一个空间将被标记为空,并且分配将继续,从第二个空间分配新对象,直到它太满,此时活动对象将被复制回第一个空间和过程将重新开始。
双空间收集器的优点是,它不会O(N)
工作,其中N是垃圾对象的总数,它只会O(M)
工作,其中M是非垃圾对象的数量 。由于在实践中,大多数对象在短时间内被分配然后释放,这可以带来显着的性能改进。
此外,双空间收集器还可以简化分配器端。大多数malloc()
实现都维护所谓的“空闲列表”:哪些块仍然可以分配的列表。要分配新对象,malloc()
必须扫描空闲列表以寻找足够大的空白空间。但是双空间分配器并没有为此烦恼:它只是像堆栈一样在每个空间中分配对象,只需将指针向上推所需的字节数即可。
所以 twospace 收集器比 . 快得多malloc()
,这很棒,因为 Lisp 程序会比 C 程序做更多的分配。或者,换句话说,Lisp 程序需要一种像堆栈一样分配内存的方法,但它的生命周期不限于执行堆栈——换句话说,一个堆栈可以无限增长而不会耗尽程序的内存. 而且,事实上,Raymond Chen 认为这正是人们应该如何看待 GC。我强烈推荐他的系列博客文章,以“每个人都以错误的方式思考垃圾收集”开头。
但是 twospace 收集器有一个重大缺陷,那就是没有程序可以使用超过一半的可用 RAM:另一半总是被浪费掉。所以 GC 技术的历史就是尝试改进双空间收集器的历史,通常是通过使用程序行为的启发式方法。然而,GC 算法不可避免地涉及权衡,通常更喜欢分批释放对象而不是单独释放对象,这不可避免地会导致对象没有立即释放的延迟。
编辑:为了回答您的后续问题,现代 GC 通常包含分代垃圾回收的概念,其中对象根据生命周期被分组为不同的“代”,并且一个代中的对象一旦存在就会被“提升”到另一代够长了。有时,对象生存期的微小差异(例如,在请求驱动的服务器中,将对象存储的时间超过一个请求)可能会导致对象被释放之前所需的时间量有很大差异,因为它会导致它变为更“终身”。
malloc()
您正确地观察到真正的 GC 必须在and的级别“之下”运行free()
。(作为旁注,值得学习如何实现malloc()
和free()
实现——它们也不是魔法!)此外,对于有效的 GC,您要么需要保守(如 Boehm GC)并且永远不要移动对象,并检查可能是指针的东西,或者您需要某种“不透明指针”类型——Java 和 C# 称之为“引用”。不透明指针实际上非常适合分配系统,因为这意味着您始终可以通过更新指向对象的指针来移动对象。在像 C 这样直接与原始内存地址交互的语言中,移动对象从来都不是真正安全的。
GC 算法有多种选择。标准的 Java 运行时包含不少于五个收集器(Young、Serial、old CMS、new CMS 和 G1,尽管我想我忘记了一个)并且每个都有一组可配置的选项。
但是,GC 并不神奇。大多数 GC 只是利用批处理工作的时间-空间权衡,这意味着速度的提高通常是通过增加内存使用来支付的(与手动内存管理或重新计数相比)。但是,提高程序性能和提高程序员性能的结合,与如今 RAM 的低成本相比,通常值得权衡。
希望这有助于使事情更清楚!