本博文碍于作者的学识与见解,难免会有疏漏错误之处,请谅解。

转载请注明出处: https://www.morcat.cn 谢谢~

前言

最近公司开始集体升级JDK11了,作为一名JVM小白惊讶的发现自从服务升级之后,堆内存的使用率下去了,同时发生Full GC的次数也明显下降。我们知道JDK9之后,G1垃圾收集器成为了JDK官方唯一指定的垃圾回收器。这样的一款垃圾收集器究竟有着什么样的能力可以从众多优秀收集器中脱颖而出呢?抱着这样的疑问,于是有了这篇文章。

传统垃圾收集器的瓶颈

在介绍G1收集器之前,让我们先看下JDK8之前传统的那些收集器组合吧。

Parallel Scavenge + Parallel Old 组合

Parallel Scavenge + Parallel Old 的组合作为JDK9之前的默认收集器组合,也算是经历了时间考验的一对优秀收集器。

Parallel Scavenge是一款重点关注吞吐量的多线程新生代收集器,允许用户配置GC暂停时间大小并可以会根据策略自适应调节内存分配细节(如Eden区与Survivor区大小),从而使得吞吐量尽可能达到最大。吞吐量大指的是运行用户代码的时间会更多。但是他并不能保证低延迟,即用户线程的等待时间低,虽然已经使用了多线程并行进行收集,但是在标记-复制的时候仍然还是STW(Stop The World)的。这对于许多互联网公司来说是很讨厌的,毕竟没有谁会希望访问网页隔断时间就卡一下。

Parallel Old 则是一款多线程老年代收集器,常常与Parallel Scavenge一起组合使用,他的问题和Parallel Scavenge回收器一样,这里就不再赘述了。

image.png

CMS + ParNew 组合

CMS + ParNew 的组合常用于要求低停顿的场景,在JDK9后由于G1的崛起沦落至 Deprecate(不推荐使用) 的收集器。但作为一款曾被广泛使用的垃圾回收器组合也是HotSpot虚拟机追求低停顿的一次成功尝试。

ParNew 是一款多线程新生代收集器,是唯一能与CMS收集器配合工作的收集器。相当于Parallel Old的"新生代版"收集器就不详细说明了。

CMS(Concurrent Mark Sweep)正如其名字一样,是一款可以与用户线程并发收集的老年代垃圾回收器,能够在最耗时的标记阶段实现与用户线程并行,而不像其他收集器一样需要STW。但是他的缺点也非常明显:

  1. 对处理器资源(CPU)敏感,他在并发标记阶段虽然不会让用户线程停顿,但是由于会占用一部分线程资源而导致程序运行速度变慢,使用CMS垃圾回收器经常会出现垃圾回收期间CPU使用率激增的情况
  2. 会产生空间碎片,由于CMS是一款基于"标记-清除"算法的收集器,意味着在收集结束时会有大量空间碎片产生。这将会导致在分配大对象时,往往找不到足够大的连续空间进行分配而不得不触发一次Full GC,反而使得效率变慢。
    image.png

G1垃圾收集器介绍

G1的目标

根据官网中对G1的描述,G1设计出来希望达到的技术目标大致如下

  • 适用于 大容量内存,多核 的服务器
  • 像CMS收集器一样,能与 用户程序同时运行
  • 可以 整理内存空间的同时不需要大量GC暂停时间
  • 仍然能保持 较高的吞吐量
  • 可以 预测GC停顿时间

总结一下G1就是一款实现高吞吐同时尽可能保证GC停顿时间不会很长的收集器,也是CMS收集器的继承者。

基于Region的堆内存布局

传统的收集器问题都是基于分代理论,将堆内存划分成固定大小的连续内存区域。而G1开创了一套新的堆内存布局方案,虽然仍然是遵守分代理论的,但它把堆内存划分为多个大小想等的独立区域即Region。每一个Region都可以根据需要去扮演Eden区,Survivor区,Old区,Humongous区。(注:Humongous区是一类特殊区域专门存放大对象)

在回收时以Region作为最小的回收单位,G1会预测出每个Region的回收时间,回收后得到的内存大小以此计算出的该Region回收的"价值",根据用户设置的期望GC停顿时间,每次回收优先处理回收价值最大的Region,这也是G1(Garabage First)名字的由来原因。

个人认为使用这种内存布局好处在于:

  1. 一次回收不用针对全部内存,只需要先回收垃圾最多的region,提高了垃圾收集的效率
  2. 变相实现了只有新生代才有的复制算法,极大减少了空间碎片的产生(后续会提到)

