解构G1三部曲(二) 增量的好处与代价

108 阅读22分钟

前言

在上一篇文章里面,我们主要讲分代的含义,为什么要分代,分代的代价,G1付出的额外代价。本篇我们来讲G1的增量是个什么意思。

增量概述

首先我认为增量应当有一个对比对象,我们常常说身高增加了多少,常常有一个参考量,那GC的增量是什么意思? 在论文《Incremental incrementally compacting garbage collection》里面对增量进行了相关的论述 , 也就是参考文档[6]

In incremental collectors, the mutator and the collec-tor are two processes running on the same processor .

In this case the problem is only to ensure that the col -lector may be resumed whenever the mutator is inter-rupted (for lack of memory or for any other reason) ,and that the collector may be quickly interrupted when the mutator is ready to resume computation .

  • 在增量式垃圾回收器中,应用线程 (mutator) 和回收器 (collector) 是运行在同一个处理器上的两个进程。在这种模式下,其核心技术挑战在于确保:

    • 每当应用线程因缺少内存或任何其他原因被中断时,回收器都必须能够立即恢复运行
    • 一旦应用线程准备恢复计算,回收器就必须能够被迅速打断

    注意:原文使用 processes,但在现代操作系统和运行时(如JVM、Go)的语境下,它们更像是同一个进程内的两个线程,这也是我们将它们译为“线程”的原因。

从上面的话里面,我们能提取出两点,也就是说增量式回收要求应用线程和回收器线程并发执行,那还是没有增啊,我们接着可以在这个论文的2.4看到如下的论述:

The original implementations of mark-and-sweep and copy collection were realized in a stop-and-collect mode . In this mode the mutator is stopped whenever it runs out of allocatable memory, and control is given to the collector for reclamation of disused (i .e . inaccessible) cell。

标记-清除(mark-and-sweep)和复制收集(copy collection)的最初实现,都是在一种“停止-收集”(stop-and-collect)模式下完成的。在这种模式下,每当应用线程(mutator)耗尽可分配的内存时,它就会被暂停,同时控制权被移交给回收器(collector),以回收那些已废弃(即不可达)的内存单元。

其实看这个论述我是有些不理解的,我不太理解为什么不是并发执行,也许从我最接触的世界来说就是并发执行的。 到这里我其实就提出来增量的含义了,相对于一次性完成整个垃圾回收过程,增量选择每次增加工作进度,GC线程获得CPU的执行权,GC工作进度为百分之三十。在这个基础上又诞生出两种增量算法,分别是基于工作量的增量和基于时间的增量。在论文《A Real-time Garbage Collector with Low Overhead and Consistent Utilization》可以看到对应的论述:

Our collector can use either time- or work-based scheduling. Most previous work on real-time garbage collection, starting with Baker’s algorithm [5], has used work-based scheduling. We show both analytically and experimentally that time-based scheduling is superior, particularly at the short intervals that are typically of interest in real-time systems. Work-based algorithms may achieve short individual pause times, but are unable to achieve consistent utilization

我们的回收器可以采用基于时间基于工作的调度策略。以往大多数关于实时垃圾回收的研究,自贝克(Baker)算法始,都采用了基于工作的调度。我们通过分析和实验双重方式证明,基于时间的调度更为优越,尤其是在实时系统中通常关注的短时间间隔上。基于工作的算法或许能实现单次短暂的停顿,但无法保证一致的(处理器)利用率

下面我们将分别对这两种增量的调度的工作方式进行讨论。

工作量调度

所谓工作量的调度,在参考文档[4]《A Real-time Garbage Collector with Low Overhead and Consistent Utilization》我们可以看到对应的论述:

In the work-based method, the collector is run for a fixed amount of time for every k bytes of data allocated by the mutator.

在基于工作量的方法中,mutator(应用线程)每分配 k 字节的数据,收集器就会运行一段固定的时间。

这种调度的缺点在于忙的时候会特别忙,停顿周期会特别明显,设想流量高峰分配内存的请求一定会频繁触发收集器频繁运作,应用线程的延迟大幅度上升。收集器线程频繁的和应用线程抢CPU, 导致应用频繁延迟。这种调度方式对于基于追求可预测性和稳定性的实时系统是不可以接受的,由此就引出了基于时间的调度增量调度。

基于时间的调度

所谓基于时间的调度也就是说应用线程和回收器线程交错执行,回收器以固定的时间间隔,运行一个固定的时长t, 我们在参考文档[4]的4.2可以看到对应的论述:

Time-based scheduling interleaves the collector and mutator using fixed time quanta. It thus results in even CPU utilization but is subject to variations in memory requirements if the memory allocation rate is uneven。

