垃圾回收算法手册读书笔记(八)并发垃圾回收

并发垃圾回收

之前我们介绍的所有垃圾回收,包括并行垃圾回收,都会在垃圾回收过程中将赋值器线程挂起。而并发垃圾回收,将会考虑如何使回收器线程和赋值器线程交替或者并行执行,以达到降低停顿时间的目的

并发垃圾回收

上图列出多种并发策略,其中白条代表赋值器的执行,灰条与黑条代表回收器的不同回收周期的执行

增量回收

单处理器下的增量回收,这种策略下,回收器在执行垃圾回收的过程中赋值器仍然挂起,但与之前不同的是,回收器并不会在一次赋值器线程挂起中完成所有的垃圾回收任务,而是在一个垃圾回收周期中,回收器与赋值器交替的执行,回收器每次执行增量的完成垃圾回收任务,这一策略很容易扩展到多处理器环境下,同时也可以将增量回收并行化。

主体并发回收

在回收的某些阶段(比如线程栈的扫描),同步是一种比并发更加简单高效的一种方式。因此在主体并发回收中,回收器将会把赋值器线程挂起一段时间以完成线程栈扫描等操作,而在其他时间,赋值器可以与回收器同时执行。

Hotspot jvm中的CMS与G1都采用了这一方式,在初始标记与重新标记阶段都会将赋值器线程挂起。

即时回收

如果完全消除stop the world,那么回收就成了纯粹的并发即时回收。虽然回收器与赋值器之间仍然可能需要一些同步,但应用将不存在全局的停顿。

并发回收的正确性

正确的并发回收算法必须满足两个条件:

  • 安全性要求回收器必须保留所有可达对象
  • 存活性要求一个回收周期最终必须能够结束

三色抽象回顾

在最初我们介绍了三色抽象的概念,这里我们进行一下回顾

  • 白色对象:追踪阶段尚未被回收器访问到
  • 灰色对象:已经被回收器访问到,但其子节点尚未追踪完成
  • 黑色对象:对象及其子节点已完成追踪。回收器将不会再次访问这个对象

追踪的初始阶段,所有对象均为白色,追踪完成后,黑色对象为可达对象,仍然为白色的对象则是不可达对象。

并发过程中的对象丢失

在回收器进行追踪的过程中,赋值器也在同时改变对象之间的拓扑结构。如果在此过程中对赋值器不进行任何限制,或者不引入其他额外的操作,将无法保证回收的正确性。

让我们考虑如下情形,在追踪过程中,赋值器将一个可达,但是尚未被访问到的白色对象的引用,写入到了一个黑色对象中,并且破坏了所有从其他灰色对象到该对象的路径。在这种情况下,这个白色对象虽然可达,但是并不会被回收器访问到,最终回收器将会错误的回收这个对象。

对象丢失

从上面我们可以了解到,只有当如下两个条件同时满足时才会造成对象丢失:

  • 赋值器将某一白色对象的引用写入黑色对象
  • 从灰色对象出发,到该白色对象的所有路径都被破坏

强三色不变式与弱三色不变式

为了保证不会错误的回收可达对象,我们需要保证导致对象丢失的两个条件不会同时出现。

弱三色不变式要求:所有被黑色对象引用的白色对象都处于灰色保护状态(即直接或者间接从灰色对象可达)

强三色不变式要求:不存在从黑色到白色对象的引用

我们可以看到,在导致对象丢失的两个条件中,条件一将白色对象写入黑色对象,打破了强三色不变式,之后又破坏了所有从灰色对象到该白色对象的路径,进而打破了弱三色不变式。

为了解决这一问题,赋值器必须在将白色对象写入黑色对象时,或者在破坏可达路径时,引入额外的操作(赋值器屏障)

回收精度

回收精度用于衡量回收器在一个回收周期内回收尽可能多的垃圾的能力。回收精度最大化便是回收所有的不可达对象。对于经过一次垃圾回收仍然存在的不可达对象,我们称为浮动垃圾,尽管浮动垃圾的存在不会影响回收的正确性,但是我们不希望存在存在过多的浮动垃圾,浮动垃圾的多少,也是检验回收器完整性的重要指标。

基于增量更新的解决方案

在解决对象丢失问题的各种策略中,某些策略将注意力集中在将白色对象写入黑色对象这一过程,此类解决方案称为增量更新技术。

当白色对象的引用被写入黑色对象时,增量更新方案保守的将其看做是存活的对象,使得不会有白色对象引用写入黑色对象,从而满足强三色不变式

基于起始快照的解决方案

