垃圾收集器如何避免堆栈溢出?


23

因此,我在考虑垃圾收集器的工作方式,并想到了一个有趣的问题。大概垃圾收集器必须以相同的方式遍历所有结构。他们不知道正在遍历链表或平衡树之类的天气。他们也不会在搜索中消耗过多的内存。我想到遍历所有结构的一种可能方法,也是唯一的方法,可能就是像使用二叉树那样递归遍历所有结构。但是,这会在链表甚至平衡差的二叉树上产生堆栈溢出。但是我曾经使用过的所有垃圾收集语言似乎都没有问题的处理能力。

在龙书中,它使用各种“未扫描”队列。基本上,而不是递归遍历结构,它只是将需要标记的内容添加到队列中,然后针对未标记的所有内容将其删除。但是这个队列不会很大吗?

那么,垃圾收集器如何遍历任意结构?这种遍历技术如何避免溢出?


1
GC几乎以相同的方式遍历所有结构,但只是非常抽象的意义(请参阅答案)。他们具体地跟踪事物的方式比您在教科书中可以找到的基本演示所表明的要复杂得多。而且他们不使用递归。此外,对于虚拟内存,错误的实现方式表现为GC速度降低,很少出现内存溢出的情况。
babou 2015年

您担心跟踪所需的空间。但是,将已追踪并已知正在使用的内存与可能可回收的内存区分开所需的空间或结构又如何呢?这可能会占用大量内存,可能与堆大小成比例。
babou 2015年

我认为可以使用大于16个字节左右的对象大小的位向量来完成,因此总开销至少要少1000倍。
2015年

有很多方法可以做到这一点(请参阅答案),它们也可以用于跟踪,然后可以回答您的问题(位向量或位图可以用于跟踪,而不是您建议的堆栈或队列)。您不能假设所有对象都是大对象,除非打算在小对象上浪费空间,而小对象可能有很多,那么您就不必担心空间了。如果您位于虚拟内存中,那么空间问题通常就不那么多了,问题也大不相同。
2015年

Answers:


13

请注意,我不是垃圾收集专家。该答案仅提供技术示例。我不认为这是垃圾收集技术的代表概述。

不扫描的队列是常见的选择。队列可能会变大,甚至可能和最深的数据结构一样大。队列通常是显式存储的,而不是存储在垃圾回收线程的堆栈上。

一旦扫描完一个节点的所有子节点(一个节点除外),就可以将该节点从未扫描的队列中删除。这基本上是一个尾注优化。垃圾收集器可以包括试探法,以尝试最后扫描节点的最深子级。例如用于Lisp的一个GC应该扫描carconscdr

避免保持未扫描队列的一种方法是在适当位置修改指针,使子项临时指向父项。这是一种恒定内存树遍历技术,用于垃圾回收器以外的上下文中。该技术的缺点是,当GC遍历数据结构时,该数据结构无效,因此GC必须停止运行。这不是一个破坏交易的事情:许多垃圾收集器的确包含一个使世界停止运转的阶段,除了那些不会但可能会错过垃圾的阶段。


2
最后一段中描述的技术通常称为“ 指针反转 ”。
Wandering Logic

@WanderingLogic是的,指针反转是我自己回答的方式。这是由于Deutsch,Schorr和Waite(1967)提出的。然而,这是不正确的状态,它的作品在不断的记忆:它确实需要额外的比特与每个细胞p指针,虽然这可以通过使用位栈减少。出于相同的原因,您引用的接受答案也不完全正确或完整。log2pp
babou 2015年

已经使用指针逆转定制GC而不需要这些额外的比特; 诀窍是使用内存中对象的特殊内存表示形式。即,对象“标题”是在中间,与指针字段之前在header是在header之后是非指针字段。此外,所有指针都对齐,并且标头包含一个始终设置最低有效位的字段。因此,在指针反转回溯过程中,可以毫无疑问地完成到达下一个指针并注意到我们已经完成了一个对象的操作。此布局还支持OOP继承。
Thomas Pornin

@ThomasPornin我认为位信息必须在某个地方。问题在哪里?我们可以在聊天中讨论吗?我现在必须离开,但我想深入了解这一点。还是在网上可以找到描述?
babou 2015年

1
@babou和托马斯
吉尔斯'所以

11

