为什么垃圾收集仅清除堆?


28

基本上,到目前为止,我已经了解到垃圾回收将永远擦除当前未指向的任何数据结构。但这仅检查堆是否存在这种情况。

为什么不同时检查数据部分(全局变量,常量等)或堆栈呢?关于堆,这是我们唯一要进行垃圾收集的东西吗?


21
“清理堆”比“清理堆”更安全... :-)
Brian Knoblauch

Answers:


62

垃圾收集器扫描堆栈-查看堆栈中的事物当前正在使用(指向)堆中的哪些事物。

垃圾收集器考虑收集堆栈内存是没有意义的,因为堆栈不是以这种方式管理的:堆栈上的所有内容都被视为“正在使用”。从方法调用返回时,堆栈使用的内存将自动回收。堆栈空间的内存管理是如此简单,廉价和容易,以至于您不希望涉及垃圾回收。

(在诸如smalltalk之类的系统中,堆栈帧是存储在堆中的头等对象,并且像所有其他对象一样收集垃圾。但这不是当今流行的方法。Java的JVM和Microsoft的CLR使用硬件堆栈和连续内存)


7
+1堆栈始终可以完全到达,因此没有任何扫视的感觉
棘手怪胎

2
+1谢谢,花了4篇文章找到正确答案。我不知道为什么你不得不说在堆栈上一切“认为”是在使用中,它在使用它至少感强如堆正在使用的对象仍然是在使用中-但是这是一个真正的鸡蛋里挑骨头一个很好的答案。
psr

@psr他的意思是堆栈上的所有内容都可以很容易地到达并且不需要收集,直到方法返回为止,但是(RAII)已经得到明确管理
棘手怪胎

@ratchetfreak-我知道。我只是说“考虑”一词可能不需要,如果没有它,可以做一个更强的陈述。
psr

5
@psr:我不同意。出于非常重要的原因,对于堆栈和堆来说,“ 被认为正在使用”更为正确。您想要的是丢弃不再使用的内容;你要做的就是丢弃那些无法达到的东西。您可能会拥有永远不需要的可访问数据。当这些数据增长时,就会发生内存泄漏(是的,即使是GC语言,也有可能发生泄漏,这与许多人的想法不同)。也许有人会争辩说堆栈泄漏也会发生,最常见的例子是在没有尾调用消除的情况下(例如在JVM上)运行的尾递归程序中不需要的堆栈帧。
Blaisorblade 2011年

19

转个问题。真正的激励问题是,在什么情况下我们可以避免垃圾收集的成本?

嗯,首先,什么垃圾回收的成本?有两个主要费用。首先,您必须确定什么还活着;这可能需要大量工作。其次,您必须缩小释放在两个仍然存在的事物之间分配的事物时形成的漏洞。那些孔是浪费的。但是压缩它们也很昂贵。

我们如何避免这些费用?

显然,如果您可以找到一种存储使用模式,在该模式中您从不分配长寿的东西,然后分配短寿的东西,然后分配长寿的东西,则可以消除漏洞的成本。如果可以保证对于存储的某个子集,每个后续分配的寿命都比该存储中的前一个分配寿命短,那么该存储中就不会有任何漏洞。

但是如果我们解决了漏洞问题,那么我们也解决了垃圾收集问题。您的存储中是否还有还活着的东西?是。一切都分配了吗?是的-该假设是我们消除漏洞的可能性。因此,您要做的就是说“最近的分配是否还活着?” 而且您知道该存储中的所有数据仍然有效。

我们是否有一组存储分配,可以知道每个后续分配的寿命都比先前分配的寿命短?是! 方法的激活框架总是以与创建它们相反的顺序销毁,因为它们的寿命总是比创建它们的激活寿命短。

因此,我们可以将激活框架存储在堆栈中,并且知道永远不需要收集它们。如果堆栈上有任何帧,则其下的整个帧的寿命更长,因此不需要收集它们。它们将按照与创建它们相反的顺序被销毁。因此消除了激活帧的垃圾收集成本。

这就是为什么我们首先将临时池放在堆栈上的原因:因为这是一种实现方法激活而又不会引起内存管理损失的简便方法。

(当然垃圾收集存储器的成本称为通过引用上的激活帧仍然存在。)