image.png

RSet解决跨Region引用

我们知道GC时判断对象是否可以回收是使用的是可达性分析算法,即从GC Roots对象开始向下遍历所有可以被引用到的对象。
G1收集器将堆内存划分为多个Region后,一个Region中的对象可能会被另一个Region的对象引用。在回收Region时则不得不做一次全堆扫描。这样就降低了垃圾回收的效率。为了解决这个问题,G1引入了RSet(Remembered Set)。在每个Region初始化时都会初始化一个RSet,用于记录其他Region指向该Region的引用(谁引用了我)。在这种设计下,每个Region都会被分成若干个大小为512 Byte卡页(Card Page)。当RegionB中引用了RegionA的对象时,RegionA的RSet就会记录下引用了该对象的卡页区域地址。在要回收RegionA分区时,只需要扫描RegionA的RSet就可以确定该分区对象是否存活而不需要再扫描全堆。

image.png

在实际使用中,也并不是所有的对象引用被记录在RSet中。G1收集器只有老年代的对象引用会被记录进RSet中,因为Young GC时,是会对整个新生代进行回收,引用源自新生代的对象都会被扫描没必要再记录。而MIXED GC时,会先对新生代进行回收,同样也没有必要记录了。

注:在维护RSet时,会使用到写屏障(Write Barrier)技术。这里借用《深入理解Java虚拟机》一书中的比喻 『
写屏障可以看作在虚拟机层面对"引用类型字段赋值“这个动作的AOP切面』 在赋值前有写前屏障,赋值后有写后屏障,G1就是通过写后屏障更新RSet来达到维护的效果。

标记阶段如何实现GC线程与用户线程并行

并发标记也是整个GC过程中最为耗时的时间,此时GC线程会与用户线程一起运行,势必会产生新的对象同时删除旧的对象引用,此时如何保证标记的准确性,不出现误标的情况是很重要的,出现误标会有两种情况:

  1. 一个本应该被回收的对象被错误标记为存活。这种情况其实也可以接受,仅仅只是一些浮动垃圾等待下一次GC时清楚就好。
  2. 一个本应该存活的对象被错误标记为消亡,这种情况就会导致很严重的后果,程序会因此发生错误。接下来的解决方案也是主要针对这种情况来解决的

SATB(Snapshot At The Beginning)维护被删除的旧引用

SATB正如他的名字一样,在开始并发标记前会对所有对象做一个快照,抽象的说就是在一次GC开始的时候是活的对象就被认为是活的。这样可以防止标记过程中有对象被删除引用后再被其他新对象添加引用导致的误标。如何实现这种快照也很简单,仍然需要使用到上文介绍到的 写屏障 技术, 在有对象的引用被删除时将该引用给记录下来 即可。这样的话在重新标记的阶段只需要扫描这些内容就可以快速恢复快照的内容。当然这种快照的方式肯定会使得一些本应该被标记回收的对象没有被回收成为浮动垃圾,但是并不影响GC的正确性,等待下次GC清除即可。

注:CMS使用的incremental update的方式来维护旧引用的,即对新增的对象引用做标记。在重新标记阶段,需要STW来重新扫描GC Roots。相比之下G1只需要扫描被删除的引用,速率高出CMS许多。

TAMS(top-at-mark-start)指针来记录新分配的对象

针对于并发标记时新分配的对象,都会被认为隐式标记的,说白了就是 新分配的对象都默认为活着的对象 。为了实现这个目标,G1为每一个Region设计了两个指针,分别是prevTAMS和nextTAMS。新分配的对象必须位于这两个指针以上,在TAMS以上的对象都会被认为是隐式存活的。

image.png

[bottom, prevTAMS): 这部分的对象信息是已经被标记过的对象,其对象存活信息会使用prev bitmap来保存(G1维护了两个bitmap来来确定标记周期内被分配的对象信息,这里不再展开,感兴趣可以自行查阅资料)

[prevTAMS, nextTAMS): 这部分里的对象表示在第n-1轮标记中是隐式存活的

[nextTAMS, top) 这部分里的对象表示在第n-1轮标记中是隐式存活的

[top, end) 这部分表示region中未被使用的区域

G1垃圾回收的运作流程

以上介绍的内容皆为需要了解G1运作流程的基础,G1的运作流程可以分为如下两个部分:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)

