【问题标题】:Why do garbage collectors wait before deallocating?为什么垃圾收集器在释放之前要等待?
【发布时间】:2013-07-15 03:52:31
【问题描述】:

我有一个“为什么它会这样工作?”关于垃圾收集的问题(任何/所有实现:Java、Python、CLR 等)。当对象不再在任何范围内时,垃圾收集器会释放它;指向它的引用数为零。在我看来,一旦引用数量达到零,框架就可以解除分配,但是我遇到的所有实现都会等待一段时间,然后一次解除许多对象的分配。我的问题是,为什么?

我假设框架为每个对象保留一个整数(我认为 Python 会这样做,因为在 C 中为它编写扩展模块时必须调用 PyINCREFPyDECREF;大概这些函数修改了一个真实的在某处反击)。如果是这样,那么当它超出范围时,它不应该花费更多的 CPU 时间来消除对象。如果现在每个对象需要 x 纳秒,那么以后每个对象需要 x 纳秒,对吧?

如果我的假设是错误的并且没有与每个对象相关联的整数,那么我理解垃圾收集为什么会等待:它必须遍历引用图以确定每个对象的状态,并且计算需要时间。这种方法比显式引用计数方法消耗更少的内存,但令我惊讶的是它更快或者由于其他原因是首选方法。听起来工作量很大。

从编程的角度来看,如果对象在超出范围后立即解除分配,那就太好了。我们不仅可以依赖于在我们希望它们执行时执行析构函数(Python 的陷阱之一是 __del__ 不会在可预测的时间被调用),而且对程序进行内存分析会变得更加容易。 Here's an example 这会造成多少混乱。在我看来,在立即解除分配框架中编程的好处是如此之大,以至于我听说的所有实现都必须在解除分配之前等待。有什么好处?

注意:如果只需要遍历引用图来识别循环引用(纯引用计数不能),那么为什么不采用混合方法呢?一旦对象的引用计数达到零,就释放对象,然后定期扫描以查找循环引用。在这样的框架中工作的程序员将有一个性能/确定性的理由尽可能地坚持非循环引用。这通常是可行的(例如,所有数据都是 JSON 对象的形式,没有指向父对象的指针)。任何流行的垃圾收集器都是这样工作的吗?

【问题讨论】:

  • 作为参考,Java 不做引用计数。

标签: garbage-collection reference-counting


【解决方案1】:

首先,一个术语:“垃圾收集”对不同的人意味着不同的东西,并且一些 GC 方案比其他方案更复杂。有些人认为引用计数是 GC 的一种形式,但我个人认为“真正的 GC”不同于引用计数。

使用引用计数,有一个整数跟踪引用的数量,当引用计数为零时,您可以立即触发解除分配。这就是 CPython 实现的工作原理,以及大多数 C++ 智能指针的工作原理。 CPython实现添加了一个mark/sweep GC作为备份,所以很像你描述的混合设计。

但是引用计数实际上是一个非常糟糕的解决方案,因为每次传递引用时都会产生(相对)昂贵的内存写入(加上内存屏障和/或锁,以确保线程安全),这种情况经常发生。在像 C++ 这样的命令式语言中,可以(只是很难)通过宏和编码约定来管理内存所有权,但在像 Lisp 这样的函数式语言中,这几乎是不可能的,因为内存分配通常是由于闭包中的局部变量捕获而隐式发生的。

因此,为 Lisp 发明了迈向现代 GC 的第一步也就不足为奇了。它被称为“双空间分配器”或“双空间收集器”,它的工作方式与听起来完全一样:它将可分配内存(“堆”)分成两个空间。每个新对象都从第一个空间分配,直到它太满,此时分配将停止,运行时将遍历引用图并仅将活动(仍被引用)对象复制到第二个空间。在活动对象被复制后,第一个空间将被标记为空,并且分配将继续,从第二个空间分配新对象,直到它太满,此时活动对象将被复制回第一个空间和过程将重新开始。

twospace 收集器的优点是,它不会做O(N) 工作,其中 N 是垃圾对象的总数,它只会做O(M) 工作,其中 M对象的数量 不是垃圾。由于在实践中,大多数对象在短时间内被分配然后释放,这可以带来显着的性能提升。

此外,双空间收集器还可以简化分配器端。大多数malloc() 实现维护所谓的“空闲列表”:哪些块仍然可以分配的列表。要分配一个新对象,malloc() 必须扫描空闲列表以寻找足够大的空白空间。但是双空间分配器并没有为此烦恼:它只是像堆栈一样在每个空间中分配对象,只需将指针向上推所需的字节数即可。

