为什么垃圾收集者在解除分配之前会等待?

时间:2013-07-15 03:52:31

标签: garbage-collection reference-counting

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

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

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

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

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

7 个答案:

答案 0 :(得分:33)

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

使用refcounts,有一个跟踪引用数量的整数,并且当refcount达到零时,您可以立即触发释放。下面介绍CPython实现的工作原理,以及大多数C ++智能指针的工作原理。 CPython实现添加了标记/扫描GC作为备份,因此它非常类似于您描述的混合设计。

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

因此,为Lisp发明现代GC的第一步也就不足为奇了。它被称为" twospace allocator"或者" twospace collector"它完全像听起来一样:它将可分配的内存("堆")分成两个空格。每个新对象都从第一个空间分配,直到它太满,此时分配将停止,运行时将遍历参考图并仅将实时(仍然引用的)对象复制到第二个空间。复制实时对象后,第一个空格将被标记为空,分配将恢复,从第二个空间分配新对象,直到它太满,此时活动对象将被复制回第一个空间,过程将重新开始。

twospace collector的优势在于,不是做O(N)工作,而 N 是垃圾对象的总数,它只会O(M)工作,其中 M 非垃圾 对象数。由于在实践中,大多数对象被分配然后在短时间内解除分配,这可以导致显着的性能改进。

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

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

但是这个twospace收藏家有一个重大缺陷,即没有一个程序可以使用超过一半的可用内存:另一半总是浪费。因此,GC技术的历史就是通常通过使用程序行为的启发式方法来改进二维空间收集器的历史。然而,GC算法不可避免地涉及权衡,通常更倾向于批量解除分配对象而不是单独解除对象,这不可避免地导致对象不会立即解除分配的延迟。

编辑:为了回答您的后续问题,现代GC通常采用generational garbage collection的概念,其中对象被分组为不同的"代"基于生命周期,一代中的对象得到提升"一旦它生活了足够长的时间,到另一代人。有时对象生命周期中的一小部分差异(例如,在请求驱动的服务器中,将对象存储的时间超过一个请求)可能导致在对象被释放之前花费的时间差异很大,因为它会导致对象生成更多"终身"。

您正确地观察到真正的GC必须运行"在" malloc()free()的级别。 (作为旁注,值得了解malloc()free()如何实施 - 它们也不是魔术!)此外,对于有效的GC,您需要保守(比如Boehm GC)并且永远不会移动对象,并检查可能指针的东西,否则你需要某种"不透明的指针" type - Java和C#调用"引用"。不透明指针实际上对分配系统很有用,因为它意味着你总是可以通过更新指针来移动对象。在像C这样的语言中,你直接与原始内存地址交互,移动对象永远不会真正安全。

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

然而,GCs并不神奇。大多数GC只是利用time-space tradeoff批处理工作,这意味着速度的提高通常是通过增加内存使用量来支付的(与手动内存管理或引用计数相比)。但是,程序性能的提高和程序员性能的提高,以及目前RAM的低成本,使得这种权衡通常是值得的。

希望这有助于让事情更清楚!

答案 1 :(得分:9)

要了解垃圾收集,请前往保龄球馆观看置瓶机在第一个球滚动后如何移除掉落的针脚。 pinsetter机制不是识别和移除单个掉落的销钉,而是拾取所有仍然站立的销钉,将它们提升到安全位置,然后在整个车道上运行清扫杆,而不考虑那里有多少针脚或位于哪里。完成后,站立的销钉放回到车道上。许多垃圾收集系统的工作原理大致相同:它们必须为每个活动对象做一些非常重要的工作,以确保它不会被破坏,但是死对象在没有被查看或被注意的情况下被摧毁。

<强>附录

垃圾收集器总是必须对每个活动项目起作用,以确保在存在大量活动项目时保存它的速度很慢;这就是为什么垃圾收集者在历史上得到了糟糕的说唱。 Commodore 64上的BASIC解释器(顺便说一下,微软在 MS-DOS之前的那些日子里写的)将花费很多秒来在程序中执行垃圾收集,该程序具有一些数组一百串。如果在第一次垃圾收集中存活的项目可以被忽略,直到许多项目在第一次垃圾收集中存活,并且那些参与并幸免于两次垃圾的情况下,可以极大地提高性能收集(注意,他们不必参加他们的第二个收集,直到许多其他对象幸存下来)才能被忽略,直到许多其他对象也参与并在第二个时幸存。这个概念可以部分轻松实现(即使在Commodore 64上,也可以强制在给定时刻存在的所有字符串免于未来的垃圾收集,如果在启动时程序创建的大型字符串数组永远不会有用改变)但通过一些额外的硬件支持变得更强大。

