G1 垃圾收集器

2,088 阅读12分钟

一、重要概念

1. 分区(Region)

G1将堆分成大小相等的分区(Region),每个分区可以是Eden,Survivor或Old,同一时刻每个分区只能属于一个代。G1 中 Region 大小最小是 1MB,最大是 32MB。具体多大会根据 Heap 大小做设置,它是尽力去保证整个 Heap 被划分为大约 2048 个 Region。比如如果 Heap 有 16G,算下来 16G / 2048 = 8MB 即一个 Region 大概是 8MB。当然 2048 个 Region 也不是绝对的,如果 Heap 特别大或者特别小,Region 总数是可以超过或小于 2048。Region 总数也能通过参数精确设置 -XX:G1HeapRegionSize=n。

单个对象内存占用大于Region的一半的称为大对象(Humongous Object),Humongous Object 分配时会根据这个对象的大小,在 available regions 中找足够放下这个 object 的连续的数个 region,专门分配给这个 Humongous object 使用,如果这个对象的大小小于一个Region,则会占用一整个Region。如果找不到这么个连续的 region,G1 会直接使用 fail-safe 的 FGC 来清理并 compact heap。理解这里不先进行 YGC 或 OGC 的原因是 YGC 和 OGC 很多过程都是 concurrent 的,这个时候 Humongous Object 无法分配内存,无法让应用线程继续运行,必须执行完全的 STW 收集一次内存才行。从下图可以看到连续的 Region 是由 StartsHumongous 和 ContinuesHumongous Region 组成的。

开辟单独的区域存放 Humongous Object 是为了避免 long-live 的大对象在 GC 过程中的拷贝。开辟连续的 Region 只存放一个 Humongous Object 是为了让 G1 对 Humongous Object 更激进的进行收集,只要发现这个 object dead,就能将其所占用的 Regions 全部收集,不用去判断 Region 还有没有别的 object 使用,别的 object 是否还 live。比如在 marking 的 clean up 阶段、 YGC 和 FGC 时,发现 humongous object 没有任何引用,就会立即被收集。

2.Collection Set (CSet)

一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。

3.Remembered Set (RSet)

每个Region都有一个RSet,记录了在其他Region中的引用了本Region中的对象的对象所在的card的index。属于point into结构。RSet使得垃圾回收器不需要扫描整个堆就能知道有哪些其他分区的对象引用了该分区的对象。如下图所示,Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。

摘一段R大的解释:G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。 这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。 举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

RSet的更新是通过一个写屏障(post-write barrier)来完成的,RSet的更新是整个应用生命周期都会发生的。post-write barrier在用户线程写入一个 reference 的时候被调用,例如x.f=y。整个更新过程如下:

  1. 判断y是不是null,如果是null则没有更新必要,之后判断y与x是不是属于同一个Region,如果y不为null且与x不在同一个Region,则进行下一步;
  2. 调用rs_enqueue方法,该方法首先判断x所在的card是否在card table中被标记为dirty,如果是则结束;如果不是,则先将该card标记为dirty,然后将x所在的card的放入线程本地的remembered set log(也称为dirty card queue,因为存放的实际是dirty card)里,如果队列满了,则将其放入全局队列里(filled RS buffers),并为该线程分配一个新的队列。

以上是写屏障做的事,当filled RS buffers中的log数量达到一个阈值时,会有一组concurrent refinement threads 对里面的card进行处理:首先将该card的状态置为clean,然后检查该card中的所有对象是否有指向其他Region对象的指针,如果有,则更新对应Region的RSet。

G1限制了filled RS buffers的大小,超过这个值提交dirty card queue(remembered set log)的线程需要自己处理这些card,这会造成一定的性能影响。

4.Snapshot-At-The-Beginning (SATB)

SATB是维持并发GC正确性的一个手段,G1 GC的并发理论基础就是SATB。G1垃圾回收器使用该算法在标记阶段记录存活对象的快照。在并发标记阶段,应用线程可能会修改一个对象的引用,比如删除或者指向其他对象,这就导致了在并发标记阶段结束之后存活对象的快照与开始时不一致。GC通过引入一个 写屏障(pre-write barrier) 来解决这个问题:在并发标记周期里,每当存在引用更新的情况,应用线程都会在引用修改之前,将该对象引用的对象存到线程本地的SATBMarkQueue,也叫SATB的日志缓冲区中(SATB log buffer),如果SATB日志缓冲区满了,将会将该缓冲区放到全局SATB日志缓冲区列表里,然后再给该线程分配一个SATB缓冲区,并发标记线程会定期检查和处理那些被填满的SATB日志缓冲区,在最终标记过程中会STW并把所有的SATB日志缓冲区都处理完。