所以 twospace 收集器比 malloc() 快得多,这很棒,因为 Lisp 程序会比 C 程序做更多的分配。或者,换句话说,Lisp 程序需要一种像堆栈一样分配内存的方法,但它的生命周期不限于执行堆栈——换句话说,一个堆栈可以无限增长而不会耗尽程序的内存.而且,事实上,Raymond Chen 认为这正是人们应该如何看待 GC。我强烈推荐他以Everybody thinks about garbage collection the wrong way开头的系列博文。

但是 twospace 收集器有一个重大缺陷,那就是没有程序可以使用超过一半的可用 RAM:另一半总是被浪费掉。所以 GC 技术的历史就是尝试改进双空间收集器的历史,通常是通过使用程序行为的启发式方法。然而,GC 算法不可避免地涉及权衡,通常更喜欢批量释放对象而不是单独释放对象,这不可避免地会导致对象没有立即释放的延迟。

编辑:为了回答您的后续问题,现代 GC 通常包含 generational garbage collection 的思想,其中对象根据生命周期分为不同的“世代”,一个对象在一个世代中一旦它活得足够长,就会被“提升”到另一代人。有时,对象生命周期的微小差异(例如,在请求驱动的服务器中,将对象存储的时间超过一个请求)可能会导致对象被释放之前所需的时间量有很大差异,因为它会导致它成为更“终身”。

您正确地观察到真正的 GC 必须在“低于”malloc()free() 级别运行。 (作为旁注,值得了解 malloc()free() 是如何实现的——它们也不是魔法!)此外,对于有效的 GC,您要么需要保守(如 Boehm GC),要么永远不要移动对象,并检查可能是指针的东西,否则你需要某种“不透明指针”类型——Java和C#称之为“引用”。不透明指针实际上非常适合分配系统,因为这意味着您始终可以通过更新指向对象的指针来移动对象。在像 C 这样直接与原始内存地址交互的语言中,移动对象从来都不是真正安全的。

GC 算法有多种选择。标准 Java 运行时包含不少于五个收集器(Young、Serial、old CMS、new CMS 和 G1,虽然我想我忘了一个),并且每个都有一组可配置的选项。

但是,GC 并不是魔法。大多数 GC 只是利用了批处理工作的time-space tradeoff,这意味着速度的提高通常是通过增加内存使用来支付的(与手动内存管理或引用计数相比)。但是,提高程序性能和提高程序员性能的结合,与如今 RAM 的低成本相比,通常值得权衡。

希望这有助于让事情变得更清晰!

【讨论】:

  • 这很有意义,并回答了我关于效率的问题。 (将两个完整空间之一标记为空,从头到尾,将花费 x 纳秒,而不是 x*N 纳秒。)似乎我的直觉是由 CPython 形成的,这是我的大部分经验,但也有有很多比 CPython 更好的方案。至少两个空间收集器是更好方法的存在证明,尽管我从您的解释中了解到,现代 GC 像 Java 和 CLR(以及 Web 浏览器?)会做一些更复杂的事情来避免丢失一半堆空间的问题.
  • 要实现这一点,需要比mallocfree 更多的低级访问权限,这不是我所期望的。我原以为这些函数是底层的。
  • @JimPivarski:是的,有些人认为使用 GC 的生产力收益会因为分析和调整应用程序性能所花费的时间而损失。我不会走那么远,但它肯定会在令人惊讶的地方增加很多复杂性。幸运的是,Stack Overflow 是在这方面获得帮助的好地方:我建议您就您要分析的内容提出更具体的问题,因为这将帮助您获得更好的答案。
  • @DanielPryden:我经常希望有一种语言/框架可以结合引用计数和 GC,区分“拥有”和“非拥有”引用。 “拥有”引用的操作需要调整引用计数,但只有在引用的接收者可能希望成为最后需要它的东西的情况下才需要。大多数引用可以在没有开销的情况下传递。当“拥有”引用的数量达到零时,将通知对象,但只要存在任何引用,它就会继续存在
  • @DanielPryden:分代 GC 是实现良好性能的关键。值得注意的是,要获得真正好的 GC 性能,需要能够识别老一代对象,这些对象不包含自上一次收集老一代以来写入的任何引用(这意味着它们不可能包含任何对更新的引用对象)。在许多 GC 上,这需要能够捕获将新引用存储在旧对象中的尝试,该旧对象被标记为不包含新对象引用并清除该标记。
