垃圾收集器的替代方案

15

我想知道垃圾回收的最佳替代方案及其优缺点。我的优先考虑因素是速度,内存不太重要。如果有不会造成任何暂停的垃圾回收器,请告诉我。

我正在开发一种安全语言(即没有悬空指针、检查边界等),必须使用垃圾回收或其替代方案。


2
许多现代编程环境都内置了垃圾回收机制,并且它们的性能非常出色(如JVM、CLR),因此您可能需要指定您正在使用的环境。 - Daniel Earwicker
如果你正在运行一些时间关键的代码,你可以暂时关闭垃圾收集器。 - Seth
7个回答

17

我认为最好还是使用垃圾回收(像JVM一样),除非你有非常充分的理由。现代的垃圾回收器非常快速、通用且安全。除非你的语言可以利用非常特定的特殊情况(例如上述分配器之一)来设计,否则你不太可能打败JVM。

目前唯一真正引人注目的反对现代垃圾回收的理由是由GC暂停引起的延迟问题。这些问题很小、很少见,在大多数情况下都不是问题(例如我已经成功地在Java中编写了3D引擎),但在非常紧密的实时情况下仍然可能会导致问题。

尽管如此,仍可能有一些特殊情况需要采用不同的内存分配方案,因此我列出了以下一些有趣的选项:

一个非常快速、专业化的内存管理方法的例子是许多游戏中使用的“每帧”分配器。这通过递增单个指针来分配内存,在一个时间段结束时(通常是一个可视化的“帧”),所有对象一次性被丢弃,只需将指针设置回基地址并在下一个分配中覆盖它们即可。这可能是“安全”的,但对象生命周期的约束非常严格。如果你可以保证所有内存分配的大小都是有界的,并且仅在处理例如单个服务器请求的范围内有效,那么这可能是一个赢家。

另一个非常快速的方法是为不同类别的对象建立专用的对象池。释放的对象可以在池中被回收利用,使用类似于空闲对象插槽的链接列表。操作系统经常使用这种方法来处理常见的数据结构。然而你需要注意对象的生命周期并通过将对象返回到池中显式地处理处理器的拆卸。

引用计数看起来表面上很好,但通常并不实用,因为当你更改指针值时,经常需要对两个对象进行解引用和更新计数。这一成本通常比简单、快速的内存管理的优点更加糟糕,而且它也不能在存在循环引用的情况下使用。

堆栈分配非常快速并且可以安全运行。根据您所使用的语言,可能可以在不使用堆的情况下完全依赖于基于堆栈的系统。然而,我怀疑这将在一定程度上限制您的语言设计,因此可能是无法启动的。对于某些DSLs可能仍值得考虑。

经典的动态内存分配(malloc/free)相当快速,并且如果您在对象创建和生命周期上有足够的约束,则可以使其安全,这些约束可以在您的语言中强制执行。例如,如果您对指针的使用施加了重大限制。

总之 - 希望这为您提供了有用的思路!


1
尊敬的,自70年代以来,大多数引用计数方案都能够考虑循环引用。一个天真的方法无法做到这一点,这是真的。 - jer
3
@jer:这些进展将引用计数与其他技术相结合,以便检测循环引用。因此它们不仅仅是引用计数。特别地,它们迅速牺牲了引用计数可能最具吸引力的优势:简单性。 - J D
@mikera: 引用计数中的另一个问题出现在多线程上下文中:计数器增加必须是原子的!有一些技巧可以解决这个问题(例如危险指针),但它们非常复杂。 - J D
2
@Jon Harrop:好吧,公平地说,检测循环并不是什么高深的科学。 - jer
@Jon Harrop:好吧,我应该在前面加上单线程的 :) 不,线程会引入很多复杂性。 - jer
1
@jer:没错,我认为在我们多核时代,任何有用的解决方案都需要处理线程。事实上,当听说Golang的程序员们正在尝试使用引用计数构建垃圾收集器时,我感到非常惊讶。 - J D