比如在 concurrent marking 过程中,业务线程执行如下语句:

x.f = y

也就是说修改了 x 这个 object 中 f 这个引用,令其指向了 y 。那么 x.f 原本指向的 object 可能死亡了也可能还活着,根据 SATB 的要求,需要将其标记为 live。pre-write 的代码逻辑类似:

if (is-marking-active) {
    prev = x.f;
    if (prev != Null) {
        satb_enqueue(prev);
    }
}

也就是说如果在 marking 过程中,x.f 的引用发生改变,需要将 x.f 原本指向的 object 放入 satb_enqueue 以异步的方式将 x.f 原本指向的 Object 标记为 live。

5. Marking bitmap和TAMS

标记位图(Marking bitmap)是一个数据结构,用来在标记周期里标记存活对象,其中的每一个bit代表的是一个可用于分配给对象的起始地址。举例来说,下图中addrN代表的是一个对象的起始地址。绿色的块代表的是在该起始地址处的对象是存活对象,而其余白色的块则代表了垃圾对象。

Marking bitmap是一个全局性的数据结构,每个Region里的地址都可以很方便的映射到bitmap上。G1在使用了两个bitmap:previous bitmap和next bitmap。previous bitmap保存上一次完成的标记信息,而next bitmap是当前并发标记周期创建的并记录标记信息的bitmap,当此次标记周期结束时,会用next bitmap覆盖掉previous bitmap。

每个Region会维持两个TAMS(top at mark start),用来记录标记开始时的该Region的top位置(top是已分配对象的区域与未分配对象的区域的分界线,比top地址小的区域是已分配对象的,比top地址大的区域是未分配对象的)。这两个TAMS分别是prevTAMS和nextTAMS,表示上一次标记开始时的top位置和这一次标记开始时的top位置。nextTAMS到top之间的位置被认为都是标记的存活对象(隐式标记,这部分的对象是在标记周期开始之后用户线程创建的新对象)。

上图以一个Region为例,帮助我们清楚地理解previous bitmap和next bitmap。

  1. A阶段是程序启动之后的第一次初始标记,这时候prevBitmap是null,nextBitmap是新建的空bitmap,prevTAMS指向Region的起始地址bottom,nextTAMS指向当前的top;
  2. B阶段是重新标记阶段,这时候nextBitmap里已经有一些bit被标记了,而top也向前移动了一段位置,在nextBitmap于top之间的就是初始标记开始到现在所产生的新对象,默认是被标记的对象;
  3. C阶段是cleanup阶段,将prevBitmap指向nextBitmap,而nextBitmap变为null,并将nextTAMS指向bottom。
  4. D阶段我们看到在新一轮的并发标记周期开始时,prevBitmap存储了上次的标记信息,而nextBitmap依然是重新创建的新位图,然后开启新一轮的标记。
  5. E,F就是上面B,C过程的重复。

6. Evacuation

Evacuation是真正执行垃圾回收的。Evacuation阶段是STW的,大概可以分成两个步骤:第一个步骤是从Region中选出若干个Region进行回收,这些被选中的Region称为Collect Set(简称CSet);而第二个步骤则是把这些Region中存活的对象复制到空闲的Region中去,同时把这些已经被回收的Region放到空闲Region列表中。

二、过程

1. 新生代垃圾回收(YGC)

当Eden区耗尽时会开启对新生代的垃圾回收,整个过程STW,新生代收集时由多个gc线程并行执行的,新生代收集之后存活的对象会被拷贝到一个新的survivor区中,或者老年代。

2. 并发标记周期(Concurrent Marking Cycle Phases)

整个堆的使用率达到了阈值,则开启并发标记周期,该阈值由参数 -XX:InitiatingHeapOccupancyPercent指定,默认为45%。并发标记总共由下面5个阶段组成:

2.1. 初始标记(Initial Mark)

