jvm

JVM 系列 G1 垃圾收集器

开启JVM探索新篇章

Posted by lichao modified on May 11, 2020

G1(Garbage-First)是一款面向服务器的垃圾收集器,支持新生代和老年代空间的垃圾收集,主要针对配备多核处理器及大容量内存的机器,G1最主要的设计目标是: 实现可预期及可配置的STW停顿时间。

G1 最大的特点是引入分区的思路,弱化了分代的概念。从分代的角度看,G1 依然属于分代垃圾回收器,它会区分年轻代和老年代,依然有 eden 区和 survivor 区。从堆的结构看,它并不要求整个 eden 区、年轻代或老年代都连续。

G1 的设计原则就是简单可行的性能调优,开发人员仅仅需要声明以下参数即可:

1
-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

其中-XX:+UseG1GC为开启 G1 垃圾收集器,-Xmx32g设置堆内存的最大内存为 32G,-XX:MaxGCPauseMillis=200设置 GC 的最大暂停时间为 200ms。如果需要调优,在内存大小一定的情况下,只需要修改最大暂停时间即可。

分区概念

jvm G1 算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过 这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者 Survivor 空间。老年代也分成很多区域,G1 收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1 完成了堆的压缩(至少是部分堆的压缩),这样也就不会有 cms 内存碎片问题的存在了。

jvm

  • Region

为实现大内存空间的低停顿时间的回收,将划分为多个大小相等的Region。每个小堆区都可能是 Eden区,Survivor区或者 Old 区,但是在同一时刻只能属于某个代。 在逻辑上, 所有的Eden区和Survivor区合起来就是新生代,所有的Old区合起来就是老年代,且新生代和老年代各自的内存Region区域由G1自动控制,不断变动。

  • 巨型对象

当对象大小超过 Region 的一半,则认为是巨型对象(Humongous Object),直接被分配到老年代的巨型对象区(Humongous regions),这些巨型区域是一个连续的区域集,每一个 Region 中最多有一个巨型对象,巨型对象可以占多个 Region。

在 Java 8 中,持久代也移动到了普通的堆内存空间中,改为元空间。

分区算法的特点

G1把堆内存划分成一个个Region的意义在于:

  1. 每次 GC 不必都去处理整个堆空间,而是每次只处理一部分 Region,实现大容量内存的GC
  2. 通过计算每个 Region 的回收价值,包括回收所需时间、可回收空间,在有限时间内尽可能回收更多的内存,把垃圾回收造成的停顿时间控制在预期配置的时间范围内,这也是G1名称的由来: garbage-first

对象分配策略

分为 3 个阶段:

  • TLAB(Thread Local Allocation Buffer) 线程本地分配缓冲区
  • Eden 区分配
  • Humongous 区分配

TLAB 是线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享空间中分配,需要采用一些同步机制来管理这些空间内的空闲空间指针。在 Eden 空间中,每一个线程都有一个固定的分区用于分配对象,即一个 TLAB。分配对象时,线程之间不再需要进行任何的同步。

对 TLAB 空间中无法分配的对象,JVM 会尝试在 Eden 空间中进行分配。如果 Eden 空间无法容纳该对象,就只能在老年代中进行分配空间。

垃圾收集过程

针对新生代和老年代,G1 提供 2 种 GC 模式,Young GC 和 Mixed GC,两种会导致 Stop The World

  • Young GC 当新生代的空间不足时,G1 触发 Young GC 回收新生代空间。 Young GC 主要是对 Eden 区进行GC,它在 Eden 空间耗尽时触发,基于分代回收思想和复制算法,每次Young GC都会选定所有新生代的Region,同时计算下次 Young GC 所需的Eden区和Survivor区的空间,动态调整新生代所占Region个数来控制Young GC开销

  • Mixed GC 当老年代空间达到阈值会触发Mixed GC,选定所有老生代里的Region,根据全局并发标记阶段(下面介绍到)统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内,尽可能选择收益高的老年代Region进行GC,通过选择哪些老年代Region和选择多少Region来控制Mixed GC开销

YongGC

Young GC 主要对 eden 区和 servivor 区进行回收,它在 eden 空间耗尽时会被触发。在这种情况下,eden 空间的数据移动到 survivor 空间中,如果 survivor 空间不够,eden 空间的部分数据会直接晋升到年老代空间。survivor 区的数据移动到新的 survivor 区中,也有部分数据晋升到老年代空间中。最终所有 eden 空间的数据被清空,至少存在一个 survivor,老年代区域增多,GC 停止工作,应用线程继续执行。 jvm jvm 这时需要考虑一个问题,如果仅仅 GC 新生代对象,如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是 G1 引进了 RSet 的概念。它的全称是 Remembered Set,作用是跟踪指向某个 heap区 内的对象引用。 jvm