5
如果速度很重要但内存不是,那么最快且最简单的分配策略就是永远不释放。分配只涉及将指针向上移动。这是最快的方式。
当然,永远不释放任何东西会导致可用内存溢出的巨大潜力。内存很少真正“不重要”。通常有大量但有限的可用内存。一种策略称为“区域分配”。即您使用指针增量策略在几个名为“区域”的大块中分配内存。释放仅通过整个区域进行。如果所处理的问题可以结构化为连续的“任务”,每个任务都有自己的区域,则可以成功地应用此策略。
对于更通用的解决方案,如果您想要实时分配(即保证从分配请求到响应时间的限制),则垃圾回收是正确的方法。实时GC可能如下:使用指针增量策略分配对象。此外,在每次分配时,分配器执行一些垃圾回收,其中“活”对象被复制到其他位置。在某种程度上,GC与应用程序“同时”运行。这意味着访问对象需要额外的工作,因为您无法将对象移动并更新所有指向新对象位置的指针,同时保持“实时”承诺。解决方案可能涉及屏障,例如额外的间接寻址。在保持暂停时间严格限制的同时,分代GC允许无屏障访问大多数对象。
对于希望研究内存分配(特别是垃圾回收)的人来说,本文是必读的。

1
如果速度很重要,但内存不是问题,那么最快且最简单的分配策略就是永远不释放。分配只涉及将指针移到上一个位置。你不能比这更快了。虽然您忽略了缓存,但它们可以使重用速度提高10倍。 - J D

1

使用C++,可以为对象进行一次堆分配,然后重复使用该内存以供后续对象使用。我见过它的工作效果,非常快速。

这仅适用于某些特定问题,并且很难正确实现,但确实是可能的。

C++的乐趣之一在于您完全控制内存管理,可以决定使用经典的new/delete,或者实现自己的引用计数或垃圾回收。

然而,这里有风险——你真的需要知道你在做什么。


你可以通过在.NET上使用结构体数组来很大程度地做到这一点。 - J D
1
龙?不,亲爱的朋友——这里是R'hleh最强大的野兽。但是,除此之外,这是一个很棒的解决方案。 - yatsek

0

如果内存不重要,那么@Thomas所说的就适用了。考虑到现代硬件的巨大内存空间,这可能是一个可行的选择——它实际上取决于进程。

手动内存管理不一定会直接解决您的问题,但它确实可以让您完全控制内存事件发生的时间。例如,通用的malloc操作并不是O(1)操作。它在其中做了所有可能的可怕事情,既在由malloc本身管理的堆内,也在操作系统内部。例如,你永远不知道“malloc(10)”何时会导致VM将某些内容分页出去,现在你的10字节RAM有一个未知的磁盘I/O组件——哎呀!更糟糕的是,这个页面可能是你的内存,你需要立即将它分页回来!现在c=*p是一个磁盘命中。耶!

但是,如果您意识到了这些问题,那么您就可以安全地设置自己的代码,以便所有时间关键的部分实际上不进行内存管理,而是使用预先分配的结构来完成任务。

使用GC系统,您可能会有类似的选项——这取决于收集器。例如,我认为Sun JVM没有短时间内“关闭”的能力。但是,如果您使用预分配的结构,并调用自己的所有代码(或者知道您调用的库例程中发生了什么),那么您很可能不会遇到内存管理问题。

因为,问题的关键在于内存管理是一项艰巨的工作。如果您想摆脱内存管理,可以使用旧式的FORTRAN和ARRAYs以及COMMON块进行编写(这也是FORTRAN之所以如此快的原因之一)。当然,您可以在大多数任何语言中编写“FORTRAN”。

对于现代语言、现代GC等,内存管理已经被推到了一边,成为了一个“10%”的问题。我们现在非常随意地创建垃圾、复制内存等等,因为GC等使我们变得更加随意。对于90%的程序来说,这不是问题,所以我们不必担心。现在,这是一个后期调整的问题。

因此,你最好一次性将所有东西都设置好,使用它,然后将所有东西丢弃。 "使用它" 部分是你将获得一致、可靠的结果的地方(当然,假设系统有足够的内存)。


"GC等使我们变得懒散变得容易。GC不仅快而且方便,例如使用分代GC分配临时内存比使用'malloc'更快。" - J D

0

作为垃圾回收的“替代方案”,C++ 特别提供了智能指针。boost::shared_ptr<>(或 std::tr1::shared_ptr<>)与 Python 的引用计数垃圾回收完全相同。在我看来,shared_ptr 就是垃圾回收。(尽管您可能需要执行一些 weak_ptr<> 操作以确保不会发生循环引用)

