Andrew W. Appel:垃圾收集可能比堆栈分配更快

Andrew W. Appel:垃圾收集可能比堆栈分配更快

一种非常古老和简单的垃圾收集算法,当物理内存远远大于可访问存储单元的数量时,可以得到非常好的结果。实际上,通过增加物理内存的大小,从堆中分配和收集单元相关的开销可以减少到每个存储单元中少于一条指令。特殊的硬件、复杂的垃圾收集算法和花哨的编译器分析变得没有必要。

1. 引言

许多现代编程环境使用堆存储和垃圾收集。从运行时堆栈中无法访问(通过指针链)的已分配存储单元是“垃圾”,它们被“收集”以供遍历算法重用。该算法通常使用深度优先搜索标记所有可到达的存储单元,然后收集所有未标记的存储单元;这将花费与总单元数(可到达和垃圾)成比例的时间。

在没有垃圾收集的语言中(如Pascal、C等),程序员必须编写记录代码来跟踪堆分配的单元,并在不再需要它们时显式地释放它们。这将使程序变得更加复杂。

在具有垃圾收集的语言中(LISP、Mesa、Icon、ML、Mainsail等),程序员不必担心已分配存储单元的记录;这使得程序更简单、更直观。另一方面,由于垃圾收集器的速度通常很慢,开销也很大,这些更简单、更直接的程序通常效率更低,因为它们使用垃圾收集。

为垃圾收集语言优化编译器的一个趋势是让编译器(静态地)推断哪些单元可以被释放。这种方法很好地解决了一些存储单元会自动释放的问题,从而减少了垃圾收集器的负载。但是复杂性是在优化编译器中,而不是在编译后的程序中。

本文表明,在计算机上有足够的内存时,显式释放一个单元比将其留给垃圾收集器要昂贵得多,即使释放一个单元的成本只是一条机器指令。

2. 垃圾收集复制算法

传统的标记-清除算法将所有空闲单元放到一个链表中供以后重用,其花费的时间与可到达单元的数量和垃圾单元的数量成比例。然而,出于本文的目的,我们需要一个时间复杂度独立于垃圾单元数量的算法。

复制垃圾收集器最简单的形式是在两个大小相等的内存空间中工作,一次只使用其中一个内存空间。当需要进行收集时,垃圾收集器将遍历活动空间中所有可到达的单元格,并将它们复制到非活动空间中。然后交换这两个空间;也就是说,现在使用另一个空间,而之前的活动空间将被留空,直到下一次垃圾收集。

可达单元的遍历可以使用深度优先搜索来完成,它花费的时间与可达单元的数量成比例。除了与所复制的单元格的总大小成比例的时间之外,复制每个单元格需要一个恒定的开销。

复制垃圾收集算法的一个最重要的特性是它从不访问垃圾单元,因此垃圾收集器的执行时间只依赖于可到达单元的数量(和大小),而与垃圾的数量无关。

一次垃圾收集的成本可以计算如下:设A为可达单元数,设s为每个单元的平均大小。设M为两个内存空间的大小。深度优先的遍历和复制要求每个单元的操作数量为常数,加上每个指针的操作数量为常数:(c1 + c2 * s) * A。垃圾收集只是深度优先遍历加上复制;所花费的时间与M无关。

假设在每个垃圾收集时可到达的单元的数量大致相同,那么我们可以计算垃圾收集的每个单元的成本。也就是说,我们可以计算每个单元的垃圾收集开销。

为此,在垃圾收集之间分配的单元格数G除以垃圾收集的成本。(既然我们假设A变化不大,那么G也是垃圾单元的数量。)但是,我们可以从上面的参数计算G,因为我们要等到活动空间满了才进行收集:

G = M/s - A

这只是在“永久的”可到达的单元被复制到那里之后,活动空间中剩余的单元数。

现在我们可以计算每个单元的垃圾收集成本:

g = ((c1 + c2 * s) * A)/(M/s -A)

只要看一下这个公式就会发现,通过使M变大可以使g变小。

3. 显式释放更昂贵

在本节中,我们将说明,如果有足够的可用内存,那么优化编译器在堆栈分配方面的工作实际上会使编译后的程序运行得更慢。

让我们假设从堆栈中取出一条记录或显式释放一个垃圾单元的成本是某个常数f。(事实上,前一种操作通常比后一种操作代价更低,但只要每一种操作都是一个常数,那么分析就是相似的。)

在什么情况下,显式释放操作的成本f大于垃圾收集这个存储单元的成本g?交叉点是:

f = g
f = ((c1 + c2 * s) * A)/(M/s - A)
f = (c1 + c2 * s)/((M/s*A) - 1)
M/s * A = (c1 + c2 * s)/f + 1

如果 M/s * A 比这个值大,那么垃圾收集就比显式释放代价更低。

作为一个例子,假设常数ci、f和s的值,在一些虚构的实现中:

c1 = 3 指令
c2 = 6 指令
s = 3 字
f = 2 指令

然后我们发现交点在

M/s * A = 7

这是物理内存与(平均)可达数据的比率。如果使用的内存是现有数据的7倍,那么垃圾收集基本上就是免费的。

实际上,M实际上是机器内存的一半,因为它是两个空间的大小。复制算法的一些改进使用了N个空间,一次只使用其中一个,因此不需要这个额外的因素2。此外,如果数据的大小确实比每个空间的大小小得多,那么可以使这些空间重叠:任何时候都需要的最大内存是 M + s*A,而不是2M。给定一个使用2mb真实数据的程序,我们所需要做的就是在大约16mb的物理内存中运行它,以实现“免费”垃圾收集。

4. 大型内存机(The Massive Memory Machine)

普林斯顿大学的大型内存机项目被设计用于实验测试随着内存的增加性能的改善。当前的计算机是128MB的VAX-11/785.