【解决方案2】:

要了解垃圾收集,请前往保龄球馆并观察置瓶机如何在第一个球滚完后移除掉下的瓶。置瓶机机制不是识别和移除单个倒下的瓶,而是捡起所有仍然站立的瓶,将它们提升到安全位置,然后在车道上运行一个清扫杆,而不考虑有多少瓶躺在那里或它们的位置.完成后,将站立的旗杆放回球道上。许多垃圾收集系统的工作原理大致相同:它们必须为每个活动对象做大量工作以确保它不会被破坏,但死对象会被大量销毁,甚至没有被查看或注意到。

附录

垃圾收集器总是必须对每个活动项目采取行动以确保当活动项目很多时其保存速度很慢;这就是为什么垃圾收集器在历史上名声不好的原因。 Commodore 64 上的 BASIC 解释器(顺便说一句,它是微软在MS-DOS 之前之前编写的)在一个包含几个数组的程序中执行垃圾收集需要很多秒百串。如果可以忽略在第一次垃圾收集中幸存下来的项目,直到 许多 个项目在第一次垃圾收集中幸存下来,并且那些 参与 并在两次垃圾收集中幸存下来的项目,性能可以大大提高集合(请注意,在许多其他对象在其第一个集合中幸存之前,它们不必参与第二个集合)可以被忽略,直到许多其他对象也参与并在它们的第二个集合中幸存。这个概念可以很容易地部分实现(即使在 Commodore 64 上,也可以强制在给定时刻存在的所有字符串免于将来的垃圾收集,如果在启动时程序创建了永远不会的大型字符串数组,这可能很有用更改),但通过一些额外的硬件支持变得更强大。

如果有人认为垃圾收集器会尝试打包将尽可能靠近内存末端的对象,那么世代支持只需要跟踪哪些(连续)范围内存被每一代的对象使用。必须扫描每一代的所有对象,以确保所有较新的活动对象都已定位和保存,但不必移动较旧的对象,因为它们占用的内存没有被大规模淘汰的危险。这种方法实现起来非常简单,并且与非分代 GC 相比可以提供一些显着的性能改进,但如果有很多活动对象,即使 GC 的扫描阶段也可能代价高昂。

加速“新一代”垃圾回收的关键是观察如果对象“Fred”在它参与的最后一次垃圾回收之后没有被写入,它不可能包含对任何对象的任何引用从那时起就创建了。因此,在 Fred 本身有资格被淘汰之前,它所引用的任何对象都不会有被淘汰的危险。当然,如果自上次低级 GC 以来对新对象的引用已存储在 Fred 中,则确实需要扫描这些引用。为了实现这一点,高级垃圾收集器设置了硬件陷阱,在写入老一代堆的部分内容时触发。当此类陷阱触发时,它将该区域中的对象添加到需要扫描的老一代对象列表中,然后禁用与该区域关联的陷阱。在老一代对象经常引用存储在其中的较新对象的情况下,这种额外的记账可能会损害性能,但在大多数情况下,它最终会成为主要的性能优势。

【讨论】:

  • 确实,我遗漏的一个关键点是,死物被大量销毁,而不是一件一件地销毁。
【解决方案3】:

您的想法通常非常有见地且经过深思熟虑。您只是缺少一些基本信息。

当对象不再在任何范围内时,垃圾收集器将其释放

这通常是完全不正确的。垃圾收集器在运行时处理范围概念早已被删除的表示。例如,活性分析的内联和应用会破坏范围。

跟踪垃圾收集器会在最后一个引用消失后的某个时间点回收空间。即使变量仍在作用域内,活跃度分析也可以使堆栈帧中的引用被其他引用覆盖,因为活跃度分析确定该变量永远不会再次使用,因此不再需要。

在我看来,一旦引用数量达到零,框架就可以解除分配,但我遇到的所有实现都会等待一段时间,然后一次解除许多对象。我的问题是,为什么?

性能。您可以在堆栈条目和寄存器级别引用计数,但性能绝对糟糕。所有实用的引用计数垃圾收集器都将计数器递减延迟到范围的末尾,以实现合理的 (but still bad) 性能。最先进的引用计数垃圾收集器延迟递减以将它们批量化,并且可以allegedly 获得有竞争力的性能。

我假设框架为每个对象保留一个整数

不一定。例如,OCaml 使用单个位。