基于时间的调度(Time-based scheduling)采用固定的时间片(fixed time quanta)来交错执行收集器(collector)和应用线程(mutator)。因此,它能带来平稳的CPU利用率,但如果内存分配速率不均匀,它就会受到内存需求变化的影响。"

在这篇论文里面论证了追求实时的系统,基于时间的调度会更优,见参考文档[4]里面的对应描述如下:

Our collector can use either time- or work-based scheduling. Most previous work on real-time garbage collection, starting with Baker’s algorithm, has used work-based scheduling. We show both analytically and experimentally that time-based scheduling is superior, particularly at the short intervals that are typically of interest in real-time systems. Work-based algorithms may achieve short individual pause times, but are unable to achieve consistent utilization

我们的收集器可以使用基于时间或基于工作量的调度。以往大多数关于实时垃圾回收的工作,从Baker的算法开始,都使用了基于工作量的调度。我们将通过理论分析和实验两种方式证明,基于时间的调度是更优越的(is superior) ,尤其是在实时系统中通常关注的短时间间隔上。基于工作量的算法或许能实现短暂的单次暂停时间,但无法获得一致的利用率(consistent utilization)

JVM中的GC实现

但是对于JVM中的分代垃圾回收器来说,并不是严格的基于时间量的调度,我们知道基本各种YGC的触发方式就是满了,不够放新进来的对象。

对于CMS垃圾回收器来说它只回收老年代,触发CMS垃圾回收器开始工作的条件粗略的说是老年代占堆的比例,但是实际的远比这个条件复杂, 有可能没到达你设定的阈值,CMS就开始了回收这个过程。 CMS Gc 在实现上分成foreground collector 和 background collector ,前台回收比较简单,一般遇到对象分配但空间不够,就会直接触发GC,来理解进行空间回收。采用的是标记-清除算法,不压缩。background collector 相对来说更为主动一点,它是通过CMS后台线程不断去扫描,过程中主要是判断是否符合backgroun collector的触发条件,一旦有符合的情况,就会进行一次后台回收,具体的触发条件可以参看参考资料[16] , 老年代 CMS垃圾回收器在JDK 14被移除,见参考文档[7]。

CMS的定位是以获取最短停顿时间为目标的回收器。但对于ParallelGC来说,默认是在触发fullgc前执行一次young gc, 并且两次GC之间能让应用程序稍微运行一小下,以期降低full GC的暂停时间(因为young GC会尽量清理了young gen的死对象,减少了full GC的工作量)见参考文档[9]。如果你并不在意停顿时间,比较关注吞吐量比较推荐的是ParallelGC。

G1带来了全新的回收模式,也就是mixed gc 收集,收集整个young gen以及部分old gen的GC,只有G1有这个模式。 G1的回收流程为,年轻区域满了回收年轻区域,然后处理对象晋升,对象来到老年代,在参考文档[10]我们可以看到在JDK 8b12之前,是堆的使用率到达阈值,触发并发标记周期,也就是Mixed GC。如果是JDK8b 12之后,是老年代占据堆容量的比值。这也就是InitiatingHeapOccupancyPercent 这个参数,在JDK 9这个参数同样进行了加强,变成了自适应。见参考文档[14]。

如果混合回收阶段结束,新的内存分配请求让G1觉得内存请求分配的速度跟不上回收速度,则会触发full GC, 在JDK 10之前,G1的 full gc 都是单线程这意味着会相当缓慢,如果应用频繁触发full gc 可以认为应用应该有些不正常。在JEP 307 才让G1的full gc 并行化。

我们应当尽量避免巨型对象的产生,在参考文档[11]里面我们可以看到对应的论述:

Allocations of humongous objects may cause garbage collection pauses to occur prematurely. G1 checks the Initiating Heap Occupancy threshold at every humongous object allocation and may force an initial mark young collection immediately, if current occupancy exceeds that threshold.

巨型对象的分配可能会导致垃圾回收停顿过早地发生。G1在每一次分配巨型对象时都会检查‘初始堆占用阈值’(Initiating Heap Occupancy threshold),并且如果当前的堆占用率超过了该阈值,它可能会立即强制执行一次‘初始标记年轻代回收’(initial mark young collection)。

对于initial mark young collection 这句话我的理解是并发启动年轻代回收,先安排一次年轻代回收,然后开启并发标记周期,为Mixed GC 做准备。

增量更新

我们前面尝试从论文找出增量的定义,遗憾的是我们在论文里面找到的增量和JVM实际的增量对不上,但是我在再度翻阅《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版》这本书的时候看到了增量更新。在前面的文章我们提到,标记要回收的对象是通过排除法来做的,也就是除了活的,就是死的。 对象之间的引用关系是一个图的数据结构:

迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的,现在的可达性算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照才得以进行。这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现在分析过程中,根节点集合的对象引用关系还在不断变化中,若是这点不能满足的话,分析结果准确性也就无法保证。

C4可以做到无停顿,但是具体的算法没有探究过,我们这里不做过多讲解。有了GC Roots,现在我们就要遍历对象的引用链了,为了方便知道哪些对象是遍历过的,哪些是没遍历的。我们不妨给引入标记状态:

  • 白色: 表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

现在的问题就出现在在遍历这个引用链的过程中,用户线程也在同步工作,修改了引用链的数据结构:

  1. 一种是把原本消亡的对象错误标记为存活, 这不是好事,但其实是可以容忍的。

举个例子我们让设A对象引用B对象和C对象,A是GC Roots,我们的GC线程先扫描完,这个时候应用线程不再引用C,这意外着C是不可达的对象是垃圾需要回收:

也就是说GC线程从A跑到B,再从A跑到C,

  1. 把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

    假设A引用B,C是白色对象,在GC的遍历过程结束之后,C是白色的,C被认为是需要回收的对象, 应用线程让A引用了C,这个时候C事实并不是垃圾。这就是错误标记。

    伪代码的示例就是:

C c = new C();// 在这里还是不可达的
A a = new A(new B());
a.c = c; // 在这里就是可达的了。

上面的想法在于忽视了,在枚举根节点这个时刻,新产生的对象就是GC roots , 在《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》可以看到GC Roots的对象:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • ·所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

总是一边学一边忘记,好,现在这个节点是GC Roots看起来能避免这个问题的出现,那现在让我们构造新的场景,我们将上面的代码改写成下面:

A a = new A();
a.b = b;
C c = new C();
a.c = c; // 语句一

我们不妨假设GC现成在遍历完a的所有引用之后,才走到语句一这个位置,因此在这个语境下我们就需要思考这样一个问题,在并发标记期间产生的对象,是黑色还是白色。如果是白色,那么意味着标记错误。或者这个时间点创建的节点和我们的图中产生了关系,不妨将这个变量标记为灰色,避免错误标记。 这也就是WIlson的论文《Uniprocessor Garbage Collection Techniques》中的做法:

In terms of tricolor marking, new objects are black when allocated, and none of them can be reclaimed; they are never reclaimed until the next garbage collection cycle.

用三色标记的术语来说,新分配的对象在分配时是黑色的,并且它们中没有任何一个能被回收;它们绝不会被回收,直到下一个垃圾回收周期为止

引入了这个假设之后,我们所考虑的问题就不存在了,但是真的不存在了嘛,因此我们不妨考虑下面的场景:

正在扫描的一个灰色对象的一个引用被切断了,注意灰色的对象代表: 表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

现在让应用线程开始发力,也就是B和D之间的引用链被切断,A引用了D:

这是一种情况,再有一种情况这种被切断后重新被黑色对象引用的对象可能是原有引用链的一部分,由于黑色对象不会重新扫描,这将导致扫描结束后出现黑色对象引用的对象仍然是白色,如下所示:

现在让应用线程发力,让B去掉对D的引用,让A引用D:

这也就是Wilson于1994年在论文《Uniprocessor Garbage Collection Techniques》 中证明的事情,当且仅当以下两个条件同时满足时,会产生“对象消失”的问 题,即原本应该是黑色的对象被误标为白色:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  1. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

那该怎么解决这个错标的情况呢? CMS的选择是增量更新,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。简单的理解就是,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

G1、Shenandoah的方案是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at M ark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

这里我们就可以回答《浅谈G1原理(一)之分区与分代假说》里提到的问题了,为什么G1要用到写前屏障,因为要记录删除的引用关系。

与CMS中的“Concurrent M ode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”

上面这一部分基本是结合《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》的一个我的理解过程,某种程度上可以理解读书笔记。

应当反思的地方

我在看《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》时候在思考应用线程和GC线程在并发工作的时候,思考对象消失的时候,我认为只要赋值器插入了一条或多条从黑色对象到白色对象的新引用,就会出现错标。这是一边看书一边思考的证明,我也构造出来的场景。然后坏处是自己找到了反例,没有尝试构造其前置条件,导致我不断的想推翻这本书引用的Wilson的论证,那我没有引入一种思路,尝试解释,其实只要引入并发标记期间产生的新对象都是黑色的假设,就能理解了,我当时已经敏锐的注意到,问题的关键是并发标记期间产生的节点如果加入了我们正在遍历的对象图,这个节点是什么颜色,我执着于认为是白色的公设,于是出现了错标,但如果我们尝试修改这个设定呢,理论就能自洽了。

