详细谈谈G1回收器

借助“三色标记”大法我们知道在垃圾回收线程扫描的过程中,用户线程同时执行修改引用关系的操作时,可能会出现的“对象消失”问题,以及其对应的两种解决方案。

增量更新和原始快照

对象关系图的变化会导致出现两种情况,一是“浮动垃圾”,二是“对象消失”。大概率的情况下面试官更加关心第二种情况,因为第二种情况会给程序带来异常。

G1垃圾回收时新对象怎么处理?

GC线程和用户线程并发执行时,用户线程修改了对象引用关系,导致“对象消失”的问题。G1是采用原始快照加写前屏障的方式解决这个问题的。
还有另一个问题:用户线程不仅修改了对象引用关系,还新分配了新对象,这个情况是非常常见的,G1是如何找到并处理这些对象的呢?换句话说就是,G1收集器是如何知道这些对象是什么时候进行垃圾标记的?

初识Garbage First(G1)

Serial,Parnew, Parallel Scavenge,Serial Old,Parallel Old,CMS等垃圾收集器工作的时候,Java堆得内存布局是按照新生代、老年代进行整体区域划分的。但是到了G1收集器,Java堆内存布局就有点不同了:它虽然还保留有新生代和老年代的概念,但是再也不是区域上的隔离了。它将整个Java堆划分成多个大小相等的独立区域,叫做Region。新生代和老年代就是由一个个的Region动态组成,可以不是连续的区间
每一个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间,或者老年代空间。除此之外还有一类特殊的区域叫做Humongous,专门用来存储大对象。
看一下下面的对比图可以更清晰的了解。
对于CMS,使用的堆内存结构如下:

微信图片_20200527115613.png

可以看到上面无论是年轻代、老年代都是逻辑上连续的空间(不要求物理上的连续)。
而G1的堆内存被划分为多个大小相等的Region,但是Region的总数在2048个左右,默认是2048。对于一个 Region来说,是逻辑连续的一段空间,其大小的取值范围是 1MB 到 32MB 之间:
微信图片_20200527120505.png

微信图片_20200527120525.png

可以看到H是以往的垃圾收集器中没有的概念,代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小的一半时,直接在新的一个或者多个连续的Region中分配,并标记为H
上面有几个数据,2048、1MB~32MB,这些数据哪里来的呢?
很多文章聊到G1的时候都只是说堆内存被划分为多个大小相等的 Region , Region 大小的取值范围为 1MB 到 32MB ,但是并没有提到 2048 这回事:源码中是这么写的:源码
微信图片_20200527121433.png

G1的工作步骤

众所周知,一般我们说G1的收集过程分为下面这四个步骤:
初始标记(Initial Marking):这个阶段仅仅标记GC Roots能够关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行,能在正确的可用的Region中创建对象,这个阶段会停顿线程,但耗时很短
而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

并发标记:从GC Roots开始对堆对象进行可达性分析,递归扫描整个堆里的对象图,找出活着的对象,这个阶段耗时长,但是可以与用户线程并行执行。
当对象图扫描完成后,还要重新处理SATB记录下的在用户并发时有引用变动的对象。

最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍然遗留下的少量SATB记录。

筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收成本和价值进行排序,然后根据用户设置的停顿时间来制定回收计划。
可以自由选择任意多个Region构成回收集,然后把决定回收的一部分Region的存活对象复制到空的Region中,在清理掉整个旧的Region的全部空间。
这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

我们把上面四个步骤分成两大部分,或者从整个算法的角度可以分成两大部分:
1、Global Concurrent Marking:全局并发标记
2、Evacuation Pauses:该阶段负责把一部分Region里的存活对象拷贝到空Region里去,然后回收原本的旧的Region空间

全局并发标记

回到一开始的问题:用户线程执行的时候不仅修改了对象引用关系,还新分配了新对象,G1 是如何找到并处理这些对象的呢?
要回答这个问题,就涉及到 TAMS 了。前面我引用的书里说:初始标记(Initial Marking):这阶段仅仅只是标记 GC Roots 能直接关联到的对象并修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的 Region 中创建新对象。
那什么是 TAMS?什么是正确可用的 Region?新对象是创建在 Region 中的哪个位置的?
我们先从论文入手:
1.有两个 bitmap。
2.一个叫 previous,一个叫 next。
3.previous bitmap 是 concurrent marking 阶段完成后的最后一个 bitmap。
4.next bitmap 是当前将要或正在进行的 concurrent marking 的结果。
5.当标记完成后,两个 bitmap 会交换角色。
6.标记周期的第一个阶段就是清理next bitmap
7.初始标记阶段Stop The World,目的是标记GC Roots能直接关联到的对象。该阶段借助Minor GC完成,没有额外的停顿。
8.每个Region包含两个TAMS
9.一个对应前一轮标记,一个对应后一轮标记

从上面我们可以知道,G1的Concurrent Marking 用了两个 marking bitmap。
一个 previous Bitmap 记录的是上一轮 Concurrent Marking 后的对象标记状态,因为上一轮已经完成,所以这个bitmap的信息可以直接使用。
一个 next Bitmap 记录的是当前这一轮 Concurrent Marking 的结果。这个bitmap是当前将要或正在进行的 Concurrent Marking 的结果,尚未完成,所以还不能使用。

我们可以假设一次并发标记变成后的 Bitmap(previous Bitmap) 大概长这样:


微信图片_20200527153654.png

