《垃圾收集》笔记——第二章

来源:互联网 发布:js的单例模式 编辑:程序博客网 时间:2024/05/21 07:59
http://blog.csdn.net/javangin/article/details/7094120

3种经典算法:引用计数(reference counting)、标记-清扫(mark-sweep)和节点复制(copying)。


2.1 引用计数算法
是一种直接算法,其基本手段是为每个单元计算指向它的引用(来自其他活动单元或者根)的数量。优点在于能够非常简单地判断单元是否正在使用。天生是一种渐进式的技术,能够将内存管理的开销分布到整个程序之中。

每个单元都有一个额外的域,存放引用计数值(reference count)。内存管理器必须位每个单元维护引用计数值,使之等于指向该单元的指针的数量(这些指针来自其他堆单元或是根)。首先这一算法把所有的单元放在一个自由单元池中,这个池的实现常常是一个链表(每个单元有一个指针域,称之为next域,所有单元通过它链接成一条长链),连同一个指向链表表头的指针,free_list。

不一定非得位此专门增加一个next域。通常,它和保存引用计数值的域是同一个域——自由单元并不需要明确的引用计数值。或者,我们也可以使用单元中某个保存用户数据的域。

2.1.1 算法
自由单元的引用计数值是0。当一个新单元从池中被分配的时候,它的引用计数值被设为1.每次有一个指针被设为指向这一单元时,该单元的计数值加1;而每次删除某个指向它的指针时,它的计数值减1。如果单元的计数值将为0,引用计数不变式告诉我们不再存在指向该单元的指针。更进一步,程序已不再需要这个单元,可以把它放回自由单元的列表了。

// 引用计数的分配
allocate() =
 newcell = free_list
 free_list = next(free_list)
 return newcell

New() =
 if free_list == nil
  abort "Memory exhausted"
 newcell = allocate()
 RC(newcell) = 1
 return newcell

//引用计数环境下更新指针域
free(N) =
 next(N) = free_list
 free_list = N

delete(T) =
 RC(T) = RC(T) - 1
 if RC(T) == 0
  for U in Children(T)
   delete(*U)
  free(T)

Update(R, S) = 
 RC(S) = RC(S) + 1
 delete(*R)
 *R = S

2.1.3 优势和弱点
优势:
1)这类垃圾收集器在运行时不会挂起完成实际工作的用户程序,使得响应时间更加“平滑”。
2)在空间上的引用局部性(locality of reference)不会劣于客户程序。当某个单元的引用计数值变为0时,系统无需访问堆中其他页面的单元(除了该单元的后代)。
3)允许这些单元以一种类似栈分配的方式在刚被丢弃时就立刻回收重用。
缺点:
1)为了维护引用计数不变式,这类实现不得不支付高昂的处理开销。每次改写一个指针,它的旧目标单元和新目标单元的引用计数都必须进行调整。
2)基于引用计数的内存管理机制总是与客户程序或它的编译器紧密地耦合在一起。

2.1.4 环形数据结构
简单的引用计数算法最主要的缺陷是无法回收环形的数据结构。

常见环形数据结构,包括双向链表和那些包含了从叶子回到根的指针的树。
许多基于图规约(graph reduction)的延迟函数式语言(lazy functional language)的实现,采用环来处理递归。


2.2 标记-清扫算法
是一种基于追踪的垃圾收集技术:标记-清扫(mark-sweep)算法,也可称为标记-扫描(mark-scan)算法。

内存单元并不会在变成垃圾的同时立刻回收,而是保持不可到达和未被发现的状态,知道所有可用的内存都耗尽。如果此时再次出现对新单元的请求,系统会暂时挂起“有用”的程序,并调用垃圾收集例程,将对中所有当前并未使用的单元清扫回自由单元池中。

这个遍历从根除非,标明所有可以到达的单元。根据定义,这些就是存活单元。除此之外所有其他节点都是垃圾,可以送回自由单元池。

2.2.1 算法
一种可能的实现是把自由单元链接成一个自由链表。

该算法分两阶段执行:
1)标记(mark)阶段,标明所有存活单元。
2)清扫(sweep)阶段,把垃圾单元还给自由单元池。
如果清扫阶段没有回复足够多的自由单元,那么系统必须扩展堆,或者中断程序。

每个单元需保留一个二进制位让垃圾收集器使用。

