本文对比了四种语言在垃圾回收方面的实现,其目标都是相同的,即希望做到准确又高效的识别和清理内存中的垃圾对象,不同语言之间在实现思路上有相似之处,又各自有不同的侧重点。
给每个对象结构体附加一个引用计数的属性,当对象被赋值或引用时会增加引用计数,当对象销毁时减少引用计数,当引用计数变为 0 时回收。
从内存中一组 root object 根对象开始向下遍历并标记所有可能访问到的对象,即可达对象,相反没有被标记的对象即为不可达对象,标记完成后将不可达对象清除。
针对对象的生命周期长短不同将其划分到不同代,如年轻代,老年代等;不同代采用不同回收策略,例如年轻代的对象可能刚分配不久就不再使用应该可以被回收,所以年轻代触发 GC 较为高频,老年代的对象可能有历久弥坚的特性,一直存活到最后,所以触发 GC 较为低频。总的来说分代回收针对不同特点的数据启用不同策略,缩短 GC 时间。
将内存分为两块,每次只使用其中一块。垃圾回收时,将存活对象从一个块复制到另一个块,然后清除未复制的块。
不同的 Python 解释器实现有不同的垃圾回收方式,在 CPython 中以引用计数为主,附加标记清除的变体解决循环引用问题,另外附加分代回收提高垃圾回收的执行效率。
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 触发时机:
long_lived_pending / long_lived_total
大于 25% 时才会触发 PHP 的垃圾回收跟 Python 十分类似,都是使用引用计数结合标记清除的变体解决循环引用。
PHP 中的对象结构体中有一个 gc.refcount 属性表示引用计数,下面是一个 PHP 循环引用的例子:
$a = [1];
$a[] = &$a;
unset($a);
unset 掉 $a 之后:
PHP 将可能存在循环引用的容器类对象放入一个 GC 缓冲链表,当缓冲链表中对象数量达到 10000 个则会触发一次 GC,步骤如下:
Java 采用可达性分析附加分代回收实现 GC。
GC root 指的是一组根对象 root object,这些对象被认为是内存中的起始点,它们直接或间接地引用了应用程序中的其他对象,因此,从这组根对象出发,可以通过一系列的引用关系遍历到所有可达的对象,而不可达的对象将被标记为垃圾并被回收。
GC Root 具体指的是:
内存划分为年轻代和老年代,年轻代分为 eden 区和 survivor0 survivor1 区。在年轻代内部移动采用的是复制回收算法,即在 survivor0 和 survivor1 之间搬运。
对象先在 Eden 区分配,当 Eden 满时触发 Young GC
当 Eden 满时,将存活的对象放入 S0,并将每个对象的年龄加一。
当 S0 满,将存活的对象放入 S1,对象年龄再加一。
S1 也满,则在移动到 S0,对象年龄再加一,直到对象年龄达到 15 时,存活对象移入老年代
老年代 FullGC 在多个情况下都会被触发:
Serial Garbage Collector:单线程GC
Parallel Garbage Collector:多线程GC
CMS Garbage Collector:多线程GC
G1 Garbage Collector:jdk7引进的GC,多线程,高并发,低暂停,逐步取代CMS GC
Go 采用标记清除法的变体-三色标记法,附加混合写屏障实现垃圾回收。下面介绍 Go 不同版本从最开始的标记清除开始,逐步演化成现在的三色标记加混合写屏障。
跟传统标记清除类似,从根对象遍历,标记出可达和不可达对象,将不可达对象清除,但整个过程需要 STW,性能不高。
三色标记法,此时依旧需要 STW
将所有对象归纳成三种颜色,三色概念的抽象如下:
下面描述 GC 的过程
一开始所有对象都是白色
从根对象开始考察,将第一个对象记为灰色
之后遍历灰色集合,将灰色对象记为黑色,并将其子对象记为灰色
重复上述步骤,直到灰色集合清空,此时黑色对象就是存活对象,白色对象就是垃圾对象。
需要指出这个版本的三色标记还是需要 STW 的,即依旧存在性能问题。
不使用 STW 就表明在标记对象的同时程序还在运行,程序有可能会修改对象的引用关系,这可能会导致对象被错误的回收。
如图对象3原本在对象2的引用下,此时对象2是灰色,对象3是白色。
由于没有 STW 程序还在运行,程序让黑色对象4引用了白色对象3,并且灰色对象2移除了白色对象3。
这样就会导致当再次遍历灰色对象集合时,将对象2移动到黑色集合之后,由于对象2不再持有对象3的引用,所以不会再考察对象3,同时由于对象4已经是黑色的考察过的对象,也不会再次考察对象3,结果就是对象3被记为白色,最终被错误地回收掉。
如果既不想要 STW,又想确保不丢对象,就需要破坏对象丢失的前提条件。通过总结上述丢失对象的过程可以发现,对象丢失的前提条件有两个:
如果同时满足上述两个条件,就可能会发生对象丢失。那么如果不想发生对象丢失,就可以破坏掉这两个条件其一即可。
如此引出强弱三色不变性:
基于上述两个原则衍生出两种屏障方式,插入屏障和删除屏障。
插入屏障
当A对象引用B对象时,将B对象被标记为灰色,使满足强三色不变性。
插入屏障的缺点:最后需要对栈空间进行 STW 从而二次扫描。这是因为由于栈空间使用频繁,插入屏障不在栈中使用,即如果在栈空间生成一个对象,新对象是一个白色对象。最终在清除垃圾对象前需要对栈空间进行一次 STW,重新执行一遍三色标记的流程,避免将新的白色对象错误删除。
删除屏障
被删除引用的对象如果是白色,则标记为灰色,使满足弱三色不变性。
删除屏障的缺点:整体开始前需要对栈空间 STW,还是有损耗
混合写屏障综合了插入屏障和删除屏障,做到不丢对象又不需要 STW。(严格来说只在标记栈上对象时需要很短的 STW,除此之外不再需要 STW)
具体原则如下:
实际上是满足了弱三色不变性,即当对象有变动时将对象变为灰色,让该灰色及其之后的对象留有被扫描的机会。
如此一来基于上述原则,无论添加对象还是移除对象引用,都不会出现丢对象的情况,也不需要长时间 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 多平台发布