白色地址之间是可以回收的对象,灰色地址之间是不可以回收的对象。
除了两个 bitmap 外,还有两个 TAMS(top at mark start)。每个 Region 都有两个 TAMS,分别是 previous TAMS 和 next TAMS。

bitmap 和 TAMS 可以用下面的图片来表示:


微信图片_20200527165158.png

首先我们可以看到 bottom 和 top 之间是一个 Region 已使用的部分。Top 到 end 之前是一个 Region 未使用的部分。
另外可以看到上面我留了四个问号,接下我们的目的就是填补这些问号。当这些问号被填上之后,所有的问题都会迎刃而解。

两个 Bitmap 和两个 TAMS 是怎么工作的呢?
接下来按照:
初始标记(Initial Marking)
并发标记(Concurrent Marking)
最终标记(final marking,也叫Remark)
清理阶段(Cleanup)
这四个阶段来说明:

初始标记(Initial Marking)

微信图片_20200527165410.png

从图片可以看到初始标记阶段 nextBitmap 是清空状态,没有标记任何存活的对象。再回到初始标记的描述:
这阶段仅仅只是标记 GC Roots 能直接关联到的对象并修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的 Region 中创建新对象。
GC Roots能直接关联到的对象:就是一个 Region 已经使用过的部分,所以在 Bottom 与 top 之间。
修改TAMS的值:就是让此时的 prevTAMS 指向 Bottom ,也就是一个 Region 内存地址起始值。让此时的 nextTAMS 指向 Top。Top 实际上就是一个 Region 未分配区域和已分配区域的分界点
正确的可用Region:对一个 Region 来说,当上面的 nextBitmap 为空、4个指针都准备就绪后,这个 Region 在下一阶段用户程序并发运行时,就是一个正确的 Region。

下一阶段用户程序并发运行时,在正确的可用的 Region 中创建新对象是什么意思呢?
下一阶段用户程序并发运行时指的就是并发标记阶段。

并发标记(Concurrent Marking)

再看一遍并发标记的描述:
并发标记(Concurrent Marking):从 GC Roots 开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
上图:


640 (1).gif

从GC Roots开始对堆得对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象:意思是说,在并发阶段 GC 线程工作在 prevTAMS 和 NextTAMS 之间,对堆里的对象进行可达性分析(回想一下“三色标记”),标记完成后, NextBitmap 就有对应有值了(里面放的是地址值),黑色对应的是存活对象,白色对应的垃圾对象。(最后结果如上图)这样就找出存活对象了。
此时:NextTAMS和Top之间的对象,就是本次并发标记阶段用户线程新分配的对象,他们是隐式的。为什么这么说?
另外,关于 NextTAMS 与 Top 为什么是重叠的,也得补充说明一下:并发标记的前一个阶段是初始标记。由于初始标记是 STW 的,所以动图中我们可以看到:
并发标记开始,即初始标记结束的时候, NextTAMS 与 Top 是重叠的(用户线程停滞)。随着并发标记过程的进行, NextBitmap 被填充上了值。而 NextTAMS 与 Top 之间的区域越来越大,这就是用户线程在并发标记阶段分配的新对象。
GC 线程的工作区间和用户线程的工作区间是有重叠的,如下图:

微信图片_20200527174803.png

而重叠的部分,就是可能产生“对象消失”的部分。对G1来说,就是原始快照(STAB)加写前屏障(Pre-Wirte Barrier)工作的部分。
这就是为什么说:当 GC 线程扫描完对象图后,还需要重新处理 STAB 记录下的在并发时有引用变动的对象

最终标记

先看描述:
最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
最终标记阶段,由于是 STW 的,所以该阶段对应的图是并发标记阶段完成后的图,如下:


微信图片_20200527175335.png

处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录是什么意思呢?
并发标记阶段, GC 线程完成对象图的扫描之后,还会去处理 SATB 记录下的在并发时有引用变动的对象。
在处理 SATB 记录的数据的时候,由于用户线程可能还是在继续修改对象图,继续在产生 SATB 数据,所以还是会有一小部分的 SATB 数据,所以才需要一个短暂的暂停。

清理阶段(Cleanup)
其实就是筛选回收阶段,包含了清理阶段和回收阶段,这里先讨论清理阶段。
在这个阶段, NextBitmap 和 PrevBitmap 会交换位置,如下图所示:


640.gif

可以看到,NextBitmap 和 PrevBitmap 交换了位置,NextTAMS 和 PrevTAMS 交换了位置。
而 Region 中, Bitmap 白色部分对应的已使用内存变成了浅灰色。它仅仅是标记了出来,并没有进行清扫操作。
需要注意的是:清理阶段不拷贝任何对象
清点和重置标记状态:这个阶段有点像 mark-sweep 中的 sweep 阶段,不过不是在堆上 sweep 实际对象,而是在 marking bitmap 里统计每个 Region 被标记为活的对象有多少。这个阶段如果发现完全没有活对象的 Region 就会将其整体回收到可分配 Region 列表中。
这样就得到下面这张图:

77AA5D31-EE40-436c-BA3D-BD1D170A339B.png

看一下整个过程图:
640.png

动起来:
请注意各个阶段 PrevTAMS 、 NextTAMS 指针的交换、 PrevBitmap 和 NextBitmap 位置的交换:
640.gif

(转自why技术公众号)

你可能感兴趣的:(详细谈谈G1回收器)