从编程的角度来看,如果对象在超出范围后立即解除分配会很好。

从编程的角度来看,如果代码能毫不费力地快 10 倍,那就太好了。

请注意,析构函数会抑制尾调用消除,这在函数式编程中是非常宝贵的。

我很惊讶它更快或出于其他原因是首选方法。听起来工作量很大。

考虑一个通过处理棋盘坐标列表来解决 n-queens 问题的程序。输入是一个整数。输出是一个包含一些棋盘坐标的列表。中间数据是一个巨大的链表节点意大利面条堆栈。如果你通过预先分配足够大的链表节点堆栈来编写它,操纵它们以获得答案,复制出(小)答案,然后在整个堆栈上调用一次free,那么你几乎可以做到与分代垃圾收集器完全相同的事情。特别是,您只需要复制约 6% 的数据,而只需调用 free 即可释放另外约 94% 的数据。

对于坚持“大多数对象年轻而老对象很少引用新对象”这一假设的世代垃圾收集器来说,这是一个完美的快乐日子场景。分代垃圾收集器挣扎的一个病态反例是用新分配的对象填充哈希表。哈希表的主干是一个大数组,可以存活,所以它会在老年代。每个插入其中的新对象都是从老一代到新一代的反向指针。每个新对象都存在。因此,分代垃圾收集器快速分配,然后标记所有内容,复制所有内容并更新指向所有内容的指针,因此,运行速度比简单的 C 或 C++ 解决方案慢约 3 倍。

我们不仅可以依赖在我们希望它们执行的时候执行析构函数(Python 的陷阱之一是 del 不会在可预测的时间被调用),而且它会变得更容易内存分析程序

请注意,析构函数和垃圾回收是正交的概念。例如,.NET 以IDisposable 的形式提供析构函数。

FWIW,在使用垃圾收集语言约 15 年的时间里,我可能使用过 3 次内存分析。

为什么不采用混合方法?一旦对象的引用计数达到零,就释放对象,然后定期扫描以查找循环引用。在这样的框架中工作的程序员将有一个性能/确定性的理由尽可能地坚持非循环引用。这通常是可行的(例如,所有数据都是 JSON 对象的形式,没有指向父对象的指针)。任何流行的垃圾收集器都是这样工作的吗?

我相信 CPython 可以做到这一点。 Mathematica 和 Erlang 在设计上将堆限制为 DAG,因此它们可以单独使用引用计数。 GC研究人员提出了相关技术,例如删除试验作为循环检测的辅助算法。

还要注意,理论上,引用计数比跟踪垃圾回收更快,因为它的性能与(活动)堆的大小无关。在实践中,即使使用 100GB 堆,跟踪垃圾回收仍然快得多

