编译器构造/运行时考虑因素
在计算中,当开发人员找到能够支持它们的科技时,总会有新工具等待出现。一个很好的例子是,50 年前,John L. McCarthy 想出了一个想法,可以在执行 Lisp 程序时自动回收不再需要的对象的内存。它是计算机科学中垃圾回收概念的起源。
在运行时执行的任务之一 - 当用户运行已编译的程序时 - 是管理分配的内存块,以便通过释放不再使用的块来最大限度地减少内存使用。这被称为“垃圾回收”。垃圾回收并非所有语言和编译器都提供,但它已在许多最广泛使用的语言和编译器中实现。当正确实现时,它可以将内存管理的负担从程序员身上卸除,并提高性能。
理想情况下,垃圾收集器(以下简称 GC)会删除所有永远不会再使用的分配。在实践中,我们可以假设,如果存在任何引用块的方法,它可以再次使用;如果不是,它将不会被使用(除了意外)。因此,GC 通过跟踪某个时刻的每个引用路径来保留可以访问的内存块,并释放其余内存块。例如,
function concat (string1, string2) { new_string = alloc (string1.length + string2.length); copy (string1, new_string); copy (string2, new_string + string1.length); return new_string; }
此函数返回一个新分配的内存块,它是给定两个字符串的连接。因为该函数只返回一个新字符串,并且不知道它将如何使用,所以调用该函数的函数负责释放它,例如
var old = null var string = 'Writing compilers is ' if you have not finished this book { string := concat (string, 'anything but ') old := string } string := concat (string, 'fun!') if old != null { free (old) }
通过让 GC 在需要时释放内存块,以上可以简化为
string = 'Writing compilers is ' if you have not finished this book { string := concat (string, 'anything but ') } string := concat (string, 'fun!')
在实践中,执行路径和引用依赖关系可能比上述要复杂得多;因此,应该很容易想象 GC 将是多么大的帮助。
在最初,有静态分配,一段时间内,它很好。FORTRAN(大约在 1950 年代中期)根本没有垃圾回收。一旦分配了一块内存,它就会一直保持分配状态。程序员无法取消分配它,即使他们尝试了也是如此。
大约在 1958 年,Algol 实现了称为堆栈分配的内存管理形式。随后,像 C 这样的语言实现了堆分配,它允许程序员随意从可用内存堆中分配和取消分配内存。在 C 中,这是通过调用 malloc()
完成的。虽然这允许程序员非常灵活地分配和取消分配内存,但粗心(误用)经常会导致内存泄漏和悬空指针;程序员错误不会由编译器或运行时环境处理。
为了克服程序员错误的障碍,我们必须要么更好地指导我们的程序员,要么为内存管理提供更好的系统。在美国似乎涌现的一系列非认证技术学院显然对解决前一个问题毫无作用,因此,我们必须着手解决后一个问题。
垃圾收集有两种基本方法:引用计数和批处理(或跟踪)。
在引用计数中,引用计数器与每个分配的对象相关联。每当对该对象进行引用时,计数器都会递增。当取消引用该对象时,计数器就会递减。当计数器达到 0 时,就不存在对该分配对象的现有引用,因此可以安全地将其删除(因为将来无法访问它)。
当创建对象 O 时,我们还为 O 创建一个引用计数器,称为 rc[O]
。在创建时,rc[O] = 0
。当我们创建对 O 的引用时,我们执行 rc[O]++
。当我们销毁对 O 的引用时,rc[O]--
。当我们递减时,我们会检查计数器是否现在为 0。如果是,我们释放 O。
为了允许分配内存,我们维护一个空闲内存块列表。当我们分配块时,我们从空闲列表中删除它们。当我们取消分配时,我们将其添加到列表中。显然,这种分配方法的主要缺点是碎片化;必须对内存进行碎片整理以允许以请求的大小分配内存,这会导致内存分配莫名其妙地变慢。为了防止这种情况,可以在某些设定时间间隔内对内存进行碎片整理,或者执行更复杂的取消分配以使内存保持连续,但解决这个问题的方案可能并不简单。
引用计数的主要优点是易于实现,不需要在任何时间间隔内进行工作,因此避免了暂停当前操作来整理垃圾收集的必要性(这会导致在看似普通的操作期间出现难以解释的暂停)。
但是,引用计数有一些严重的局限性,即它无法检测循环指针(例如,A 引用 B,B 引用 A,在这种情况下,A 和 B 都不会被收集),它对指针操作和空间的使用成本,以及由于碎片化导致的分配计算成本。其中一些问题可以通过增强引用计数来解决,但有些问题最好通过使用其他形式的垃圾收集来解决。
在批处理中,堆被视为一个有向图,指针是分配空间(我们将其视为图中的节点)之间的边。要进行垃圾收集,我们遍历图并标记我们遇到的每个节点。如果一个节点没有被标记,它就无法被访问,可以安全地取消分配。
与引用计数不同,这种方法必须在特定时间执行(当然,引用计数是在普通分配和释放的特性中执行的)。垃圾回收——当它必须在特定时间执行,而不是像引用计数那样简单地随着程序运行时执行——可以在存储空间耗尽时执行,在需要快速运行的代码段之前执行,或者简单地在计算机无事可做时的空闲时间执行。最佳方法在一定程度上取决于运行的程序类型;例如,在 Microsoft Word 中,有足够的空闲时间(用户坐在那里思考有趣的括号语句时),因此第三种选择是最好的。然而,在实时系统中,最好总是在某些代码运行之前进行垃圾回收。
批处理依赖于对内存指针的准确识别。这里有许多问题;看起来像整数的东西可能是指针,而看起来像指针的东西可能不是指针。可以专门设计一种语言来避免这种混淆,但许多流行的语言,如 C 和 C++,并非如此设计。编译器可以在编译时将用作指针的任何东西标记为指针,但这同样需要额外的开销。一些相同的方法可以在运行时应用,但会带来额外的运行时成本。然而,无论如何实现这一点,重要的是对指针识别保持谨慎。有疑问时,最好假设某个东西是指针,而不是不是指针。毕竟,拥有额外的未收集垃圾比消除稍后将使用的块要好得多。