为了确认上一节中预测的垃圾收集时间会随着内存的增加而急剧减少的结果,我们进行了一个简单的实验:用 Edinburgh Standard-ML 系统编译2277行 Standard-ML 代码。这个系统是为可移植性而不是最优性而设计的,所以它不是特别快;它的垃圾收集器是用C语言编写的,而不是用汇编语言;但即便如此,它还是令人信服地证明了海量内存的优势。

相同的编译完成了四次运行,但内存大小不同:4、8、16 和 64 MB。

MemoryMCPU timeGC time# GC’stime/GCM/sA − 1 (typical)
4 Meg2 Meg663 sec232 sec347 sec0.5
8446736752.5
1684398336.7
643243100*29

因为使用了两个空间内存组织,所以运行4兆字节实际上一次只使用2兆字节,依此类推。CPU时间是进程的用户时间;所有的运行都有相似的系统时间。仅从其他时间减去64 MB运行时间即可计算出GC时间(因为上次运行从未被垃圾收集);由于缓存的影响,这可能不是完全准确的,但它应该是接近的。

垃圾收集的数量按预期减少;更重要的是,每次垃圾收集的平均时间不会像M那样增加;它实际上减少。这可能是由于在第一次运行时,当s*A接近M时,垃圾收集器开始“抖动”,在几乎没有垃圾可收集时重复收集。

请注意,最后一列M/s*A-1度量的是多余内存与可访问数据的比率,它在M中并不是严格的线性关系。这是因为测量是在垃圾收集时进行的;垃圾收集在不同的运行中发生在不同的点。

该表似乎支持这样的结论:有足够的内存,就永远不会调用垃圾收集器,因此可以节省时间。人们可能会认为64MB的运行仅对校准测量有用,但得出的结论要强得多:随着内存的增加,垃圾收集的数量也相应减少,每次垃圾遍历的时间也没有增加。

第2节预测垃圾收集时间应该与 M/s*A − 1 成反比。1/g 与 M/s*A − 1 的曲线图支持这样的结论,即垃圾收集总时间大约与过剩的内存成反比:

在这里插入图片描述

5. 从堆中分配

前一节说明通过垃圾收集释放堆单元比释放堆栈更便宜(如果有足够的内存)。现在考虑分配一个存储单元的成本。在LISP中,这是用(cons A B)来表示的,意思是“分配一个包含值A和B的两个值的存储单元,并返回一个指向它的指针”。假设在任何方案中,A和B的值都必须存储在内存中;除了存储A和B的指令之外的任何指令都算作开销。

使用压缩的垃圾收集器,未分配的内存总是一个连续的区域。也就是说,没有 free 列表;相反,有一个空闲的内存区域。函数(cons A B)可以用这些机器指令来实现:

  1. 根据 free 空间的限制检查 free 空间指针。
  2. 如果达到了限制,则调用垃圾收集器。
  3. 将 free 空间指针减去2 (cons存储单元的大小)。
  4. 将A存储到新存储单元中。
  5. 将B存储到新存储单元中。
  6. 返回 free 空间指针的当前值。

这个代码序列假设从较高的地址开始分配单元,并向较低的地址移动。

这个指令序列似乎比将A和B压入栈的相应指令要昂贵得多。但是我们可以使用计算机的虚拟内存硬件来完成第一行的测试。如果一个不可访问的页面恰好在free空间之前映射到该区域,那么任何试图将其存储在那里(第4行)的操作都将导致页面错误。这个错误可以返回到运行时系统,该系统将启动垃圾收集。

free 空间指针可以保存在寄存器中,以简化对它的访问。此外,在VAX上,free 空间指针的减法可以通过自动减量寻址模式来实现。(cons A B)的新指令序列是这样的:

  1. movl A,-(fp)
  2. movl B,-(fp)

此时,指向新存储单元的指针在 free 空间指针(fp)寄存器中可用,以供适当使用。由于这两条指令占用的时间并不比其他任何一对存储到内存中的时间多,因此从堆分配的开销正好为零。实现cons的这两条指令序列与推送值A和B的序列相同。因此,从堆中分配单元与将单元推入栈的成本是相同的。通过对这个简单的想法进行适当的修改,过程调用帧可以像在堆栈上分配一样廉价地进行堆分配。并且,如前一节所示,可以使计算单元的回收开销接近于零;从栈弹出至少需要一条指令。

6. 评价和结论

与程序员必须始终显式地将存储单元返回到堆的编程风格相比,具有垃圾收集的编程语言允许一种更简单、更干净的编程风格。人们很容易相信,为了使编程变得如此简单,人们必须在效率上付出代价;这种显式的存储单元释放虽然痛苦,但产生一个更快的程序。但事实并非如此。即使使用旧的、简单的垃圾收集算法,垃圾收集的成本也可以忽略不计。LISP程序员不顾一切地避免cons,是在不必要地混淆他们的程序。

本文分析的算法是一种stop-and-copy算法,不是并发算法。因此,它没有保证响应时间的上限,这在实时环境中是一个缺点。但是,该算法的垃圾收集开销总量可能比Baker’s这样的并发算法要小得多,后者为每个计算单元分配了若干条开销指令。

还有其他一些算法的成本也会随着系统中内存的大小而变得更低;Lieberman-Hewitt算法的非并发版本就是一个例子。任何包含这种垃圾收集器的系统都会随着内存变得更便宜和更大而自动提高速度。但是,在一个可以用不到4000美元获得50MB内存芯片的时代,对复杂的垃圾收集算法或特殊的垃圾收集硬件的需求就会减少。诸如引用计数、短暂垃圾收集、闭包分析等技术,现在可能不需要使用大量内存了。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值