垃圾回收之Python PHP Java Go对比

930 阅读13分钟

本文对比了四种语言在垃圾回收方面的实现,其目标都是相同的,即希望做到准确又高效的识别和清理内存中的垃圾对象,不同语言之间在实现思路上有相似之处,又各自有不同的侧重点。

常见的垃圾回收算法

引用计数

给每个对象结构体附加一个引用计数的属性,当对象被赋值或引用时会增加引用计数,当对象销毁时减少引用计数,当引用计数变为 0 时回收。

  • 优点:实现简单,性能良好
  • 缺点:无法识别循环引用的情况
  • 代表语言:Python、PHP

标记-清除

从内存中一组 root object 根对象开始向下遍历并标记所有可能访问到的对象,即可达对象,相反没有被标记的对象即为不可达对象,标记完成后将不可达对象清除。

  • 优点:可以解决循环引用问题
  • 缺点:需要 STW (Stop The World)暂停程序的执行,有性能损耗,这也是大部分标记清除类算法都在试图优化的地方。
  • 代表语言:Go 的三色标记法是标记清除的变体;Python 和 PHP 也都有各自的标记清除变体实现,主要为了解决循环引用的问题。

分代回收

针对对象的生命周期长短不同将其划分到不同代,如年轻代,老年代等;不同代采用不同回收策略,例如年轻代的对象可能刚分配不久就不再使用应该可以被回收,所以年轻代触发 GC 较为高频,老年代的对象可能有历久弥坚的特性,一直存活到最后,所以触发 GC 较为低频。总的来说分代回收针对不同特点的数据启用不同策略,缩短 GC 时间。

  • 优点:减少 STW 时间,性能较稳定
  • 缺点:实现逻辑较复杂
  • 代表语言:Java 是典型的分代回收的例子;Python 使用简化的分代回收策略来提升回收效率

复制回收

将内存分为两块,每次只使用其中一块。垃圾回收时,将存活对象从一个块复制到另一个块,然后清除未复制的块。

  • 优点:可以快速回收对象,且没有内存碎片
  • 缺点:需要额外的内存空间,复制对象时开销较大
  • 代表语言:Lisp、Smalltalk

Python 的垃圾回收

不同的 Python 解释器实现有不同的垃圾回收方式,在 CPython 中以引用计数为主,附加标记清除的变体解决循环引用问题,另外附加分代回收提高垃圾回收的执行效率。

以引用计数为主:对象链表 refchain 和对象的引用计数 ob_refcnt

Python 中使用 refchain 双向循环链表维护所有对象,在对象的结构体中, ob_refcnt 是引用计数器

Python 对象的结构示意:

              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ \
              |                    *_gc_next                  | |
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | PyGC_Head
              |                    *_gc_prev                  | |
object -----> +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ /
              |                    ob_refcnt                  | \
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | PyObject_HEAD
              |                    *ob_type                   | |
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ /
              |                      ...                      |

使用标记清除的变体解决循环引用问题

循环引用只可能发生在容器类对象中,如 list、set、dict、类实例等,为了识别并处理循环引用,Python 维护了两个双向链表,一个包含所有要扫描的对象 Objects to Scan,另一个包含暂时无法访问的对象 Unreachable。

Python 中循环引用例子

>>> import gc

>>> class Link:
...    def __init__(self, next_link=None):
...        self.next_link = next_link

>>> link_3 = Link()
>>> link_2 = Link(link_3)
>>> link_1 = Link(link_2)
>>> link_3.next_link = link_1
>>> A = link_1
>>> del link_1, link_2, link_3

>>> link_4 = Link()
>>> link_4.next_link = link_4
>>> del link_4

# Collect the unreachable Link object (and its .__dict__ dict).
>>> gc.collect()
2

上述代码示意图如下:

两个链表如图所示,其中每个对象的 ref_count 是对象真正的引用计数,gc_ref 的值与 ref_count 相同,用于辅助 GC 使用,目的是为了在 GC 时修改而不影响原本的引用计数。

当 GC 开始时将 Object to Scan 链表中所有对象的 gc_ref 减 1,这一步可以消除容器对象之间的引用。

如果此时 gc_ref>0,说明还有容器对象之外的引用指向这个对象,即该对象是可访问的。可访问对象引用的对象也被视为是可访问对象,而其他 gc_ref=0 的对象被移动到 Unreachable 链表中

再次扫描整个链表,将所有可达对象重新移回 Objects to Scan 链表,而最终的 Unreachable 链表中的对象就是真正不可达对象,需要被回收。

使用分代回收提升效率