标记过程不会沿着已经标记的单元追踪下去,确保标记能终止。

// 标记-清扫的分配
New() ==
 if free_pool is empty
  mark_sweep()
 newcell = allocate()
 return newcell

// 标记-清扫垃圾收集器
mark_sweep() =
 for R in Roots
  mark(R)
 sweep()
 if free_pool is empty
  abort "Memory exhausted"

// 简单的递归标记算法
mark(N) =
 if mark_bit(N) == unmarked
  mark_bit(N) = marked
  for M in Children(N)
   mark(*M)

// 对堆得急切清扫(eager sweep)
sweep() =
 N = Heap_bottom
 while N < Heap_top
  if mark_bit(N) == unmarked
   free(N)
  else mark_bit(N) = Unmarked
  N = N + size(N)

2.2.2 标记-清扫算法的优势和弱点
关键性的安全系统、实时系统,甚至是视频游戏,都不能接受在垃圾收集时有这么长时间的停顿。解决方案是在关键的时间段内禁止垃圾收集。

这一算法的渐进复杂度正比于整个堆的大小,而非仅仅正比于存活单元的数量。

这已算法还会倾向于使内存空间更破碎,让单元散布在整个堆中。

在实存储器系统中,尽管内存破碎会使采用cache带来的益处不复存在,但它对性能的影响还不是很大。而在虚拟存储器系统中,这种破碎会造成数据结构的相关单元之间失去空间上的局限性,导致系统出现颠簸(thrashing)现象:程序极为频繁地在辅助存储器和主存储器之间交换页面。

这种破碎会使得分配内存更加困难,因为必须在队中搜寻合适的空隙以容纳新德对象。

 

2.3 节点复制算法
节点复制式收集器将整个堆等分为两个半区(semi-space),一个包含现有的数据,另一个包含已被废弃的数据。

节点复制式垃圾收集从切换(flip)两个半区的角色开始。然后收集器在老得半区(Fromspace)中遍历存活的数据结构,在第一次访问某个单元时把它复制到新的半区(Tospace)。遍历完后,收集器在Tospace中建立了一个存活数据结构的复本。垃圾单元只是简单地被遗弃在Fromspace中。

节点复制垃圾收集天生的一个有益的作用:所有存活的数据结构都缩并地排列在Tospace的底部。

New过程只需要检查有没有足够的空间,然后再递增指向自由空间开始处的指针free。
由于存活数据在Tospace中是缩并的,检查空间是否足够仅仅是一个指针比较而已。
不会给像更新指针这样的用户程序操作带来额外的负担。

// 节点复制式收集器中的分配
init() =
 Tospace = Heep_bottom
 space_size = Heap_size / 2
 top_of_space = Tospace + space_size
 Fromspace = top_of_space + 1

New(n) =
 if free + n > top_of_space
  flip()
 if free + n > top_of_space
  abort "Memory exhausted"
 newcell = free
 free = free + n
 return newcell

2.3.1 算法
首先过程flip交换Tospace和Fromspace的角色,它重置了变量Tospace、Fromspace和top_of_space。接着,收集器把每个从根出发可以到达的单元都从Fromspace复制到Tospace。这里暂时使用简单的递归算法。

Copy(P)复制P指向的单元中的各个域。复制数据时必须小心地保持共享结构的拓扑,否则将会导致共享的对象出现多个副本。

最好的情况下,这将增大程序对堆空间的占用率,如果没那么幸运,这种错误可能破坏用户程序的语言(例如,如果用户程序更新了某个单元的一个副本,但却读取了另一个副本的数据。若没能保持共享,复制环形数据结构将会需要很大的空间

// 节点复制式垃圾收集中的切换过程
flip() =
 Fromspace, Tospace = Tospace, Fromspace
 top_of_space = Tospace + space_size
 free = Tospace
 for R in Roots
  R = copy(R)

在复制节点时,节点复制收集器为Fromspace中德对象保留一个迁移地址(forwarding address),以此来保持共享。迁移地址实际上是Tospace中复本的地址。

每当Fromspace中德某个单元被访问时,Copy过程会检查它是否已经被复制了,若是那么就返回迁移地址,否则就在Tospace中保留空间以备复制。

迁移地址可能存放在单元内专为它保留的域中。更普遍的做法是将它写入单元的第一个字。

假设单元P中保存迁移地址的域是P[0],并且我们等价地使用forwarding_address(p)和P[0]。P指向一个字,而不是一个单元。

// 支持可变大小单元的Fenichel-Yochelson节点复制垃圾收集
copy(P) =
 if atomic(p) or p == null --P不是指针
  return P
 if not forwarded(p)
  n = size(P)
  p' = free --在Tospace中保留空间
  free = free + n
  temp = P[0] --迁移地址将保存在第0域中
  forwarding_address(P) = p'
  p'[0] = copy(temp)
  for i = 1 to n-1 --将P中德各个域复制到P'
   p'[i] = copy(p[i])
 return forwarding_address(P)

2.3.3 节点复制算法的优势和弱点
采用节点复制技术可以极大地降低内存分配的开销:
1)检查空间是否耗尽只需要做简单的指针比较;
2)获取新内存可以简单地通过递增自由空间指针来实现;
3)由于存活数据缩并地排列在Tospace的底部,内存碎片问题将不存在。

