本文对比了四种语言在垃圾回收方面的实现,其目标都是相同的,即希望做到准确又高效的识别和清理内存中的垃圾对象,不同语言之间在实现思路上有相似之处,又各自有不同的侧重点。
常见的垃圾回收算法
引用计数
给每个对象结构体附加一个引用计数的属性,当对象被赋值或引用时会增加引用计数,当对象销毁时减少引用计数,当引用计数变为 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 的过程
- 一开始将所有对象视为白色
- 从根对象开始考察可达对象,将可达对象本身记为灰色
- 遍历灰色集合,将灰色对象本身记为黑色,并将其子对象记为灰色
- 重复第 3 步,直到灰色集合没有对象,此时所有的黑色对象为存活对象,白色对象为垃圾对象要清理。
一开始所有对象都是白色
从根对象开始考察,将第一个对象记为灰色
之后遍历灰色集合,将灰色对象记为黑色,并将其子对象记为灰色
重复上述步骤,直到灰色集合清空,此时黑色对象就是存活对象,白色对象就是垃圾对象。
需要指出这个版本的三色标记还是需要 STW 的,即依旧存在性能问题。
如果不使用 STW 会出现什么情况
不使用 STW 就表明在标记对象的同时程序还在运行,程序有可能会修改对象的引用关系,这可能会导致对象被错误的回收。
如图对象3原本在对象2的引用下,此时对象2是灰色,对象3是白色。
由于没有 STW 程序还在运行,程序让黑色对象4引用了白色对象3,并且灰色对象2移除了白色对象3。
这样就会导致当再次遍历灰色对象集合时,将对象2移动到黑色集合之后,由于对象2不再持有对象3的引用,所以不会再考察对象3,同时由于对象4已经是黑色的考察过的对象,也不会再次考察对象3,结果就是对象3被记为白色,最终被错误地回收掉。
强弱三色不变性
如果既不想要 STW,又想确保不丢对象,就需要破坏对象丢失的前提条件。通过总结上述丢失对象的过程可以发现,对象丢失的前提条件有两个:
- 黑色对象引用了一个白色对象,即上图中黑4引用白3
- 灰色对象与白色对象之间的引用关系遭到破坏,即上图中灰2移除掉白3的引用
如果同时满足上述两个条件,就可能会发生对象丢失。那么如果不想发生对象丢失,就可以破坏掉这两个条件其一即可。
如此引出强弱三色不变性:
插入屏障和删除屏障
基于上述两个原则衍生出两种屏障方式,插入屏障和删除屏障。
插入屏障
当A对象引用B对象时,将B对象被标记为灰色,使满足强三色不变性。
插入屏障的缺点:最后需要对栈空间进行 STW 从而二次扫描。这是因为由于栈空间使用频繁,插入屏障不在栈中使用,即如果在栈空间生成一个对象,新对象是一个白色对象。最终在清除垃圾对象前需要对栈空间进行一次 STW,重新执行一遍三色标记的流程,避免将新的白色对象错误删除。
删除屏障
被删除引用的对象如果是白色,则标记为灰色,使满足弱三色不变性。
删除屏障的缺点:整体开始前需要对栈空间 STW,还是有损耗
Go v1.8 混合写屏障
混合写屏障综合了插入屏障和删除屏障,做到不丢对象又不需要 STW。(严格来说只在标记栈上对象时需要很短的 STW,除此之外不再需要 STW)
具体原则如下:
- GC 开始时将栈上对象全部扫描并记为黑色,这样就不需要最后的 STW 二次扫描了
- GC 期间,任何在栈上创建的新对象均标记为黑色
- 被删除的对象记为灰色
- 被插入的对象记为灰色
实际上是满足了弱三色不变性,即当对象有变动时将对象变为灰色,让该灰色及其之后的对象留有被扫描的机会。
如此一来基于上述原则,无论添加对象还是移除对象引用,都不会出现丢对象的情况,也不需要长时间 STW。
总结
编程语言提供垃圾回收的目的是简化内存操作,避免内促泄露,减轻开发者的成本,既然目的是一致的,面临的问题也是类似的,大致上分为如何找到垃圾,如何清除垃圾两部分,而解决方式基本上是在几种常规手段的基础上做权衡和取舍。
参考
Python内存管理&垃圾回收原理:https://www.bilibili.com/video/BV1F54114761/
Python Developer’s Guide:https://devguide.python.org/internals/garbage-collector/
PHP 垃圾回收:https://www.kancloud.cn/nickbai/php7/363305
Java Young GC 和 Full GC:https://www.cnblogs.com/klvchen/articles/11758324.html
Java 垃圾回收算法及详细过程:https://xie.infoq.cn/article/9d4830f6c0c1e2df0753f9858
Python 和 Golang的垃圾回收:https://www.yance.wiki/gc_go_py
混合写屏障:https://liqingqiya.github.io/golang/gc/垃圾回收/写屏障/2020/07/24/gc5.html
三色标记混合写屏障:https://www.yuque.com/aceld/golang/zhzanb
本文由mdnice多平台发布