还是没有从第一性公理出发,不过还是从wilson的论文里面找到了这个公设,这里也体会到了修改问题的基本假设将影响结论的导出,这是一个值得记忆的一个点。

总结一下

本篇尝试以增量为入口,探究增量的含义,所谓的增量也就是应用线程和GC线程并发执行就是增量,那么基于这种方式又有两种增量。一种是基于工作量的增量,所谓工作量的增量也就是应用线程每分配一定的字节,GC线程就需要工作一段时间。基于工作量的增量的CPU使用难以平稳,原因在于高峰期的内存分配请求更为剧烈,这将频繁的触发GC工作。于是就引出了基于时间量的增量算法,也就是基于时间的增量,也就是应用线程工作一段时间,GC线程工作一段时间。

然后我们探讨了JVM 中GC对增量的实现,但是现阶段JVM的增量只实现了时间增量的前半段,在触发GC的时候,应用线程和GC线程并发执行。基本YGC的触发条件都是Young区满了,CMS垃圾回收器独有的回收老年代模式,也是在堆占用到达一定的阈值之后才触发,这个阈值都后面改成了自适应的方式。在JDK 14 CMS垃圾回收器被移除。 并行垃圾回收器更关注吞吐量,在并没有单独回收老年的模式,但是在回收全堆的垃圾之前,会进行YGC,来降低FullGC的暂停时间。

但随着堆的逐渐变大,旧有GC的弊病开始显现,停顿时间开始变得不可预测,同时业界也呼唤一款在吞吐量和延迟之间取得平衡的GC,这也就是G1横空出世。G1采取了标记-复制算法,将内存分为若干区域,年轻代,年老代,巨型对象区。这些逻辑上不连续的区域的引入是尽可能的避免扫描某大一块连续的内存区域,选择性的回收部分区域,这也就是G1独有的mixed gc模式。

为了实现G1独特的回收模式,原先的卡表要获得进化,原先的卡表仅仅是标识老年代指向了年轻代,在仅仅回收年轻代的时候,我们可以通过筛选卡表中的脏表,将脏表中的对象加入到gc roots。但在分区下面,我可以一个年轻区域搭配一个年老区域进行回收,这意味着过去的卡表有些不够用,我们急需要扩展卡表的功能,也就是说卡表要记录的是所有region的引用,我们在触发回收过程的时候,就要遍历全部的卡表,这就有些慢了,尤其是各个区域不连续的前提上。

于是为了空间换时间,我们选择为每一个为每一个region都准备一个记忆集,好处是速度变快了,坏处是消耗的空间也变多了。这在(一)里面有过对应的讨论,这里不再进行赘述。

在学习G1的过程中,我还翻阅了《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》这本书看到了增量更新,所谓的增量是指遍历可达对象的时候,对象的引用链发生了修改怎么办,对此CMS垃圾回收器的选择是并发标记期间,这类改变了我们遍历图的对象,我们会再进行冲标。G1的选择是在快照,也就是以并发标记那一刻的对象为准。

参考资料

[1] docs.oracle.com/en/java/jav…

[2] Java中9种常见的CMS GC问题分析与解决 tech.meituan.com/2020/11/12/…

[3] JEP 439: Generational ZGC openjdk.org/jeps/439

[4] 《A Real-time Garbage Collector with Low Overhead and Consistent Utilization》 www.cs.purdue.edu/homes/hoski…

[5] 《List Processing in Real Time on a Serial Computer》 dl.acm.org/doi/pdf/10.…

[6] 《Incremental incrementally compacting garbage collection》 dl.acm.org/doi/10.1145…

[7] G1 GC 停顿预测模型 sdww2348115.github.io/jvm/g1/Paus…

[8] Implementation for JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector bugs.openjdk.org/browse/JDK-…

[9] Major GC和Full GC的区别是什么?触发条件呢? www.zhihu.com/question/41…

[10] 官方文档竟然有坑!关于G1参数InitiatingHeapOccupancyPercent的正确认知 heapdump.cn/article/271…

[11] Garbage-First (G1) Garbage Collector docs.oracle.com/en/java/jav…

[12] www.bilibili.com/opus/592749…

[13] [www.zhangy-lab.cn/teaching/%E…]

[14] JDK-8017163 bugs.openjdk.org/browse/JDK-…

[15] G1源码从写屏障到Rset全面解析 heapdump.cn/article/260…

[16] JVM 源码解读之 CMS GC 触发条件 heapdump.cn/article/190…