如果有人认为垃圾收集器会尝试打包尽可能接近内存末端的对象,那么代际支持只需要跟踪什么(连续)范围内存由每一代的对象使用。必须扫描每一代的所有对象以确保定位和保留所有新生代的活动对象,但不必移动老一代的对象,因为它们占用的内存不具有大规模消除的危险。这种方法实现起来非常简单,与非世代GC相比可以提供一些显着的性能改进,但如果有很多活动对象,即使是GC的扫描阶段也会很昂贵。

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

答案 2 :(得分:6)

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

  

当垃圾收集器不再处于任何范围内时,它会解除分配对象

总的来说,这是完全错误的。垃圾收集器在运行时工作在一个表示中,其中范围的概念早已被删除。例如,活体分析的内联和应用会破坏范围。

跟踪垃圾收集器在最后一次引用消失后的某个时刻回收空间。即使变量仍在范围内,活动分析也可以在堆栈框架中覆盖引用,因为活动分析确定变量从未再次使用过,因此不再需要。

  

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

性能。您可以在堆栈条目和寄存器级别引用计数,但性能非常糟糕。所有实际的引用计数垃圾收集器都将计数器减量推迟到范围的末尾,以便实现合理的(but still bad)性能。最先进的参考计数垃圾收集器推迟减量以便批量处理,并且allegedly可以获得竞争性能。

  

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

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

  

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

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

请注意,析构函数禁止尾调用,这在函数式编程中是非常有用的。

  

我很惊讶它更快或者是其他原因的首选方法。这听起来像是很多工作。

考虑通过操纵棋盘坐标列表来解决n-queens问题的程序。输入是一个整数。输出是包含几个板坐标的列表。中间数据是链表节点的巨大意大利面堆栈。如果你通过预先分配足够大的链表节点堆栈来编码它,操纵它们来得到答案,复制出(小)答案,然后在整个堆栈上调用free一次,然后你就可以了。 d几乎完全和分代垃圾收集器一样。特别是,您只需复制约6%的数据,并且只需拨打一次free即可解除其他约94%的费用。

对于一代垃圾收集器而言,这是一个完美的快乐日场景,这种收集器遵循以下假设:大多数对象死亡的年轻人和旧对象很少涉及新对象&#34;。分代垃圾收集器挣扎的病态反例是用新分配的对象填充哈希表。哈希表的主干是一个幸存的大数组,所以它将在旧一代中存在。插入其中的每个新对象都是从旧一代到新一代的后向指针。每个新对象都存活下来。因此,分代垃圾收集器可以快速分配,然后标记所有内容,复制所有内容并更新指向所有内容的指针,因此运行速度比简单的C或C ++解决方案慢〜3倍。

  

当我们想要它们时,我们不仅可以依赖于执行析构函数(其中一个Python陷阱是 del 在可预测的时间不被调用),但它会变得更容易memory-profile a program

请注意,析构函数和垃圾收集是正交概念。例如,.NET以IDisposable

的形式提供析构函数

FWIW,在使用垃圾收集语言的大约15年中,我使用了3次内存分析。

  

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

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

另请注意,引用计数在理论上渐近地比跟踪垃圾收集更快,因为它的性能与(实时)堆的大小无关。在实践中,即使使用100GB堆,跟踪垃圾收集仍然很多

答案 3 :(得分:0)

我认为表现的原因。如果在循环中创建了很多对象并在循环步骤结束时销毁它们,则执行该代码需要更多时间,然后等待程序空闲并立即释放数据。或者因为记忆力不足。

答案 4 :(得分:0)

在我遇到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,您可以从这种方法中节省大量时间

答案 5 :(得分:0)

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

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

垃圾收集器不仅仅解除分配对象,还负责完整的内存管理。从fragmentation开始,垃圾收集器的一个最大问题。如果操作不当,将导致不必要的页面命中和缓存未命中。垃圾收集器从一开始就是为处理这个问题而设计的。随着不同代的发展,处理这一问题变得更加容易。使用A[1],线程必须定期设置并处理它。

此外,事实证明清除多个对象比在A[1]中更快。 (想想看,对于一个有沙子扩散的房间 - 将所有这些一起清除的速度要快得多,而不是单独挑选每一个)

其次,对于多线程系统中的线程安全性,必须为每个对象保持锁定以增加/减少计数,这是不良的性能和额外的内存.Plus现代收集器能够并行执行而不是停止世界(例如:Java的ParallelGC),我想知道A[1]会如何发生这种情况。

答案 6 :(得分:0)

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

我真的推荐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)是一个原子操作,需要数百个内存周期。