为了限制每次垃圾收集所花费的时间,Python 使用分代回收的思想提升效率。分代回收假设大多数对象的寿命都很短,因此会在创建后不久就被收集。事实上大部分的程序非常符合这一假设,许多临时对象的创建和销毁速度非常快。而对象越老,它变成不可访问的可能性就越小。

Python 将所有容器对象都划分到三个代:0 代,1 代,2 代,如果对象在其所在的代的 GC 中存活下来,它将被移动到下一个代。采用分代回收机制,根据对象存活时间的不同来区分扫描的频次和时机,可以提高垃圾回收的效率。

那么应该在何时启动某一代的 GC 呢,gc.get_threshold 可以查看三个代垃圾回收的触发阈值:

>>> import gc
>>> gc.get_threshold()
(700, 10, 10)

# 另外 gc.set_threshold 可以设置阈值

三个阈值分别表示 GC 触发时机:

  • 0 代:如果 0 代中对象数量达到 700 个则触发一次。
  • 1 代:如果 0 代被扫描 10 次,则触发一次。
  • 2 代:只有当 long_lived_pending / long_lived_total 大于 25% 时才会触发

PHP 的垃圾回收

PHP 的垃圾回收跟 Python 十分类似,都是使用引用计数结合标记清除的变体解决循环引用。

PHP 对象结构和引用计数

PHP 中的对象结构体中有一个 gc.refcount 属性表示引用计数,下面是一个 PHP 循环引用的例子:

$a = [1];
$a[] = &$a;

unset($a);

unset 掉 $a 之后:

遍历对象链表标记不可达对象

PHP 将可能存在循环引用的容器类对象放入一个 GC 缓冲链表,当缓冲链表中对象数量达到 10000 个则会触发一次 GC,步骤如下:

  • 从 GC 缓冲链表头开始进行深度优先遍历,标记为 GC_GREY 灰色,并将引用计数减 1
  • 再次遍历缓冲区链表,考察对象的引用计数是否为 0:
    • 为 0,表明其是一个不可达对象,标记为 GC_WHITE 白色。
    • 不为 0,表明还存在链表之外的引用,其是一个可达对象,标记为 GC_BLACK,并将引用计数加 1 恢复。
  • 最后遍历缓冲链表,将所有 GC_WHITE 白色对象移除。

Java 的垃圾回收

Java 采用可达性分析附加分代回收实现 GC。

GC root 和可达性分析

GC root 指的是一组根对象 root object,这些对象被认为是内存中的起始点,它们直接或间接地引用了应用程序中的其他对象,因此,从这组根对象出发,可以通过一系列的引用关系遍历到所有可达的对象,而不可达的对象将被标记为垃圾并被回收。

GC Root 具体指的是:

  • 虚拟机栈中的引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中 JNI(Native方法)的引用的对象

分代回收

内存划分为年轻代和老年代,年轻代分为 eden 区和 survivor0 survivor1 区。在年轻代内部移动采用的是复制回收算法,即在 survivor0 和 survivor1 之间搬运。

Young GC

对象先在 Eden 区分配,当 Eden 满时触发 Young GC

当 Eden 满时,将存活的对象放入 S0,并将每个对象的年龄加一。

当 S0 满,将存活的对象放入 S1,对象年龄再加一。

S1 也满,则在移动到 S0,对象年龄再加一,直到对象年龄达到 15 时,存活对象移入老年代

Full GC

老年代 FullGC 在多个情况下都会被触发:

  • 发生 Young GC 之前进行检查,如果“老年代可用的连续内存空间” < “新生代历次Young GC后升入老年代的对象总和的平均大小”,说明本次 Young GC 后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,此时会触发 FullGC。
  • 当老年代没有足够空间存放对象时,会触发一次 FullGC。
  • 如果元空间区域的内存达到了所设定的阈值-XX:MetaspaceSize=,也会触发 FullGC

常见 Java 垃圾回收器

Serial Garbage Collector:单线程GC

Parallel Garbage Collector:多线程GC

CMS Garbage Collector:多线程GC

G1 Garbage Collector:jdk7引进的GC,多线程,高并发,低暂停,逐步取代CMS GC

Go 垃圾回收

Go 采用标记清除法的变体-三色标记法,附加混合写屏障实现垃圾回收。下面介绍 Go 不同版本从最开始的标记清除开始,逐步演化成现在的三色标记加混合写屏障。

Go v1.3 之前标记清除

跟传统标记清除类似,从根对象遍历,标记出可达和不可达对象,将不可达对象清除,但整个过程需要 STW,性能不高。

Go v1.5 带 STW 的三色并发标记法

三色标记法,此时依旧需要 STW

将所有对象归纳成三种颜色,三色概念的抽象如下:

  • 白色:可能是垃圾的对象
  • 灰色:存活对象,但子对象待考察
  • 黑色:存活对象