这种策略集中注意力于赋值器破坏灰色对象到白色对象路径这一过程。

当赋值器从灰色或者白色对象中删除指针时,起始快照方案保守的将该指针指向的对象看做是存活对象,使得赋值器无法破坏灰色对象到白色对象路径,从而满足弱三色不变式

并发回收相关屏障技术

为了在赋值器将白色对象写入黑色对象时,或者破坏可达路径时,引入额外的操作解决对象丢失问题。开发人员提出了许多种赋值器读写屏障方案

其中主要额外操作有:

  • 将白色对象着色为灰色对象(shade)
  • 追踪对象并将其变为黑色对象
  • 将黑色对象回退(revert)到灰色对象,以便后续重新追踪

如下列举了一些读写屏障例子

Steele写屏障:

    // 将引用 ref 写入对象 src
    atomic Write(src, i, ref):
        src[i] <- ref
        if isBlack(src) // 如果src是黑色对象
            if isWhite(ref) // 并且ref引用的对象是白色
                revert(src) // 将src回退到灰色,后续会重新对src进行追踪

这种写屏障的实现,拥有较高的回收精度,因为白色对象ref的可达性判断推迟到了重新追踪的过程中(在重新追踪之前,ref的引用可能又被删除了)。由于该方案会导致回收进度的后退,所以相当于是牺牲回收进度换取回收精度。

Dijkstra写屏障:

    // 将引用 ref 写入对象 src
    atomic Write(src, i, ref):
        src[i] <- ref
        if isBlack(src) // 如果src是黑色对象
            shade(ref) // 将ref着色为灰色对象,后续会对ref进行追踪

与Steele屏障方案不同,Dijkstra屏障会将所有写入黑色对象的白色对象着色成灰色对象进行扫描(而不管其引用之后会不会又被赋值器删除),这样有利于回收进度的推进,而回收精度精度较低,相当于牺牲回收精度换取回收进度。

Baker读屏障:

    // 读取src对象中的引用
    atomic Read(src, i):
        ref <- src[i]
        if isGray(src) // 如果src为灰色对象
            ref <- shade(ref) // 将ref指向的对象着色为灰色对象,后续会对ref进行追踪
        return ref

Baker读屏障的回收精度相较于Dijkstra写屏障更加的低,在回收过程中被赋值器读取的白色对象,无论其之后会不会被写入到黑色对象中,都会被着色成灰色进行追踪。

屏障技术还有很多实现,这里不详细的列举

写屏障的指针记录

这一节我们主要讨论写屏障的实现,首先我们已经了解到,写屏障需要拦截指针的写入操作,并且记录指针指向的对象或者指针写入的目标对象,以便之后对这些对象的重新追踪。

记录可以使用多种并发数据结构完成,其中一种常见的技术便是卡表

卡表既可以用于记录分代回收器中的分代间指针,也可以用于并发垃圾回收中,关于卡表会在另外的文章中讨论,这里不做详细的叙述。

需要考虑的问题

并发回收器的主要目的在于最大限度的减少垃圾回收过程中的停顿时间。然而相比stop the world的垃圾回收,并发回收中赋值器与回收器之间的通信与同步开销将会降低应用的吞吐量。 并发回收器对于停顿时间敏感的应用是一个好的选择,但是对于要求高吞吐量的应用来说并不是最佳选择

并且,并发垃圾回收仅仅在停顿时间上提供松散的保障,仍然不能满足硬实时应用程序的要求。

混合分代/并发回收器

并发垃圾回收特别适用于存活比例很高的引用,在这一场景下,即便是并行回收也会产生不可接受的停顿时间。同时我们需要了解到,并发垃圾回收意味着回收过程中内存仍然在不停的分配。因此启动并发回收时必须留下足够的内存余量,以确保回收过程中内存不会被耗尽。这一问题在内存分配率较高的情况下尤为麻烦。

结合上述问题,开发人员结合分代垃圾回收的特点,提出以下方案,该方案依然使用传统的 stop the world 回收来回收年轻代,而老年代则使用并发回收器进行管理。这一方案存在诸多优势:

  • 理论上年轻代回收的停顿时间足够短,可以接受
  • 为了保证回收进度,通常并发写屏障会保守的将新创建对象当成存活对象进行追踪,从而降低回收精度,而在该方案下新对象在年轻代分配,根本不需要考虑这种问题
  • 理论上老年代的提升率并不高,使得内存余量不需要太多
  • 老年代对象修改频率会比年轻代低很多,因此并发写屏障的调用频率也会低很多