• 前置知识:可达性分析算法,分代回收机制,G1基本原理

Garbage-First垃圾回收器,想必各位都不陌生,但涉及到其原理,包括Remembered Set ( RSet )、Card Table、写屏障等一系列名词,只背八股文可能就有些不够了,今天我们还是以“问题-方案”的形式来深入剖析一下其原理。

首先G1基本原理这里不做过多介绍,将堆分成不同区域(如下图),使用局部复制(整体上看是标记整理)的机制进行垃圾回收,有 Young GC 和 Mixed GC 两种方式,同时支持动态调整内存大小,其有几个显著特点:

  • 避免内部碎片
  • 充分利用CPU资源,发挥并行优势
  • 每次不是回收整个堆,而是回收一部分,称为Collection Set
  • 允许用户自己控制最大等待时间,减少STW(这是最关键的一点)
    image.png

好的,我们接下来详细分析一下 Young GC 和 Mixed GC 的过程。
年轻代的回收,我们先来看看之前我们在年轻代回收上是如何做的:

  • 在之前的分代回收算法中,创建出的对象先放入Eden区,当达到某个阈值后触发 Minor GC ,将 Eden 区和 Survivor From 区共同进行垃圾回收,通过可达性分析,找到所有非垃圾对象,复制到 Survivor To 区域中,完成 GC 。

我们来思考这其中有什么问题,我们知道,可达性分析算法是通过系统中的一系列 GC Roots 节点出发,按其引用链向下寻找,如果在引用链上就代表不是垃圾,但是实际上,这其中GC Roots的引用链查找大有学问,这其中尤其涉及到跨代引用问题,就更加复杂。

为什么需要解决跨代引用问题?

不知道你是否有这样的疑问,都说什么RSet、CardTable解决跨代引用问题,那究竟为什么需要解决跨代引用问题?

想要回答这个疑问,我们要追溯到分代垃圾回收机制的诞生,在分代诞生之前,每次垃圾回收都要对整个堆进行垃圾回收,而随着堆越来越大,这种方式需要STW的时间越来越长,每次都要造成用户线程阻塞,于是研究人员开始想办法优化....如何优化?他们经过大量的实验(也许没有,我猜的)得出了对象存活时间的大概分布,即刚创建的对象和存活时间较长的对象是比较多的,于是他们打算依据此将堆拆分为新生代和老年代。
image.png
我们都背惯了八股文,什么 Minor GC ,Full GC ,但如果我们真的去细究,为什么要对堆进行分代呢?直觉上想,肯定是解决之前每次都要回收整个堆的问题,分代之后就可以分别对新生代/老年代,也就是堆的一部分进行回收,提高了回收效率。那我们继续深究,如何判断是否是垃圾呢,没错,当然是可达性分析算法,从 GC Roots 出发,但这里出现了一个问题,这个问题也是困扰了我很久才得以解决,我们都知道 Minor GC 相对于 Full GC 来说是要很频繁地执行的,平时大家总说 Full GC 效率低下,Minor GC 效率高,高在哪了?二者不都是从 GC Roots 出发去扫描引用链吗?

翻阅了大量资料后,这个问题终于得以解决,事实上,Minor GC 和 Full GC 扫描 GC Roots 的过程是不一样的

  • Minor GC在扫描引用链时,碰到老年代对象就停止向下扫描(可以认为是一种剪枝)
  • Full GC在扫描引用链时,会进行全引用链的扫描(这也是慢的原因)

image.png
如图,Minor GC 在遍历时,1路径和2路径在遍历到 Old 对象时便不再向下进行,而3路径找到的Young对象被标记为非垃圾,不会回收,正是这种遇到Old对象就停止的“剪枝”策略,导致了 Minor GC 比 Full GC 效率更高,于是我们可以解决刚才抛出的问题了:为什么需要解决跨代引用问题?正是因为这种剪枝策略导致2-4这条路径上的Young对象没有被扫描到,所以如果我们不解决跨代引用问题,就会导致该对象被意外回收,造成不可想象的后果。