下面描述 GC 的过程

  1. 一开始将所有对象视为白色
  2. 从根对象开始考察可达对象,将可达对象本身记为灰色
  3. 遍历灰色集合,将灰色对象本身记为黑色,并将其子对象记为灰色
  4. 重复第 3 步,直到灰色集合没有对象,此时所有的黑色对象为存活对象,白色对象为垃圾对象要清理。

一开始所有对象都是白色

从根对象开始考察,将第一个对象记为灰色

之后遍历灰色集合,将灰色对象记为黑色,并将其子对象记为灰色

重复上述步骤,直到灰色集合清空,此时黑色对象就是存活对象,白色对象就是垃圾对象。

需要指出这个版本的三色标记还是需要 STW 的,即依旧存在性能问题。

如果不使用 STW 会出现什么情况

不使用 STW 就表明在标记对象的同时程序还在运行,程序有可能会修改对象的引用关系,这可能会导致对象被错误的回收。

如图对象3原本在对象2的引用下,此时对象2是灰色,对象3是白色。

由于没有 STW 程序还在运行,程序让黑色对象4引用了白色对象3,并且灰色对象2移除了白色对象3。

这样就会导致当再次遍历灰色对象集合时,将对象2移动到黑色集合之后,由于对象2不再持有对象3的引用,所以不会再考察对象3,同时由于对象4已经是黑色的考察过的对象,也不会再次考察对象3,结果就是对象3被记为白色,最终被错误地回收掉。

强弱三色不变性

如果既不想要 STW,又想确保不丢对象,就需要破坏对象丢失的前提条件。通过总结上述丢失对象的过程可以发现,对象丢失的前提条件有两个:

  1. 黑色对象引用了一个白色对象,即上图中黑4引用白3
  2. 灰色对象与白色对象之间的引用关系遭到破坏,即上图中灰2移除掉白3的引用

如果同时满足上述两个条件,就可能会发生对象丢失。那么如果不想发生对象丢失,就可以破坏掉这两个条件其一即可。

如此引出强弱三色不变性:

  • 强三色不变性:黑色对象不可以指向白色对象,只可以指向灰色对象或者黑色对象;

  • 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径

插入屏障和删除屏障

基于上述两个原则衍生出两种屏障方式,插入屏障和删除屏障。

插入屏障

当A对象引用B对象时,将B对象被标记为灰色,使满足强三色不变性。

插入屏障的缺点:最后需要对栈空间进行 STW 从而二次扫描。这是因为由于栈空间使用频繁,插入屏障不在栈中使用,即如果在栈空间生成一个对象,新对象是一个白色对象。最终在清除垃圾对象前需要对栈空间进行一次 STW,重新执行一遍三色标记的流程,避免将新的白色对象错误删除。

删除屏障

被删除引用的对象如果是白色,则标记为灰色,使满足弱三色不变性。

删除屏障的缺点:整体开始前需要对栈空间 STW,还是有损耗

Go v1.8 混合写屏障

混合写屏障综合了插入屏障和删除屏障,做到不丢对象又不需要 STW。(严格来说只在标记栈上对象时需要很短的 STW,除此之外不再需要 STW)

具体原则如下:

  • GC 开始时将栈上对象全部扫描并记为黑色,这样就不需要最后的 STW 二次扫描了
  • GC 期间,任何在栈上创建的新对象均标记为黑色
  • 被删除的对象记为灰色
  • 被插入的对象记为灰色

实际上是满足了弱三色不变性,即当对象有变动时将对象变为灰色,让该灰色及其之后的对象留有被扫描的机会。

如此一来基于上述原则,无论添加对象还是移除对象引用,都不会出现丢对象的情况,也不需要长时间 STW。

总结

编程语言提供垃圾回收的目的是简化内存操作,避免内促泄露,减轻开发者的成本,既然目的是一致的,面临的问题也是类似的,大致上分为如何找到垃圾,如何清除垃圾两部分,而解决方式基本上是在几种常规手段的基础上做权衡和取舍。

参考

Python内存管理&垃圾回收原理:www.bilibili.com/video/BV1F5…

Python Developer’s Guide:devguide.python.org/internals/g…

PHP 垃圾回收:www.kancloud.cn/nickbai/php…

Java Young GC 和 Full GC:www.cnblogs.com/klvchen/art…

Java 垃圾回收算法及详细过程:xie.infoq.cn/article/9d4…

Python 和 Golang的垃圾回收:www.yance.wiki/gc_go_py

混合写屏障:liqingqiya.github.io/golang/gc/垃…

三色标记混合写屏障:www.yuque.com/aceld/golan…

本文由mdnice多平台发布