【讨论】:

    【解决方案4】:

    我认为原因在于性能。如果您在循环中创建大量对象并在循环步骤结束时销毁它们,则执行该代码将花费更多时间,然后等到程序空闲并立即释放数据。或者是因为记忆力低。

    【讨论】:

    • 如果释放每个对象需要 x 纳秒并且您有 N 个对象要释放,那么无论您是在循环期间还是在循环之后执行此操作,都需要相同的时间。也许争论是大多数程序做了很多工作,然后等待资源/用户输入,然后再工作。在这种情况下,在循环之后花费 x*N 纳秒是有益的,因为程序只是在等待。但这不适用于数字运算应用程序,知道优化仅适用于运行和等待类型的程序会很有趣。
    • @JimPivarski:正如我在回答中所指出的(你也看到了),许多 GC 可以批量销毁旧对象,而无需为每个对象执行任何工作。如果大多数对象在它们的第一次 GC 之前被放弃,这可能是一个巨大的胜利。即使没有这种能力,GC 仍然会在多处理器系统上胜出,因为同步动作处理器的成本很高。在具有 stop-the-world GC 的系统中,与内存管理相关的同步只需要每个 GC 周期一次,而不是每次分配和释放一次(请注意,许多多处理器系统分配...
    • ...每个处理器都有一个单独的“第一代”堆。这不需要太多额外的存储空间,因为第一代堆通常不到总内存空间的 5%,但这意味着处理器可以从自己的堆中分配内存,而无需任何其他处理器知道或关心它这样做了。由于多处理器同步很昂贵而且相对而言,每年都在变得更昂贵,这是一个巨大的胜利。
    【解决方案5】:

    我遇到过 GC 系统,它们会等到需要运行时才进行,这样仍在使用中的对象的重定位就可以完成一次,而不是多次。

    考虑在内存中顺序分配的一系列对象:

    Object 1
    Object 2
    Object 3
    Object 4
    Object 5
    

    如果对象 2 可以被释放,并且 GC 立即运行,则对象 3,4 和 5 都需要移动。

    现在考虑可以释放对象 4,GC 现在会将对象 5 移动到对象 3 旁边。对象 5 已移动两次

    但是,如果 GC 等待一小会儿,Objects2 和 4 可以同时被移除,这意味着 Object 5 被移动了一次,并且移动得更远。

    将对象的数量乘以 100,您可以看到这种方法节省了大量时间

    【讨论】:

    • 为什么物体在移动?如果它们是在堆上而不是在堆栈上声明的,那么它们之间是否存在间隙不是根本问题——幸存的对象可以留在它们所在的位置。碎片堆确实会成为一个问题(你用完了连续的点来放置新的大对象),我可以理解为什么堆碎片整理程序应该等待并且只定期运行。垃圾收集器也是碎片整理程序吗?如果是这样,它作为碎片整理者的角色是否比它作为解除分配者的角色更重要?
    • 假设一个循环创建和消除所有相同大小的对象(这是一个典型情况)。立即释放它们会很有用,无需重新定位,因为它们之间留下的间隙可以用下一个循环迭代的对象填充。这允许循环在不增加内存使用的情况下运行。然而,碎片整理程序会希望稍后清理幸存者的位置。我认为有充分的理由推迟碎片整理,而不是取消分配。
    • 不 - 如果堆上的对象之间存在间隙,这不是问题。正如您正确指出的那样,缺乏连续的间隙会成为问题。如果 GC 在释放时立即运行,则重复移动会浪费资源。如果它一直等到完全没有空间,GC 就会成为一项耗时的任务,会影响响应能力。这是一个平衡。由于后一个问题,Firefox 最近将他们的 GC 算法更改为更先进的方法。从我现在看到的情况来看,FF 总体上做了更多的 GC,但对响应的影响较小。
    • 澄清一下:我同意碎片整理应该等待一个最佳时间,不要太早(大量浪费的重新定位),也不要太晚(混乱失控)。我在问它为什么要等待释放。
    • @Mike W:在每次释放后立即压缩堆的想法是非常荒谬的。想象一下,您刚刚释放了 10 个字节,您会压缩 10 GB 堆吗?你愿意吗?
    【解决方案6】:

    @Jim 已经回答了很多,我会补充更多。

    首先是什么让您认为一旦计数为0 就解除分配[A1] 是一个不错的选择?

    垃圾收集器不仅释放对象,而且负责完整的内存管理。从fragmentation 开始,垃圾收集器的最大问题之一。如果处理不当,将导致不必要的页面命中和缓存未命中。垃圾收集器从一开始就是为了处理这个问题而设计的。对于不同的世代,处理这个变得更容易。使用A[1],线程必须定期对其进行设置和处理。

    此外,事实证明清除多个对象比A[1] 中的操作更快。 (想一想,对于一个铺满沙子的房间 - 将它们全部清除而不是单独挑选它们会更快)

    其次,为了多线程系统中的线程安全,必须为每个对象持有一个锁以增加/减少计数,这是性能不佳和额外的内存。此外,现代收集器有能力并行处理而不是 Stop The World(例如:Java 的 ParallelGC),我想知道 A[1] 怎么会发生这种情况。

    【讨论】:

      【解决方案7】:

      使用引用计数的垃圾收集非常慢,尤其是在线程环境中。

      我真的推荐this post by Brian Harry

      那里提供了一个足以说服我的代码示例(C#):

      public interface IRefCounted : IDisposable
      {
              void AddRef();
      }
      
      // ref counted base class.
      class RefCountable : IRefCountable
      {
              private m_ref;
              public RefCountable()
              {
                      m_ref = 1;
              }
              public void AddRef()
              {
                      Interlocked.Increment(ref m_ref);
              }
              public void Dispose()
              {
                      if (Interlocked.Decrement(ref m_ref) == 0)
                              OnFinalDispose();
              }
              protected virtual void OnFinalDispose()
              {
              }
      }
      

      Interlocked.Increment(ref m_ref) 是一个原子操作,需要数百个内存周期。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2021-02-16
        • 2011-10-30
        • 2013-01-21
        • 1970-01-01
        • 1970-01-01
        • 2014-04-11
        相关资源
        最近更新 更多