简而言之垃圾收集器不使用递归。它们仅通过跟踪本质上两组(可以合并)来控制跟踪。跟踪和单元处理的顺序无关紧要,这为表示集合提供了很大的实现自由。因此,有许多解决方案实际上在内存使用上非常便宜。这是必不可少的,因为在堆内存用完时会精确调用GC。大型虚拟内存的情况有所不同,因为可以轻松分配新页面,而且麻烦不是缺少空间,而是缺少数据 局部性

我假设您正在考虑跟踪垃圾收集器,而不是您的问题似乎不适用的引用计数

问题集中在跟踪用于跟踪集合的内存成本:可访问存储单元的集合(对于未跟踪),仍包含尚未跟踪的指针。这仅 是垃圾回收的内存问题的一半。GC还必须跟踪另一个集合:已发现所有可访问单元的集合V(用于访问),以便在过程结束时回收所有其他单元。讨论一个而不是另一个的意义有限,因为它们可能具有相似的成本,使用相似的解决方案,甚至可以合并使用。üV

首先要注意的是,所有跟踪GC都遵循相同的抽象模型,这是基于对程序可访问内存中单元格的有向图的系统研究,其中内存单元为顶点,指针为有向边。为此,它使用以下设置:

  • 已经发现可被增变器访问的单元格的集合(已访问),即执行GC的程序或算法。集合V分为两个不相交的子集: V =VV ;V=UT

  • 尚未访问指针的已访问单元的集合(无赛道);U

  • 跟踪了所有指针的被访问单元的集合(已跟踪)。T

  • 我们还注意到 H是否为堆中所有单元的集合。

UUVUUŤ需要以某种方式表示,该算法才能起作用。

该算法从运行时系统已知的一些根指针开始(通常是堆栈分配的内存中的指针),并将它们指向的所有单元格放入未跟踪的集合U(因此在V也是如此)。

然后收集器将单元格一一取出,并检查每个单元格c的所有指针。对于每个指针,如果指向的单元格位于V中,则不执行任何操作,否则将指向的单元格添加到U,因为尚未检查其指针。处理完所有指针后,单元格c从未跟踪集U转移到跟踪集TUcVUcUT

为空时,跟踪终止。这肯定会发生,因为没有任何一个单元会多次通过U。到那时,V = T,并且已知V中的所有单元都是程序可访问的,因此不可回收。V的补H - VUUV=TVHVV在堆确定哪些细胞是由增变程序不可访问,并且可以通过收集器,用于将来分配给增变被回收。

当然,细节取决于集合的实现方式以及是U还是UVUUT有效表示的

我还将跳过有关什么是单元格的详细信息,无论它们是一种还是多种,我们如何在其中找到指针,如何压缩它们,以及许多其他技术问题,您可以在有关垃圾收集的书籍和调查中找到这些问题。 。

您可能已经注意到,这是一个非常简单的算法。没有递归,只有集合U的元素上有一个循环U可以随着处理而增长,直到最终清空为止。没有关于额外内存的先验假设。 允许识别集合并以足够便宜的方式执行所需操作的方法。请注意,处理单元的顺序无关紧要(对下推堆栈没有特殊要求),这为选择有效表示集合的方式提供了很大的自由度。

这些集合的实际表示方式与已知实现方式不同。实际上已经使用了许多技术:

  • 位图:为一个映射保留了一些存储空间,每个存储单元都有一个位,可使用该单元的地址找到该位。当对应的单元格位于映射定义的集合中时,该位打开。如果仅使用位图,则每个单元仅需要2位。

  • 或者,您可以在每个单元格中留出一个特殊的标记位(或2)以对其进行标记。

  • list:列出列表中的那些单元格。您不需要堆栈或特定的数据结构。在某些系统中,精明的指针反转技术允许使用很少的额外内存来构建列表,精确地位,其中p是每个单元格的指针数量,可通过位堆栈进一步减少。log2pp

  • 您可以测试有关单元格内容及其指针的谓词。

  • 您可以将单元格重新定位在内存的空闲部分中,该部分仅用于属于所表示集合的所有单元格。

  • VTTU

  • 您实际上可以结合使用这些技术,即使是单个集合。

如前所述,以上所有内容已由一些实现的垃圾收集器使用,有些人可能觉得很奇怪。这完全取决于实施的各种约束。而且它们的内存使用可能相当便宜,这可能得益于可以为此目的自由选择的处理订单策略,因为它们对最终结果无关紧要。

在新区域中传输单元似乎是最奇怪的一种,实际上是非常普遍的:这称为复制收集。它主要用于虚拟内存。