节点复制式垃圾收集最直接的代价是使用两个半区:这样它所需要的地址空间是非复制式收集器的两倍。

一种解决方案:采用虚拟存储器的系统会将不活动的半区换出到辅助存储器上,但是这种观点忽略了换页的开销。

在垃圾收集周期内,不论用户程序的内存占用率如何,节点复制式垃圾收集器将会触及堆中的每一页。除非两个半区能同时存放在物理内存中,否则由于节点复制式收集器所使用的页的数量是标记—清扫式收集器的两倍,它将会遇到更多的缺页错误。

这一问题必须同缩并带来的好处放在一起权衡。
采用简单的标记—清扫技术,堆中的数据很可能变得更加破碎,从而导致程序的工作集(working set)所包含的页面数量增加。如果工作集达到无法容纳于主存储器,缺页率也会升高。

 

2.4 比较标记—清扫和节点复制算法
节点复制收集器最主要的缺点是必须把可用内存划分为两个半区。随着程序内存占用率的升高,收集器的性能会不断下降。虚拟存储器可以减轻这些症状:半区的大小和物理内存的大小相同(或更大),并且堆可以再必要的时候扩展。
标记-清扫收集器的性能随着内存占用率下降的速度,仅仅是节点复制收集器的一半。

节点复制算法的渐进复杂度比简单的标记-清扫算法要小:它的复杂度正比于存活数据结构的大小,而不是整个堆(半区)的大小。

节点复制收集器必须追踪并更新根集合和存活数据结构中的每一个指针,并把那些对象搬迁到Tospace中去。
标记—清扫收集器在标记阶段追踪指向存活数据结构的指针,并在清扫阶段中线性地清扫整个堆。

 

2.5 需要考虑的问题

这一章所讨论的实现都是幼稚简单的。

1)需求
客户程序能否容忍由内存管理器造成的中断
引用计数算法的操作与用户程序的指令交织在一起,在总体上提供了更平滑的响应。

2)即时性
第一、它允许变成垃圾的空间能够立刻回收重用
第二、面向对象语言常常支持终结机制,可以再一个对象死亡时调用一个用户定义的过程。终结机制最典型的范例是,在指向一个文件的最后一个引用销毁时关闭这个文件。

3)环形数据结构

4)根和指针搜索
基于追踪的垃圾收集器需要能够找到程序中所有的根,而且可能需要找到存活数据结构中所有的指针。而像节点复制收集器这样的搬迁式收集器,必须既能定位所有的根从而能够追踪到全部存活数据,又能找到存活数据结构中所有的指针从而能够更新它们使之指向新德位置。

5)实现
在选择垃圾算法时,除能否符合客户程序和环境的需求之外,性能也是一个重要的因素。
性能可以通过多个方面衡量:给用户程序操作带来的开销,在分配和收集上花费的时间,或是收集器直接占用的空间和加在用户数据上的额外的空间。

6)处理代价
引用计数与用户程序耦合紧密,带来两个后果。
第一、引用计数给用户程序的每一个指针操作都带来了额外的开销。
第二、简单的、非分代式的追踪式收集器并不会给客户程序的操作带来额外开销。
引用计数收集器和标记—清扫收集器一般采用自由链表的某个变种来管理可用的自由空间池。因此存在堆内存破碎问题。内存碎片不但会弱化存活数据的局部性,而且会使分配可变大小的对象更加困难。

7)空间开销

8)堆占用率和收集器的退化


原创粉丝点击