当然,不必担心,研究人员们给出了结论:这种跨代引用的情况出现的比较少,基于此,我们才引入了后面的 Rset 与 Card Table ,包括写屏障来解决跨代引用的问题。

Young GC

好的,当我们明确了为什么要解决,接下来就是怎么解决,想必很多人都知道了,基于 Rset + Card Table + 写屏障去解决,但这里面的一些细节和问题,我认为还是有必要提一下。
当触发 Young GC 时,通过可达性分析算法找到一部分 Young 对象,标记为非垃圾,现在我们需要找到另一部分,像上面的2-4路径中的被老年代引用的新生代对象,怎么找?对于 Young 对象来说,我们自然能想到,让每个 Young 对象记录一下自己是否被老年代对象引用,在 Young GC 时遍历所有 Young 对象,就能知道谁被引用了,但是很显然,这种做法效率不高,我们来看研究人员们是怎么解决的:

首先,为每个新生代 Region 区配备一个Remembered Set,其中记录了这个 Rigion 中哪些对象被老年代引用了,等 Young GC 时直接遍历每个 Region 的 RSet 就能快速找到所有被老年代引用的新生代对象。

看到这是不是有些蒙了?这记忆集不是已经完美实现我们需要的功能了吗?那卡表是干什么的?实际上,就我个人看法而言,卡表是对记忆集的一种优化,是为了减少记忆集占用空间的一种技术,如果没有卡表,记忆集就必须记录该 Region 中新生代对象被哪些老年代对象引用,也就是对象精度,需要记录的东西太多了,而以卡页为单位去进行记忆,可以大幅缩小记忆集所占空间,至于为什么采用卡表而不是对象精度/字长精度的实现方式,应该是研究人员们进行了大量实验得出的效率最高的一种形式。

我们将整个堆分为许多卡页,并为每个 Rigion 配备一个卡表(这个卡表可以映射所有卡页),该卡表底层是一个字节数组,当某个老年代对象引用了年轻代对象时,就把该年轻代所在 Region 的卡表中对应的那一位修改为0,称为“脏页”。
image.png

上图中,老年代的B引用了A,D引用了C,那么在 Young Region 的卡表中,就将B和D所在的卡页5和8记录为脏卡,代表5和8卡页中有对象对当前 Region 引用,于是在将来进行 Young GC 时,会将5和8卡页中的所有对象加入 GC Roots 中,那么通过B和D这两个 GC Roots 自然就能找到A和C,将其标记为非垃圾,就不会错回收了。

然而,这种策略仍然存在问题,我们只是解决了跨代引用的问题,我们只是知道B引用了A,D引用了C便把B和D加入了 GC Roots ,如果B和D本来是不可达的对象呢?也就是说只要B和A的引用不断,那么这两个本应该回收掉的垃圾,在 Young GC 阶段一直无法回收,而是要等到 Mixed GC 真正扫描整个引用链才会被回收,官方没有对这种情况作出应对,我想大概是这种情况实在是概率很低吧,等 Mixed GC 解决就可以了,这也就是我们的标题--“宁大赦天下也不错杀一个”,宁愿留下A这种垃圾等待 Mixed GC 解决,也不能错误地删除掉一些跨代引用且在引用链上的新生代对象,我想,这是研究人员们做出的妥协。

同时,上图中的E也会被加入到 GC Roots 中,但这无伤大雅,因为E如果没有引用对象,那完全没事,如果E引用了老年代,在扫描时仍然会被直接剪枝,如果E引用了新生代,那么在那个新生代所在的 Region 区的卡表中也必然记录了5这个卡页。

理解了卡表和记忆集,我们接下来要探讨卡表是什么时候写进去的了。我们可以想,什么时候卡表会被修改?当出现了对象引用的时候,也就是说,在用户代码中的每一次对象引用的创建或者是改变,都应该去查看是否需要修改卡表,所以我们在涉及到对象引用的代码前后加入写屏障,其中写后屏障会检查是否出现了跨代引用,如果出现了就修改相关的卡表,并将脏卡放入脏卡队列,由单独的线程Refinement去读取脏卡队列并更新记忆集。为什么不修改卡表后直接更新记忆集而是用一个单独的线程?这里和多线程的思想类似,我为什么要新开一个线程去做某件事而不是自己去做?原因在于我自己有其他的事或者是这个任务太过耗时,这里也一样,写后屏障指令是插在用户代码中的,自然由用户线程去执行,而用户线程有许多其他的功能,自然不能话花费很多时间在更新记忆集上,所以用户线程只需要简单地更新一下卡表,并将对应脏卡放入队列中,后续由另一个线程去修改即可。

