垃圾收集算法-简介

垃圾收集算法

翻译内容来自CMPSCI 377 Operating Systems Lecture 18
GitHub地址

1 前言

动态内存分配器位于应用层和操作系统之间,用于管理堆对象。
当一个程序从分配器请求内存时(如:malloc()),分配器将返回一个指向适当大小的内存块指针(或引用)。当程序用完内存后,应当释放,返回给分配器。诸如C 和C++ 这样的语言,将这个操作交给程序员手动处理,如通过free()进行手动释放。另一方面,像Java、Python等语言会自动管理动态分配的内存,这样使得程序员更加轻松,并且可以排除内存管理的bug。

虽然使用free()delete 相对简单,但是要使用正确可能会很难。在C 和C++ 程序中,一些常见的bug都与手动管理内存有关。如果我们忘记释放对象,那么最终会导致内存泄露;如果我们过早的释放对象,那么最终会导致空指针(dangling pointers);此外,如果我们还可以尝试做一些奇怪的操作,比如执行两次释放(free())等。因此,自动管理内存,是非常有用的。

执行垃圾收集时,需要判断对象是否存活着。存活对象(live object)的定义:还可通过一个或多个指针访问的任何对象。

让我们来分析下面的C++代码:

node x = new node ("happy");	// note: class node implicitly is a pointer
node ptr = x;
delete x;
node y = new node ("sad");	 	// can allocate into same space x pointed to!
cout << ptr->data << endl; 		// can print "sad"

即使在调用delete后,x变量指向的内存块仍然是存在的,,因为可以通过ptr 访问他。但是C++ 并没有跟踪它。调用delete之后,分配器认为之前属于x的内存块为空闲,所以当y分配新空间时,x指向的空间可以重用(ptr还指向着该空间)。这种类型的程序可能导致潜在的、难以返现的bug。

2 垃圾收集算法

任何垃圾收集算法,都必须收回无任何指针指向的对象。这些对象显然是无用的,他们的内存块可以返回给系统。即使从程序员的视角来看,垃圾收集是非常有用,但是他它可能降低缓存性能和页面位置。

所有垃圾收集算法都有一个主要的问题是:“我们如何知道某些对象是否仍然可到达(reachable)?”。明确判断这一点的一些经典算法是标记扫描算法引用计数半空间(semi-space)

2.1 标记扫描算法(Mark-sweep)

程序可以直接访问的对象,是由处理器堆栈上的局部变量引用的对象,或由引用对象的任何全局/静态变量引用的对象,或由CPU寄存器中的变量引用的对象。在垃圾收集的上下文中,这些变量称的根(roots)。如果对象被其他(直接或间接)可访问对象中的字段引用,则该对象是间接可访问的。一个可访问的对象被称为活动对象。相反,非活动的对象是垃圾。请注意,活动的堆对象可以从根(roots)或其他堆对象间接访问。

标记扫描的想法相对简单。我们从根(roots)开始,递归地修改每一个可通过指针访问的对象,将它们标记为活动对象。递归结束时,将所有未标记的内容都视为垃圾,并被删除。注意,Mark-sweep可以执行惰性垃圾收集,因为它不一定需要立即删除垃圾。

注意:Mark-sweep不会清理已分配的内存,而只是清理从未使用过。并且我们要周期性的递归访问所有对象,从根(roots)开始。对于一个大程序来说,这是很慢的。这是传统Mark-sweep算法的问题。

2.2 引用计数(Reference counting)

引用计数的思想就是为每一个对象都记录引用数量。即,指向对象的指针数量。每当引用数量为零时,我们就知道对象无法通过任意指针来访问,因此它是垃圾。此外每当我们选择删除一些未使用的对象时,我们要递归地检查对象,看他们是否包含指向其它对象的指针,如果有,那么我们需要减少这些对象的引用计数。

引用计数也存在一个问题,如何处理循环?假设对象A 指向B ,且B 指向A 。如果这俩对象只能通过对方到达(reached),我们必须释放它们,即是它们的计数器不是零!注意,这种循环,在许多数据结构中可能很常见;例如,考虑一个双链表。这个问题通常采用的解决办法是,时不时的执行标记扫描算法(mark-sweep),以删除循环引用,然后在剩余时间运行正常的引用计数算法。

引用计数分散了垃圾收集的开销。传统的标记扫描算法是一次性完成所有的垃圾收集操作,然而,引用计数算法,每一个引用操作都有一个额外的开销,从而及时分散垃圾收集成本。相比传统的标记扫描算法,引用计数在大型程序中,还是更好。

2.3 半空间GC(Semi-space GC)

