2

最近面试的时候,提到引用计数(因为刚好在看 Python),立马被面试人员反驳引用计数是很老的东西。虽然大致上知道,每一种垃圾收集策略都是几种垃圾收集算法的组合,不存在过时的算法。无奈没有系统的了解过各种垃圾收集算法,怼不回去。于是决定系统的了解一下各种 GC 算法。

基本概念

对象: GC 中的对象,GC 中的对象代表程序使用的数据,对象有 head 和 field 属性。head 中主要记录对象的大小,对象的种类和 GC 算法所需的信息。field 中记录的就是程序要访问的数据,可以是一个指针或是数字,字符串等基本类型。

mutator: mutator 指的就是应用程序,应用程序会申请内存。

:由 GC 管理的内存区域。

活动对象:mutator 能引用到的对象。

非活动对象:mutator 不能引用到的对象。

root:可以想象为全局变量空间或是堆外内存中定义的对象,保存在堆中的对象都是从根一步一步引用的。就如 JavaScript 中定义的对象都是从全局变量(浏览器中的 window)一层一层引用到的。

clipboard.png

标记-清除法

标记清除法如同它的名字一样,有标记和清除两个阶段组成。

标记阶段

标记阶段就是从根对象开始,遍历所有能引用到的对象(活动对象),并给这些对象打上标记,在对象的 head 中标记。

从根对象开始,需要经过几次引用才到当前对象,对应的就是 chrome dev 工具里 memory 分析中对象的 distance 属性。

clipboard.png

标记阶段伪代码:

// 遍历 root 中的对象
markPhase () {
  for (obj: root) {
    // 打上标记
    mark(obj)
  }
}

mark (obj) {
  // 先检查标记是否为 FALSE,这是因为存在循环引用的情况。如果不检查标记,则会陷入死循环。
  if (obj.mark == FALSE) {
    obj.mark = TRUE
    // 递归的标记自己能引用到的对象
    for (child: obj.children) {
      mark(child)
    }
  }
}

标记阶段完成后,堆中对象状态:

clipboard.png

清除阶段

清除阶段把那些没有标记的对象(非活动对象)回收的阶段,回收就是记录下这些非活动对象的指针首地址和大小,之后分配内存的时候就可以在这些记录的内存地址片段中找一块大小合适的内存分配。

清除阶段伪代码

sweepPhase () {
  // 遍历整个堆
  sweeping = heap_start
  while (sweeping < heap_end)
    // 初始化标记,为下一次 GC 做准备
    if (sweeping.mark == TRUE)
      sweeping.mark = FALSE
    else
      // 如果当前指针与上一个空闲指针相邻,执行合并操作
      if (sweeping == freeList + freeList.size)
        freeList.size += sweeping.size
      // 将空闲指针添加到链表中
      else
        sweeping.next = freeList
        freeList = sweeping
    // 指向堆中下一个指针
    sweeping += sweeping.size
}

清除阶段完成后,堆中对象状态:

clipboard.png

从图中可以看到,清除阶段完成后,可用的内存呈碎片化分布在堆中。这时候需要把相邻的可用内存片段连接在一起,就是伪代码中的合并操作。

优缺点

优点是标记清除算法实现简单,兼容性好可以与其他算法组合一起使用。缺点就是碎片化,回收的可用内存散布在堆的各处,再次分配时,需要耗费时间从空闲链表中找到一块大小合适的内存。

一些优化措施

上面讲到标记清除算法的碎片化,需要耗费时间在空闲链表中找到合适的内存空间。一种优化手段就是用多个空闲链表分别记录不同大小的可用空间。需要分配 2 个字节就去记录 2 个字节的链表中寻找,需要分配 8 个字节,就去记录大于等于 8 的链表中找。比起只利用一个空闲链表来说,此方法大幅节约了分配所需要的时间。

引用计数法

前面说到的标记-清除法,是从根对象开始遍历,将无法被引用到的对象收集起来。那么如果在对象的 head 中用保存对象被引用
的次数,不就可以在次数为 0 的时候,回收该对象了。这就是引用计数法。引用计数法的一大特点是,不需要专门执行 GC,在更新
对象引用的时候,就知道引用次数,如果次数为 0 就可以直接回收了,也就没有执行 GC 时候的 Stop the World(执行 GC 的时候,一切其他活动都要暂停)问题了。

增加和减少引用数

有两种情况会增加或减少引用数,一种是创建一个新对象时,另一种是改变对象引用时。

a = new Object // a 对象引用次数为 1
b = new Object // b 对象引用次数为 1
b.a = a // a 对象引用次数为 2
b = null // b 对象引用次数为 0,回收 b 对象;a 对象引用次数为 1

增加对象引用的伪代码很简单,只要递增对象的计数器就行了

  inc_ref_count (obj) {
    obj.ref_count++
  }

减少对象引用的时候,如果引用计数为 0 ,则递归的把子对象的引用全部减 1,然后立即回收该对象占用的内存, 这是在更新对象引用的时候完成的,这样就不需要像标记清除法那样,要花费时间专门去执行 GC。

  dec_ref_count (obj) {
    obj.ref_count--
    if (obj.ref_count == 0)
      for (child: obj.children)
        dec_ref_count(child)
      reclaim(obj) // 立即回收内存
  }

优缺点

引用计数法无需暂停程序执行,可即刻回收内存。但是也有实现复杂,不能解决循环引用,计数器需要耗费额外的内存。

一些优化措施

引用计数法,需要在对象 head 中记录引用次数,会耗费大量的内存。比如 32 位机下,CPU 寻址空间是 2 ^ 32,极端情况下一个对象可能被 2 ^ 32 个对象引用,需要要用 4 个字节记录引用次数。一种解决方法是减少计数器位数,对于引用次数超出计数器范围的对象,不用引用计数法来管理。在适当的时机,使用标记-清除法来清除这些引用计数法不能回收的部分。这样的组合使用,既解决了标记清除法的 mutator 暂停时间长的问题,也解决了引用计数耗费内存的问题。


huaguzheng
496 声望1 粉丝