现在考虑一个控制流系统,其中激活框架不会以可预测的顺序被破坏。如果短暂激活会导致长期激活,会发生什么情况?您可能会想象,在这个世界上,您将无法再使用堆栈来优化收集激活的需求。激活集可以再次包含孔。

C#2.0具有的形式的此功能yield return。在稍后的时间(下一次调用MoveNext的情况下)将重新激活产生收益的方法,并且这种情况何时发生是不可预知的。因此,通常将在迭代器块的激活帧的堆栈上存储的信息存储在堆中,在收集枚举器时将在此处进行垃圾收集。

同样,C#和VB的下一版本中的“异步/等待”功能将使您可以创建方法,这些方法在执行该方法的过程中定义明确的点处将其激活“屈服”和“恢复”。由于不再以可预测的方式创建和销毁激活帧,因此所有以前存储在堆栈中的信息都必须存储在堆中。

几十年来,我们碰巧决定,带有以严格有序方式创建和销毁的激活框架的语言是时髦的,这只是历史的偶然。由于现代语言越来越缺乏这种特性,因此希望看到越来越多的语言将连续性变成垃圾收集的堆,而不是堆栈。


13

最明显的答案(也许不是最完整的答案)是,堆是实例数据的位置。实例数据是指表示在运行时创建的类(又称为对象)实例的数据。这些数据本质上是动态的,这些对象的数量以及它们占用的内存量仅在运行时才知道。恢复该内存可能会有些痛苦,否则长时间运行的程序会逐渐消耗该内存中的所有内存。

类定义,常量和其他静态数据结构消耗的内存本质上不太可能未经检查地增加。由于每个未知数量的类的运行时实例在内存中只有一个类定义,因此这种结构类型不会对内存使用构成威胁是有道理的。


5
但是堆不是“实例数据”的位置。它们也可以放在堆栈上。
svick 2011年

@svick当然取决于语言。Java仅支持堆分配的对象,而Vala相当明确地区分了堆分配的(类)和堆栈分配的(结构)。
蓬松的

1
@fluffy:这些是非常有限的语言,您不能认为这通常适用,因为没有精确的语言。
Matthieu M.

@MatthieuM。那是我的观点。
蓬松的

@fluffy:那为什么要在堆中分配类,而在栈中分配结构呢?
黑暗圣堂武士

10

值得牢记的是我们进行垃圾回收的原因:因为有时很难知道何时释放内存。您确实只有堆有此问题。分配在堆栈上的数据最终将被释放,因此实际上并不需要进行垃圾回收。通常假定数据部分中的内容是为程序的生存期分配的。


1
它不仅会“最终”释放,而且会在正确的时间释放。
鲍里斯·扬科夫

3
  1. 它们的大小是可以预测的(除了堆栈,常数,并且堆栈通常限制为几个MB),并且通常很小(至少与大型应用程序可以分配的数百MB相比)。

  2. 动态分配的对象通常在较短的时间内可以到达。此后,就无法再引用它们了。将其与数据部分中的条目,全局变量等进行对比:通常,有一段代码直接引用它们(请考虑const char *foo() { return "foo"; })。通常,代码不会改变,因此该引用会保留下来,并且每次调用该函数时都会创建另一个引用(就计算机所知,此引用可以随时出现-除非您解决了暂停问题,即)。因此,您无论如何都无法释放大部分内存,因为它总是可以访问的。

  3. 在许多垃圾回收语言中,属于正在运行的程序的所有内容都是堆分配的。在Python中,根本没有任何数据部分,也没有栈分配的值(存在局部变量的引用,并且有调用栈,但int在C中,这两个值都不具有相同的含义)。每个对象都在堆上。


“在Python中,根本没有任何数据部分”。严格来说,这不是真的。据我了解,在数据部分中分配了None,True和False: stackoverflow.com/questions/7681786/how-is-hashnone-calculated
Jason Baker

@JasonBaker:有趣的发现!虽然没有任何效果。这是一个实现细节,并且仅限于内置对象。更不用说这些对象在程序的生命周期中永远都不会被释放,也不是,并且它们的大小也很小(我猜每个对象少于32字节)。

@delnan正如埃里克·利珀特(Eric Lippert)所喜欢指出的那样,对于大多数语言来说,用于堆栈和堆的独立内存区域的存在是实现细节。您可以完全不使用堆栈来实现大多数语言(尽管这样做可能会降低性能),并且仍然符合其规范
Jules

