JVM垃圾收集器概述

前言

通过细说Java垃圾回收我们已经理解了常见的垃圾回收算法,有了垃圾回收算法后我们就需要垃圾收集器去执行。有点抽象,但是我们可以把垃圾回收算法类比为打扫垃圾时候所使用工具,可以是扫帚也可以是吸尘器。有了工具还需要具体的人去操作它,可以是你爸妈、爷爷奶奶或者你自己。这就是垃圾回收算法和垃圾收集器之间的关系。

分代假设时期

在我细说Java垃圾回收里面讲到的算法都是依据分代收集的理论进行设计的,分代收集建立在两个分代假说上:绝大多数对象都是朝生夕灭的。熬过越多次垃圾收集过程的对象就越难以消亡。因此将Java堆分为新生代和老年代,新生代每次垃圾回收都伴随大量对象死去,所以采取复制算法,并内存区域分成1块较大的eden和2块较小的survivor;熬过多次垃圾回收的内存逐步进入老年代,并采用标记整理算法进行回收。

古典时期

抛开复杂的理论知识,如果让你设计一款垃圾收集器,那么你会怎么做?从历史经验来讲,大部分的系统设计都是由串行到并行。

一个Java进程里面会有多个线程,每个线程都会产生不同的垃圾。最容易想到的方式就是用串行的方式分别去收集新生代和老年代,并且再进行垃圾收集时,暂定所有工作线程,直到收集完成。就好比你妈再给你打扫房间的时候对你说“儿子,把你那扔垃圾的手给我停下!我要打扫房间了,等我打扫完毕你在干”。这就是最开始的垃圾串行收集器。

JVM垃圾收集器概述

 

后来的话人们就嫌慢,好比你妈正在给你打扫垃圾,你就只能定在那里不能动。所以串行垃圾回收器的最大问题就是它在进行垃圾收集的时候,必须暂停其他所有工作线程,直到收集结束。自然而然人们想到的就是,我们现在有多个CPU那么我一次性采取多个垃圾收集器进行收集不就好了。

JVM垃圾收集器概述

 

中古时代

即使有了并行的垃圾收集器,但是你依然无法避免应用的停顿下来清理垃圾。那此时怎么办呢?即使增加垃圾回收线程,到一定程度也会有性能瓶颈,那么我们能做的就只有尽量减少程序的停顿时间。于是此时引入了一种新的GC算法叫CMS(Concurrent Mark Sweep)

JVM垃圾收集器概述

 

从上图也可以看出,古典时期的垃圾回收STW的时间很长。所以CMS垃圾回收的目的也很简单:尽可能的减少垃圾回收时的停顿时间。

初始标记

JVM垃圾收集器概述

CMS首先进行的是初始标记,初始标记有一个很短暂的STW暂停,仅仅是标记一下GC Root能直接关联到的对象,速度很快。

并发标记

JVM垃圾收集器概述

接下来的并发标记使用一个和用户线程并行跑的的线程,从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

重新标记

JVM垃圾收集器概述

因为并发标记是和用户线程一起执行的,那么势必在标记过程中会产生一些新的引用变换,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

并发清除

JVM垃圾收集器概述

好了这时候我们知道谁个是垃圾了,就使用一个和用户线程并发进行的垃圾回收线程将它回收掉。

总结

总的来说CMS它是一个低延迟的垃圾回收算法,我们从他的名字Mark Sweep标记清除就能够明白它使用的标记清除算法来回收垃圾,也就意味着垃圾收集结束时会产生大量的空间碎片,碎片过多的话则会影响大对象的分配。使用标记清除也就决定了它是一个老年代的垃圾回收算法。因为它是老年代的算法,所以必须和一些年轻代的算法搭配使用,但是CMS无法和之前介绍经典的Parallel Scavenge搭配使用。所以人们就只有开发了一个新的年轻代垃圾回收器ParNew,所以目前为止各款垃圾收集器的搭配关系就如下图:

JVM垃圾收集器概述

不过遗憾的是在19年12月的时候已经被移出到最新的JDK中,所以它不在最新的JDK标准中了,因为它并不是很完善也从未成为JDK默认的垃圾回收器,但是依然无法影响它的地位。

现代垃圾回收理论

我们也可以看出,垃圾回收算法进化的历程是很容易想到的,首先采用串行的去回收年轻代和老年代,后来串行嫌慢了就采用并行的,后来并行还是慢那么就设计一个和用户线程齐头并进跑的垃圾回收器尽可能的减少程序停顿时间。

至此我们已经挖进尽了基于分代模型下垃圾回收算法能够达到性能的极限,并且随着应用越来越复杂,堆也越来越大,垃圾回收一次需要的时间也越来越大,我们的系统的延时也会越来越大。迄今为止所有的串行、并行和CMS他们要么不回收,要么就回收整个年轻代和老年代,他们都会带来很高的延迟。原始的分区算法明显不适用与现在虚拟机越来越大的现状,需要有一种新的分区算法,于是就有了G1(Garbage First)垃圾收集算法,它的分区布局如下:

JVM垃圾收集器概述