全局并发标记(global concurrent marking)

全局并发标记是基于SATB的主要可以分为如下的几个阶段:

1.初始标记(initial marking)---暂停用户线程

在这一阶段会只标记下GC Roots能直接关联到的对象。由于在Young GC时也需要这一步操作,因此初始标记阶段会借用和Young GC暂停时间,所以可以说初始标记时总是会伴随着Young GC。(初始标记通常会伴随Young GC,但是Young GC时不一定要做初始标记)。

2.并发标记(concurrent marking)---用户线程并行

这一阶段会从初始标记对象开始进行可达性分析,虽然耗时较长但和用户线程一起并行。同时这一阶段还会扫描SATB write barrier所记录下的引用。

3.重新标记(remarking) ---暂停用户线程

这一阶段主要负责把并发标记时SATB write barrier所记录的引用进行处理,速度会很快。

4.清理(cleanup)---暂停用户线程

这一阶段会统计每个Region中存活的对象还有多少个,顺便将完全没有活对象的region直接回收了

注:虽然说全局并发标记的4个阶段只有并发标记是与用户线程并行的,其他全是STW的。但是其他三个阶段运行时间相对并发标记来说非常短。

拷贝存活对象(evacuation)

整个拷贝存活对象的阶段也是会 暂停用户线程 的。此时G1可以 自由选择任意多个region来独立收集构成收集集合(CSet) ,由于每个Region中都记录了RSet,因此回收Region时并不需要扫描整个堆,节省了很多时间。(注:一次GC将会回收整个CSet)
进行回收时,G1将一部分region里的活对象拷贝到空region里去,然后回收原本的region的空间。这也是为什么G1不会像CMS收集器一样产生许多空间碎片的原因。

G1的CSet也不是随意选择的,会根据不同的垃圾回收模式来选择不同的Region放入CSet中。总共有两种垃圾回收模式,整个回收过程也是在这两种模式中来回切换:

YoungGC模式

当Eden区被耗尽时,将会触发YoungGC。G1会 选定所有的年轻代Region为CSet ,然后进行回收。值得一提的一点是YoungGC并不依赖于全局并发标记阶段,而全局并发标记时一般都会伴随着一次Young GC。

MixedGC

MixedGC时,G1 除了会选定所有的年轻代Region外,还会根据全局并发标记统计的内容计算出回收效益最高的老年代Region 作为CSet进行回收。也因此MixedGC必须依赖于全局并发标记阶段(注:当MixedGC跟不上程序分配内存是会触发Full GC,此时这个Full GC是serial old GC会STW)。

为什么G1可以做到低延迟

听完以上介绍,你可能会觉得很奇怪,G1的大部分流程甚至连"拷贝存活对象"这种耗时操作都是会暂停用户线程的,那他如何去保证低延迟呢?因为G1虽然会标记整个堆,但是他并不像传统的垃圾收集器一样,一次回收所有被标记的数据。而是通过选择回收价值最高的Region进行回收,靠多次回收完成清理,使得每次回收的暂停时间是可控的。

G1的优势与劣势

G1收集器开创了新的收集方式为其收集带来了很多红利,但是也并不是完美的,大致评估下它的优劣势如下:

优势:

  1. 拥有可预测的停顿,达到所谓的"软实时"GC。
  2. Region的堆内存布局使得G1不容易产生空间碎片,总能提供规整的可用内存,方便大对象的存储。
    劣势
  3. 堆内存占用大,由于每个Region都需要维护自己的RSet,不可避免的会使用更多内存空间,有时RSet甚至会占用整个堆内存的20%。因此,G1更适合使用在拥有在拥有大内存的服务器上。
  4. 在拷贝存活对象阶段依然会暂停用户线程,虽然说不能太苛责,但是这也是一个提升的目标,也为后续的ZGC(JDK11支持)指明了方向。

小结

em...其实写这篇文章的时候只是想大致了解下G1的工作原理,没想到随着查阅资料的深入,扯出了这么多知识点,让自己对JVM也有了更深一步的理解。在最后还是想安利下R大在知乎上的所有回答,他对于JVM的理解非常深入,本文的很多内容都是参考了R大的回复内容。

参考资料

G1垃圾收集器之RSet

Java Hotspot G1 GC的一些关键技术

详解 JVM Garbage First(G1) 垃圾收集器

R大部分论坛回答

《深入理解Java虚拟机》

Q.E.D.


吃得苦中苦,卷成王中王🏆