好了,到这核心的技术都介绍完了,我们可以来梳理一下 Young GC 的全过程了。

  • 找到原始 GC Roots 对象
  • 如果脏卡队列中还没空,GC线程帮助Refinement线程生成记忆集,清空脏卡队列
  • 将记忆集中所有对象加入 GC Roots
  • 从 GC Roots 出发进行剪枝查找,标记非垃圾对象
  • 根据设定的最大停顿时间,评估回收哪些区域,生成 Collection Set
  • 开始回收,采用复制算法,在过程中如果发现有对象符合老年代晋升标准,晋升老年代

整个过程是STW的,至于为什么必须STW,在后面的 Mixed GC 会给出解答。
此外,写前屏障有什么用,也会在后面作出解释。

Mixed GC

接下来我们来看 Mixed GC 的过程,Mixed GC 一般是对整个堆进行回收,效率较低,其回收过程大致可以分为以下几个步骤

  1. 初始标记,STW
  2. 并发标记
  3. 最终标记,STW
  4. 清理,STW

image.png

有基础的朋友能看出来,这个步骤和CMS差不多,不同的是CMS在并发标记阶段使用的是增量并发标记,而G1使用的是SATB算法,有关CMS的知识我们以后再来研究。

初始标记阶段,大家都知道,采用了三色标记法,也就是黑-灰-白,但是为什么要采用三色标记法呢?我们知道 CMS 和 G1 这两种垃圾回收器之所以性能高,就是因为能够支持并发标记,那我们来回想一下之前怎么标记:从 GC Roots 出发,扫描整个引用链标记存活对象,全程STW,而使用了三色标记法,可以先进行初始标记(这个过程很快,虽然需要STW但是基本耗费不了什么时间),将一些对象标记为灰色并放到队列里,而很耗时的大批量的标记阶段变为里可以与用户并发执行的并发标记阶段,在这个阶段可以从队列中取出灰色对象并继续标记,STW的时间大大减少,但是三色标记法会有比较严重的并发问题,如果不解决,会对JVM产生不可估量的问题,我们来看:
image.png
左图是标记某个时刻时的状态,此时正在并发标记中,标记线程与用户线程并发执行,如果用户修改了引用关系,修改为右图所示,在下一次标记时B会变为黑色,而D因为被C引用而C不是灰色,所以D最后是白色,就导致了在垃圾回收时,D会被回收,但它实际上还是在引用链上的,这会导致非常严重的问题。

为了解决并发问题, G1 垃圾回收器使用了 SATB 算法,具体做法是:首先在标记阶段生成一个快照,记录当前所有对象,并且后续标记阶段所有生成的新对象直接标记为黑色,然后加入写前屏障,在与对象引用有关的代码前加入写屏障,在操作对象引用关系前,判断被引用的对象是否是快照中保存的对象,如果是,将该对象加入到 SATB 队列中,等到最终标记阶段将 SATB 队列中的对象全部当作存活对象拿出来进行最终标记,这样就会保证不会漏掉任何一个对象了。

但是,这样做也有问题,我们可以很明显的看出来,有很多对象可能本来是垃圾,但是这一轮标记被标记为黑色,导致不能被回收,只能等待下一轮垃圾回收,所以在 Mixed GC 环节,一样会产生浮动垃圾,但 G1 为了安全性仍然保留了他们,这也再次对应了我们的标题--“宁赦天下不错杀一个”,这也导致了,G1必须设置一个阈值,在老年代达到该阈值时就进行 Mixed GC ,该阈值不能为100%,因为我们要给浮动垃圾留足空间。


Echo
2 声望1 粉丝