半空间工作的主要思想是,维护两个不相交的内存区域可供分配。这些区域被称为from-space 和to-space。

首先,该算法只从from-space空间分配内存,且不用担心垃圾收集。(通常是使用简单的指针碰撞来执行分配,这简化了整个过程)。当我们用完from-space中的空间时,我们会找到所有可通过任意方式达到(reached)的活动对象,将他们移动到to-space空间,并且更新所有指向活动对象的指针,让他们指向to-space空间中新的位置。因此,半空间被称为复制收集器(copying collector)。在完成所有移动过程后,只有to-space空间有活动对象。

然后,现在我们可以在to-space空间上执行分配了,当to-space空间耗尽时,再次重复上面的过程。

这个算法的有点,是可以压缩内存中的对象,从而增加局部性和碎片最小化。另外,当从其中一个空间执行分配是,他可以使用简单的指针碰撞,这是非常快的。然而,这个算法将内存需求翻了一倍。

2.4 分代式GC(Generational GC)

分代GC是复制收集器(copying collectors)的一种优化。它的关键是,假设大多数对象在年轻代就无引用了(这被称为:分代假设generational hypothesis),当这个假设成立时,我们可以优化这种常见情况的GC。为此,我们首先在一个很小的幼儿(nursery)标识的内存区域分配一个对象,我们可以在这个区域中频繁的进行收集,其中大部分都是垃圾。还要注意的是,由于他们中大部分的确是垃圾,所以使用标记扫描(Mark-sweep)等方法遍历它们会非常快。在幼儿(nursery)标识的内存区域中执行完GC后,我们将所有幸存者(survivors)复制到成熟(mature)标识的内存空间中。我们假设所有在幼儿(nursery)标识的内存中存活下来的对象,都能存活很长一段时间。注意,我们仍然要在成熟(mature)标识的内存空间中进行垃圾收集。然后,由于这些对象不太可能很快的无活动,我们不必像在幼儿标识的内存中那样频繁GC。一个关键的思路是,我们需要跟踪从幼儿内存到成熟内存的指针。

2.5 保守式GC(Conservative GC)

保守式GC可用于C 和C++这样的语言,这些语言没有为垃圾收集明确设计。这是一种非复制技术。保守式的垃圾收集不知道每个对象的具体位置,而是寻找可能是对象的区域,并扫描这块内存区域来发现活动对象集。因为C 和C++ 允许类型转换(casting),任何一个可以持有指针的东西都可以是一个指针,例如,unsigned long是一个可转类型的指针、float int也是一个可转类型的指针,等。保守式GC或多或少使用“鸭子测试(duck test)”,这里解释为“如果它看起来像一个指针,行为也像一个指针,那么它可能是一个指针”。从根(roots)开始,我们可以找到所有看起来像指针的对象,如果它们是指向对象的真实指针,接着,将这些对象标记为活动对象。通过这样的处理,我们可以为不支持它的语言提供垃圾收集。

实际上,在找到的对象中,有的是对象,有的不是,但是我们可以确保垃圾收集器找到了从该特定区域引用的所有活动对象。既然我们必须发现可能是对象的区域,我们还必须知道如何识别指针;通常,如果某个对象看起来像一个指针,那么我们就假设它是一个指针。然后,保守式GC再通过跟踪这些“指针”,来标记所有仍然活动的对象。

这种方法的一些缺点是:

  1. 内存区域看起来像指向对象的指针,但实际不是,只要伪指针存在,就会保留垃圾对象;这会增加垃圾收集器的内存使用,并可能导致其它有限资源耗尽。
  2. 准确找到要跟踪的区域的方法并不总是可移植的。

2.6 比较GC 和malloc

GC用空间换取时间。如果我们所有时间都采用收集(这需要大量的处理时间),GC分配器将尽可能少的使用内存。另一方面,如果我们有大量空间可使用,我们可以几乎不进行收集,因为这样非常快。一般来说,与显示的内存管理,如果有足够的额外内存,GC几乎不需要额外的时间。例如,如果我们想花费比平时多4倍的空间,那么垃圾收集器可以和显式内存管理一样快;另一方面,如果我们提供的内存空间太少,那么它将频繁的运行,因此比显式内存管理需要更多的处理时间。

面对这种权衡,我们什么时间应该使用垃圾收集器而不是手动内存管理?

  1. 如果,你有很多内存,通常使用垃圾收集器
  2. 如果想避免与内存管理相关的bug或额外的编程工作;如果硬件资源是限制因素而程序员不是限制因素,那么手动内存管理可能是一个更好的主意。
posted @ 2021-09-11 18:55  AmazeBug  阅读(72)  评论(0)    收藏  举报