2

正如许多其他响应者所说的那样,堆栈是根集的一部分,因此,对其进行扫描以查找引用,但并不对其进行“收集”。

我只想回应一些暗示堆栈上的垃圾无关紧要的评论。这样做是因为它可能导致堆上的更多垃圾被认为是可到达的。认真的VM和编译器编写者要么无效,要么从扫描中排除堆栈中的无效部分。在IIRC中,某些VM具有将PC范围映射到堆栈插槽活动性位图的表,而其他VM则使插槽无效。我不知道目前首选哪种技术。

用于描述这一特殊考虑的一个术语是“ 空间安全”


知道会很有趣。首先想到的是消除空间是最现实的。遍历一棵排除区域的树可能比扫描空值花费更长的时间。显然,任何试图压紧堆栈的企图都充满危险!使该工作听起来像一个弯腰/容易出错的过程。
Brian Knoblauch

@Brian,实际上,要想一想,对于类型化的VM,无论如何您都需要类似的东西,因此您可以确定哪些插槽是与整数,浮点数等相对的引用。另外,关于压缩堆栈,请参见“ CONS应该“不是缺点”。
瑞安·库尔珀

确定插槽类型并验证是否正确使用了插槽类型,通常可以在编译时(对于使用受信任字节码的VM)或加载时(其中字节码来自不受信任的来源(例如Java))静态地完成。
Jules

1

让我指出您和其他许多人错误的一些基本误解:

“为什么垃圾收集仅清除堆?” 相反。只有最简单,最保守和最慢的垃圾收集器才能清除堆。这就是为什么他们这么慢。

快速垃圾收集器仅清除堆栈(以及可选的其他一些根,例如FFI指针的某些全局变量和活动指针的寄存器),并且仅复制堆栈对象可访问的指针。其余的被丢弃(即被忽略),根本不扫描堆。

由于堆的大小大约是堆栈的1000倍,因此这种堆栈扫描GC通常要快得多。〜15ms,而普通大小的堆为250ms。由于它是将对象从一个空间复制(移动)到另一个空间,因此它通常被称为半空间复制收集器,它需要2倍的内存,因此在诸如内存不多的手机之类的小型设备上大多不可用。它非常紧凑,因此对缓存非常友好,与简单的标记和清除堆扫描器不同。

由于指针在移动,因此FFI,身份和引用非常棘手。身份通常通过随机ID(通过转发指针进行引用)解决。FFI非常棘手,因为异物无法保留指向旧空间的指针。FFI指针通常保存在单独的堆域中,例如使用缓慢的标记扫掠静态收集器。或带有refcounting的琐碎malloc。请注意,malloc具有巨大的开销,并且需要更多的更新。

“标记并清除”的实现很简单,但不应在实际程序中使用,尤其不要作为标准收集器来学习。这种最快速的堆栈扫描复印收集器中最著名的就是切尼两指收集器


问题似乎更多是关于内存的哪些部分被垃圾收集,而不是特定的垃圾收集算法。最后一句话特别暗示OP正在使用“清除”作为“垃圾收集”的通用同义词,而不是实现垃圾收集的特定机制。考虑到这一点,您的回答是说,只有最简单的垃圾收集器会垃圾收集堆,而快速的垃圾收集器会垃圾收集堆栈和静态内存,从而使堆不断增长直至内存耗尽。
8bittree '17

不,这个问题非常具体和巧妙。答案并非如此。慢速标记和清除GC有两个阶段,标记步骤扫描堆栈上的根,扫描阶段扫描堆。快速复制GC只有一个阶段,即扫描堆栈。那样简单。由于显然没有人知道适当的垃圾收集器,因此需要回答该问题。您的解释很不正确。
rurban

0

堆栈上分配了什么?局部变量和返回地址(以C表示)。当函数返回时,其局部变量将被丢弃。扫栈并不是必需的,甚至是有害的。

许多动态语言以及Java或C#都是用系统编程语言(通常是C)实现的。您可以说Java是使用C函数实现的,并且使用C局部变量,因此Java的垃圾回收器不需要清除堆栈。

有一个有趣的例外:Chicken Scheme的垃圾收集器确实(以某种方式)清除了堆栈,因为其实现使用堆栈作为第一代垃圾收集空间:请参阅Chicken Scheme Design Wikipedia

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.