显然,没有递归,并且不必使用mutator算法堆栈。

另一个重要的一点是,许多现代GC是针对大型虚拟内存实现的。然后,由于可以轻松分配新页面,因此无需占用空间来实现和额外的列表或堆栈。但是,在大型的虚拟记忆中,敌人不是缺乏空间,而是缺乏局部性。然后,代表集合的结构及其使用必须适应保留数据结构和GC执行的局部性。问题不是空间而是时间。与内存溢出相比,不充分的实现更有可能显示出不可接受的速度下降。

由于这些技术的足够长的时间,我没有提及由这些技术的各种组合产生的许多特定算法。


4

避免堆栈溢出的标准方法是使用显式堆栈(作为数据结构存储在堆中)。这也适用于这些目的。垃圾收集者通常具有需要检查/遍历的项目工作清单,该清单起到了这一作用。例如,“未扫描”队列就是这种模式的一个示例。该队列可能会变大,但不会导致堆栈溢出,因为它没有存储在堆栈段中。无论如何,它永远不会超过堆中活动对象的数量。


调用GC时,堆通常已满。另一点是,它发生堆栈和从相同的内存空间的两端的堆成长..
babou

4

在垃圾收集的“经典”描述中(例如,马克·威尔逊(Mark Wilson),“ 单处理器垃圾收集技术 ” ,1992年国际内存管理研讨会替代链接)或安德鲁·阿佩尔(Andrew Appel)的《现代编译器实现》(剑桥大学出版社, 1998)),收集者分为“标记和扫描”或“复制”。

如@Gilles的答案所述,Mark and Sweep收集器通过使用指针反转避免了多余的空间。Appel说,Knuth将指针反转算法归因于Peter Deutsch,Herbert Schorr和WM Waite。

复制垃圾收集器使用通常称为Cheyney的算法来执行队列遍历,而无需额外的空间。该算法在Comm。的 CJ Cheyney的“非递归列表压缩算法”中进行了介绍。ACM,13(11):677-678,1970。

在复制垃圾收集器中,您有一个要尝试收集的内存块(称为from-space)和要用于副本的一块内存(称为to-space)scan收件人空间组织为一个队列,其中的指针指向最旧的已复制但未扫描的记录,而free指针指向收件人空间中的下一个空闲位置。威尔逊论文的图片如下:

切尼算法示例

扫描To-Space中的每个项目时,您将其子项从From-Space复制到freeTo -Space中的指针,然后将指向该子项的指针从From-Space更改为To-Space中该子项的新副本。当您的数据结构不是树时(当一个孩子可以有一个以上的父母时),您还需要使用一个额外的技巧。在这种情况下,当您将子项从空间复制到空间时,您需要使用指向该子项新副本的转发指针覆盖该子项的旧版本。然后,如果您曾经扫描过另一个指向该孩子的旧版本的指针,则您会意识到它已经被复制,因此无需再次复制。


实际上,正如我的回答所述,Mark + Sweep和Copy集合都是相同的抽象图算法。MS和Copy集合的区别仅在于实现抽象算法所使用的集合的方式不同,并且包括两个家族以及许多变体,这些集合以我在回答中描述的集合实现技术的某种组合形式出现。一些GC变体实际上在同一GC中混合了MS和Copy。在某些情况下,将MS和Copy分开是一种构造书本的便捷方法,但是这是一个任意的,而且我认为已经过时了。
babou 2015年

@babou:如果使用一种复制算法,其中访问的所有内容都将被复制(速度很慢,但是在工作集永远不会那么大的小型平台上可能很有用),某些算法可能会有所简化,因为可以使用以前由重定位的对象作为暂存器占用的内存。如果在每次读取之前和之后检查对象有效性,并在转发指针之后跟踪对象(如果对象已移动),则使其他线程在收集期间对对象执行只读访问的能力也可能会受到限制。
supercat 2015年

@supercat我不确定您要说什么,您的意图是什么。您的某些陈述似乎是正确的。但是我不明白如何在GC周期结束之前使用from-space(它包含转发指针)。那将是什么的暂存器?简化算法如何?关于在发生GC时执行的多个mutator线程,尽管可能严重影响实现,但这在很大程度上是一个正交的问题。我不会尝试在评论中解决这个问题。它应该在只读访问中产生较少的问题,但细节在于魔鬼。
babou 2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.