我认为 auto_ptr<>(或在 C++0x 中,unique_ptr<>...)是一个可行的替代方案,具有自己的一套优点和权衡。Auto_ptr 语法笨拙,不能用于 STL 容器...但它可以完成工作。在编译时,您将指针的所有权从变量移动到变量。如果一个变量在其作用域结束时拥有指针,则会调用其析构函数并释放内存。只允许一个 auto_ptr<>(或 unique_ptr<>)拥有真正的指针。(至少,如果您使用正确的话)。

作为另一种选择,您可以将所有内容存储在堆栈上,并仅传递引用到您需要的所有函数中。

这些替代方案并不能真正解决垃圾回收所解决的一般内存管理问题。尽管如此,它们是高效和经过充分测试的。auto_ptr不会使用比指针最初使用的空间更多的空间...并且在dereferencing auto_ptr时没有开销。 "移动"(或在Auto_ptr中赋值)具有少量开销以跟踪所有者。我没有做过任何基准测试,但我相当确定它们比垃圾回收/ shared_ptr更快。


请注意,当析构函数级联时,shared_ptr 会产生任意长的暂停。 - J D
1
@Jon Harrop 一个缓慢的析构函数会影响你的性能,无论是使用shared_ptr还是不使用。我的意思是,我可以指出在堆栈上声明变量会导致返回或引发异常时任意长的暂停...这个问题并不仅限于shared_ptr。 - Dragontamer5788
在堆栈上声明变量会导致返回或引发异常并离开函数时出现任意长的暂停,我不明白为什么会这样。返回只是重置堆栈指针,这是一个常数时间操作。抛出异常会长跳到下一个处理程序,这也是一个常数时间操作。 - J D
1
@JonHarrop:在C++中,离开函数会调用所有析构函数。这就是为什么C++不需要“finally”语句的原因,因为在离开作用域时保证调用析构函数。 - Dragontamer5788
我同意你的说法,但我的观点是关于一个单一析构函数花费任意长时间,而不是有许多常数时间的析构函数。为了获得这样的暂停,您的析构函数需要调用其他析构函数,因此如果您回收整个树,则可以仅使用auto_ptr获得类似的效果。 - J D

0

如果您真的想完全没有暂停,除了堆栈分配、基于区域的缓冲区和静态分配之外,禁止所有内存分配。尽管您可能听说过的是malloc()不会导致严重的暂停,但如果空闲列表变得碎片化,它实际上会导致严重的暂停;如果您经常发现自己构建大型对象图,则天真的手动释放将输给停止和复制;真正避免这种情况的唯一方法是分摊预分配页面,例如堆栈或一次性释放的撞球分配池。我不知道这有多有用,但我知道专有的图形编程语言LabVIEW默认为每个子程序等效项分配一个静态内存区域,需要程序员手动启用堆栈分配;这是在需要对内存使用量进行绝对保证的硬实时环境中非常有用的。

如果您想要让暂停易于“推理”,并让开发人员控制分配和放置,那么已经有一种名为Rust的语言具有与您的语言相同的目标;虽然它不是完全安全的语言,但它确实有一个安全子集,允许您为原始位操作创建安全抽象。它使用指针类型注释来消除使用后释放错误。在安全代码中,它也没有空指针,因为空指针至少会花费十亿美元
如果有界暂停足够了,那么有各种算法可以使用。如果您的工作集与可用内存相比真的很小,那么我建议使用MOS收集器(也称为Train算法),它可以逐步收集,并且可以证明总是朝着释放未引用对象的方向取得进展。

-1

有一个普遍的谬论,认为托管语言不适合高性能低延迟场景。是的,在资源有限(如嵌入式平台)和编程不规范的情况下,你可能会像使用C++一样惨痛地自毁(而且这可能非常非常壮观)。

在Java/C#开发游戏时遇到了这个问题,解决方案是利用内存池并且不让对象死亡,因此在你不希望它运行时不需要垃圾回收器。这实际上与低延迟的非托管系统采用的方法相同 - 努力尽量不分配内存。

因此,考虑到在Java/C#中实现这样的系统与C++非常相似,以“女人般软弱”的方式(托管方式)进行操作的优势在于,你可以享受其他语言特性的“美好”,从而释放出更多的精力集中于重要事情上。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接