初始标记是标记出gc roots直接可达的对象。

在这个阶段,会STW,由于新生代的垃圾回收也会STW,所以将这个阶段背负在一次YGC上,也就是在新生代垃圾回收时进行初始标记,这样虽然会使STW的时间延长,且增加CPU的开销,但是比单独进行一次STW的初始标记+一次YGC的STW时间要短。在这个阶段会重置nextBitmap,并将各个Region的nextTAMS指向对应的top。

2.2. 根区域扫描(Root Region Scan)

扫描Survivor区域的对象,标记出被这些对象引用对象。Survivor区域对象是伴随初始标记阶段的YGC产生的对象,所以都被认为是存活的对象,因此做为marking roots扫描。

这个阶段是和应用程序并发执行的,并且这个过程不能被YGC所打断,因为YGC会修改survivor区,如果在这个过程中不得不进行YGC,那么YGC会停顿等待根区域扫描完成之后再进行垃圾回收。在下面的一段GC 日志中可以看到YGC在根区域扫描结束之前就开始了等待。

50.819: [GC concurrent-root-region-scan-start]
51.408: [GC concurrent-root-region-scan-end, 0.5890230]
...
350.994: [GC pause (young)
351.093: [GC concurrent-root-region-scan-end, 0.6100090]
351.093: [GC concurrent-mark-start],0.37559600 secs]

2.33.并发标记(Concurrent Mark)

标记整个堆中所有可达的对象,与用户线程并发执行,可以被YGC打断(因为YGC会STW)。并发标记会利用trace算法找到所有活着的对象,并记录在nextBitmap中,因为在nextTAMS之上的对象都被视为隐式存活,因此我们只需要遍历那些在nextTAMS之下的。

这个阶段会通过扫描nextBitmap上已标记的存活对象,并使用一个marking栈来进行深度遍历。具体过程可以想象一下,遍历nextBitmap,利用marking栈,三色标记等。

在并发标记线程在扫描和标记对象时,也会定期去查看SATB log buffer list,从中读出SATB log buffer,标记其中的对象,被这个对象引用的对象最终也会被标记到。

2.44.重新标记(Remark)

该阶段会STW,gc线程会消费掉所有的SATB log buffer,如果用户线程还继续运行,那么SATB log buffer还是会增加,导致无法被完全消费掉。除此之外还会做一下reference(soft,weak,phantom)的处理工作.

2.5.清理(Cleanup)

这个阶段会将prevBitmap指向nextBitmap,将prevTAMS指向nextTAMS。

另外这个阶段还有三个最耗时的操作:

  1. 计数Region中的存活对象,并按存活对象的比例从小到大排序(将包含垃圾最多的一些Region标记为X,在MixedGC是回收这些Region)。
  2. 发现没有 live object 的 Region 时直接将其清理,并将这些Region归还给空闲列表。
  3. 对每个 Region 的 RSet 进行清理,比如发现一个 card 中指向当前 Region 的对象已经变成垃圾了,就直接清理这个 RSet 内的记录。因为之后无论是 YGC 还是 Mixed GC 都会扫描这个 RSet,将其清理一下有助于提升之后清理过程中 RSet 扫描效率。

该过程在计数对象和清理RSet的时候是STW的,在重置没有存活对象的Region并将其归还到空闲列表时是和用户线程并发执行的。

所以该过程的主要目的并不是回收内存,只会回收没有存活对象的Region。

3.Mixed GC

在并发标记周期完成之后,G1从执行YGC切换到执行MixedGC。MixedGC会同时清理新生代Region和部分老年代Region,我们可以说这个时期同时进行YGC和清理在并发标记周期结束被标记为X的Region。像普通的YGC那样、G1完全清空掉Eden同时调整survivor区。每次MixedGC只会清理一部分老年代中被标记的分区,所以MixedGC会持续发生,直到将所有被标记的分区都清理。之后G1从MixedGC模式转换为YGC模式,直到再次达到并发标记的条件,开启新一轮并发标记周期。

在R大的帖子中,给出了一个假象的G1垃圾收集运行过程,如下图所示,在结合上一小节的细节,就可以将G1 GC的正常过程理解清楚了。

三、G1调优

参见[www.javaadu.online/?p=465]第四、五…