在 CMS 中,也有 RSet 的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种 point-out,在进行 Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。

但在 G1 中,并没有使用 point-out,这是由于一个分区太小,分区数量太多,如果是用 point-out 的话,会造成大量的扫描浪费,有些根本不需要 GC 的分区引用也扫描了。于是 G1 中使用 point-in 来解决。

point-in 的意思是哪些分区引用了当前分区中的对象。这样仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次 GC 时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在 G1 中又引入了另外一个概念,卡表(Card Table)。一个 Card Table 将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于128到512字节之间。Card Table 通常为字节数组,由 Card 的索引(即数组下标)来标识每个分区的空间地址。默认情况下,每个卡都未被引用。当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来。一般情况下,这个 RSet 其实是一个 Hash Table,Key 是别的 Region 的起始地址,Value 是一个集合,里面的元素是 Card Table 的 Index。

FullGC

jvm

全局并发标记

全局并发标记主要是为 Mixed GC 计算找出回收收益较高的Region区域,具体分为5个阶段 jvm

  • 阶段 1: 初始标记(Initial Mark) 暂停所有应用线程(STW),并发地进行标记从 GC Root 开始直接可达的对象(原生栈对象、全局对象、JNI 对象),当达到触发条件时,G1 并不会立即发起并发标记周期,而是等待下一次新生代收集,利用新生代收集的 STW 时间段,完成初始标记,这种方式称为借道(Piggybacking)

  • 阶段 2: 根区域扫描(Root Region Scan) 在初始标记暂停结束后,新生代收集也完成的对象复制到 Survivor 的工作,应用线程开始活跃起来; 此时为了保证标记算法的正确性,所有新复制到 Survivor 分区的对象,需要找出哪些对象存在对老年代对象的引用,把这些对象标记成根(Root)。 这个过程称为根分区扫描(Root Region Scanning),同时扫描的 Suvivor 分区也被称为根分区(Root Region); 根分区扫描必须在下一次新生代垃圾收集启动前完成(接下来并发标记的过程中,可能会被若干次新生代垃圾收集打断),因为每次 GC 会产生新的存活对象集合

  • 阶段 3: 并发标记(Concurrent Marking) 标记线程与应用程序线程并行执行,标记各个堆中Region的存活对象信息,这个步骤可能被新的 Young GC 打断 所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次新生代收集

  • 阶段 4: 再次标记(Remark) 和CMS类似暂停所有应用线程(STW),以完成标记过程短暂地停止应用线程, 标记在并发标记阶段发生变化的对象,和所有未被标记的存活对象,同时完成存活数据计算

  • 阶段 5: 清理(Cleanup) 为即将到来的转移阶段做准备, 此阶段也为下一次标记执行所有必需的整理计算工作:

    • 整理更新每个 Region 各自的 RSet(remember set,HashMap结构,记录有哪些老年代对象指向本Region,key为指向本Region的对象的引用,value为指向本Region的具体Card区域,通过RSet可以确定Region中对象存活信息,避免全堆扫描)
    • 回收不包含存活对象的Region
    • 统计计算回收收益高(基于释放空间和暂停目标)的老年代分区集合

Mixed GC

jvm jvm

G1 特点

  1. 并行与并发:能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短停顿的时间;
  2. 分代收集:G1 可以 不需要 其他收集器的配合就能独立管理整个 Java 堆,采用不同的方式处理新生对象、已经存活一段时间的对象和熬过多次 GC 的旧对象以获得更好的收集效果。
  3. 空间整合:从整体看基于标记整理算法,从局部看基于复制算法(两个Region之间),这两种算法都意味着 G1 运行期间不会产生内存空间碎片,收集后能提供规整的内存空间。不会因为大对象找不到足够的连续空间而提前触发GC,这点优于CMS收集器。
  4. 可预测的停顿:追求低停顿,还能建立可以预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超N毫秒,这点优于CMS收集器。

建立可预测的时间模型:G1 跟踪各个 region 里面的垃圾堆积的价值大小(回收所获得的空间大小及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 region。这种使用region 划分内存空间以及优先级的区域回收方式,保证了 G1 收集器在有限的时间内尽可能高的收集效率。

Region 不可能是孤立的,而可以与整个Java堆任意的对象发生引用关系。Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1 中每个 region 都有一个与之对应的 Remembered Set(被引用对象所属的region 的 Remembered Set 之中会记录相关的引用信息)。当进行内存回收时,在GC根结点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

参考文献

https://juejin.im/post/5b6b986c6fb9a04fd1603f4a