我们可以看到再G1的内存布局中,没有像基于分代假说时看到的那样整个生命周期中内存布局不会改变,G1它会把堆分成若干个等大的区域称之为Region,默认情况分为2048个区域。此时每个区域存放内存不再是固定的,而是由G1自己去决定,它可能是Eden也可能是Old,所有老年代的成员加起来构成整个老年代。

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(CollectionSet,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

跨代引用问题

我们每次回收的时候都回收一小块区域,这样可以让我们的延时非常的低,但是如果老年代对象可能持有年轻代对象的引用,它是如何解决跨代引用的问题?

JVM垃圾收集器概述

如同上图新生代的内有对象D/E/F,他们分别被老年代对象A/B/C所引用,此时就无法回收此Eden的区域。所以我们需要一种方法知道有哪些再这个区域之外的对象引用了它,我们才能安全的将它回收掉。

Card Table(卡表) & Remember Set(记忆集)

为了解决跨代引用问题,此时引用了一种新的数据结构Card Table和Remembered Set(简写:RSet)。

JVM垃圾收集器概述

 

首先是卡表(Care Table),卡表中中的每个卡片(entry)表示覆盖512Byte的内存空间。如果entry中所覆盖的内存空间的对象,引用了别的Region的话就把卡标记为dirty。同时如果别的Region发现我的内存区域被别人引用了,就在RSet中记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1中的Remember Set本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。所以G1中Card Table记录了“我指向谁”,而Remember Set记录了“谁指向我”。因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

JVM垃圾收集器概述

就如同上图中Region2被Region1和Region3中的某些对象引用,那么此时就在RSet中记录下来我被引用Region的编号,同时记录被引用Region中引用内存卡页的位置。当我们要回收Region2的时候,就可以通过RSet知道谁在引用我,然后扫描对应卡里面的对象,从而避免了对整个堆的扫描提高了效率,这也是一种经典的空间换时间的方案。

Write Barrier(写屏障)

我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

首先卡表元素何时变脏是明确的:有其他Region区域中对象引用了本Region区域中的对象时,其对应的卡表元素就会变脏。所以JVM会在发生在引用类型字段赋值的那一刻,注入一小段代码,相当于为引用赋值挂上了一小段钩子代码,用于记录指针的变化。

JVM垃圾收集器概述

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

在G1中当对象引用关系变化时都需要更新RSet,所以如果这里是同步进行的话会非常影响性能。所以G1维护了一个Dirty Card Queue队列,更新指针时首先标记Card为Dirty,然后将Card状态信息存入Queue里面。

并发的可达性分析(三色标记)

目前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活,可达性分析算法理论上要求全过程都基于一个能保证一致性快照中进行分析,那么这就必须再扫描的时候全程冻结用户线程。在CMS之前的垃圾收集器也都是这样做的,但是后来CMS、G1的标记阶段采用了并发标记,意思就是说标记对象的时候不暂停用户线程。所以并发的可达性分析采用了一种三色标记算法。

白色:未被标记的对象

灰色:自身被标记,成员变量未被标记

黑色:表示自身和成员变量均已经被标记

他们的执行过程也很好理解,如下动图:

JVM垃圾收集器概述

1:初始阶段首先标记GC Root,他所指向的所有对象都是灰色,并且把灰色对象放入Grey集合里面(可以想象成有一个队列)。

2:接下来循环遍历灰色集合,遍历的过程中可以会有新的灰色对象加入进来

3:当灰色集合为空,说明已经标记完成,此时停止标记

4:标记结束,此时对象如果是白色就代表不可达,清除所有标色对象。

Lost Object Problem

我们需要明确的是,三色标记过程中,标记线程和用户线程是并发进行的。那么就有可能在我们的标记过程中标记中途用户线程修改了引用关系。一种是把原本应该回收的对象错误标记为已存活,这可能会产生浮动垃圾,但也可以容忍,逃过了本次垃圾回收那就等下一次了;另一种是把原本存活的对象错标成了已消亡,这可能会导致非常致命的后果,程序肯定会发生错误,我们重点讨论这种情况。

JVM垃圾收集器概述

首先如图1所示对象A已经被标记为黑,同时A所指向的对象B/C也已经被表灰。但是这时候有用户线程操作对象A引用了对象D,同时对象B指向对象D的引用消失了如图3。当标记线程开始标记灰色B对象的时候,就会发现B没有指向任何对象,就会直接把B标记为黑。最后标记结束发现对象D还是白色,就会把对象D回收了。但是引用对象A引用了对象B,此时就会发生程序错误。

总结一下发生漏标必须要满足的条件:

- 赋值器插入了一条或多条从黑色对象到白色对象的引用(图2)

- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。(图3)

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At TheBeginning,SATB)。

JVM垃圾收集器概述

增量更新

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

JVM垃圾收集器概述

CMS垃圾收集算法中使用的是这种方式,但是我们会发现把A标灰之后,需要再次以A为根遍历整个对象图,还是有一定的成本在里面。

原始快照(SATB)

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

 

最后,如果感觉对你有帮助就来个二连吧:关注、点赞!