JVM-详解G1垃圾收集器

1,073 阅读9分钟

写这篇文章的原因是我看很多人提到G1的Full GC时都说是使用单线程回收,这个就很迷惑,虽然Full GC是在内存很紧张的时候才发生,但是既然都内存分块,为什么不能开多线程去并行回收,这样明显效率高很多,这点内存都不预留出来那设计G1回收的大佬是怎么考虑的?

G1垃圾回收在发生Full GC的时候由于全程单线程完全失去了内存分块意义,反而由于内存分块甚至比不分块还慢,这个问题已经在JDK10中改进了,感兴趣的可以看下官网的描述Parallel Full GC for G1,也就是说现在G1的Full GC其实是多线程处理的。可以灵活的设定Full GC时的工作线程数

由于上面问题的困扰,导致本人看了不少G1原理的文章,总感觉都讲的很复杂,很多很多概念性的东西,所以准备自己画一些图,对G1一些关键的机制做一个简单的说明。错误之处希望大家能够勘正,感谢🙏。

1.内存结构-为什么要将Java堆划分为多个Region?

基于分代收集理论实现的垃圾收集器,将Java堆分为两个区域,新生代和老年代。G1同样如此,G1将Java堆划分为多个Region区,每个Region有三个状态:未分配,新生代,老年代。

  • 因为新生代收集是基于复制算法实现的所以又分为Eden区,Survivor区
  • 老年代包括新生代中熬过多次回收后被复制到Old区的对象以及超过单个Region设定容量Humongous区

image.png G1的创新之处在于每个区域可以灵活的根据当前状态去执行不同策略,同时做了很好的内存隔离,举个极端的例子,在不考虑跨区引用,且一块没有内存隔离的区域只能用一个线程去进行垃圾回收的情况下:

  • 对于G1之前的垃圾回收器假如使用1G的内存作新生代,那么其实在进行清理回收的时候只能是开1个线程去处理的,顶多可以开其他线程去处理别的请求,但是不能参与垃圾回收,这本质就是并发。
  • 而G1则是1G的内存作为新生代,同时又把这1G内存分为10个Region的话,那就可以开10个线程去并行处理,这样的回收速度远超单线程回收,当然了代价就是需要额外的内存去记录这些Region的信息。
image.png

上图是CMS(老年代)和G1(新生代+老年代)垃圾回收的主要过程,可以看出G1是并发标记+并行回收,而CMS则是并发标记+并发回收。并行回收看着简单其实实现难度还是很高的,要考虑很多因素,比如如何做到各个区域间的内存隔离,信息管理等,从提出理论到初步实现就花费了将近10年。

因为G1实现了模块化的内存管理模式,所以可以根据每个模块的状态以及内存利用率这些信息,提前计算出进行一次垃圾回收需要的时间等,从而去选择回收价值最高的先去回收。而CMS这种提前就把内存区域划分为两大块的做法就显得很笨重,同时也没有办法去控制回收的对象比例(主要还是在没有内存隔离的情况下实现这个需求太难了)。

问题结论:只有将Java堆划分为多个独立Region模块才能做到多线程并行回收

2.回收目标-什么是卡表CardTable,记忆集Remembered Set和写屏障Write Barrier?如何利用这些来定位需要回收的对象?

先提一下卡表的作用:卡表中存放着能够快速找到存在跨代引用的对象的索引,在进行非全局的GC时,可以凭借这种索引结构快速定位需要保留的对象。

G1是根据可达性算法来确定需要回收的目标,通过从GC Root Set包含的对象向下寻找引用链,来标记不能被回收的对象,在一个回收中,如果一个Region参与垃圾收集,那么在回收阶段,没有标记的对象将被全部清除。GC Roots由固定对象和临时对象构成。

  • 固定对象:
    • 虚拟机栈中引用的对象:线程中正在使用的对象
    • 方法区中类静态属性引用的对象:类中static声明的引用类型字段
    • 方法区中常量引用的对象:类中final声明的引用类型字段
    • 本地方法栈中引用的对象:native本地方法引用的对象
  • 临时对象:记忆集Remembered Set中保存的对象,可以理解为一个集合结构,里面放了一些同新生代有引用关系的老年代对象的地址,跟集合中对象有引用关系的对象就不会被回收,而卡表Card Table就是这个集合实现的一种方式 image.png

重点看下临时对象是怎么确定的,首先我们可以知道一个对象在哪个区域,这个区域是什么状态(新生代/老年代),当新生代中的对象引用了老年代对象时,就会把老年代的这个对象放入Remembered Set中,当作一个临时对象。

写屏障Write Barrier在其中的作用则类似Spring的AOP,当对象的赋值发生时,判断是不是老年代,如果是则通过统一的切面处理将引用对象的地址加入Remembered Set。(实际过程跟这个有出入,但我觉得如果不深究源码的话,这么理解是没有问题的)

下面画图来解释下CardTable的实现,可以理解为内存中建立的稀疏索引 image.png
G1的每个Region都是连续内存,Card Table第一行表示 Card Table的key为与当前Eden Reign存在引用关系的Old Region的内存起始位置,value是一个集合存储这临时对象所在的内存数组索引,,这就构成了一个稀疏索引

比如1MB的内存,如果我们把其分为10个内存块,那么想确定这个Region中的一个临时对象最多只需要扫描100Kb即可,当对象的引用关系发生变化时,通过写屏障将key和value加入Card Table即可,代价也很明显就是为一个Region建立Card Table需要额外的空间,这个空间大小取决于索引段的大小,所以相同的Java堆内存,G1的内存使用率要低于CMS。

这种通过空间换时间的索引结构运用也很广泛,在Kafka,MySQL,Redis等中间件和数据库中都能见到。

3.回收过程-三种GC有哪些共同点和不同点?

先简单说明细不同的GC的目标类型,G1涉及 Young GC/Mixed GC/Full GC三种垃圾收集

  1. 部分收集(Partial GC):目标不是完整收集整个Java堆的垃圾收集

    • Minor GC/Young GC:新生代的垃圾收集
    • Major GC/Old GC:老年代的垃圾收集
    • Mixed GC:整个新生代及部分老年代的垃圾收集
  2. 整堆收集(Full GC):整个Java堆和方法区的垃圾收集。

Young GC
  1. 触发条件
    申请新的内存空间,在Eden区占据整个堆比例在允许范围内时,G1会计算现在Eden区回收大概要多久时间,如果回收时间远小于参数-XX:MaxGCPauseMills设定的值(默认200ms),那么增加新生代的Region,继续给新对象存放,不会马上做YoungGC。如果回收时间接近设定参数或者Eden空间不足则会发生进行一次Young GC
  2. 简图
    image.png 如何确定哪些对象需要回收,哪些需要保留前面已经提到过了就不多说了,保留的对象一部分复制到Survivor区,一部分达到晋升条件的移动到Old区,然后清除回收对象释放空间。
    Young GC全程暂停用户线程,进入STW,采用多线程并行的方式进行垃圾回收,每个线程负责部分Region。Young GC后如何还是无法获取足够Eden区空间,在进行一定次数的重试后会直接进行Full GC
Mied GC
  1. 触发条件
    在触发一次Young GC后,如果Old区占用的堆内存大小超过了InitiatingHeapOccupancyPercent参数设定的比例就会开始顺着Young GC已经调整标记的GC ROOTS进行并发标记->最终标记->清理的生命周期。
  2. 简图

image.png

Mixed GC并不会去回收全部的Old区对象,而是根据当前系统信息计算每个Region的回收价值,在设定的最大暂停内优先回收价值高的老年代Region。但是如果一次Mixed GC后空间还是不足,那么还会马上再次发起一次Mixed GC,超过最大设定的连续次数后就会进行Full GC

Full GC

在JDK9中,G1会直接采用原来的Serial Old来对整个堆垃圾收集,基于标记-清除-压缩算法来进行单线程回收,JDK10开始已经支持并行的Full GC,可以通过XX:ParallelGCThreads来设置并行的线程数

小结

上面是总结了部分作者对于G1垃圾收集器的看法,总的来说G1相比于之前的垃圾回收器最大的进步就是对于堆内存进行了更加精细的模块化管理,使其能够实现并行回收,能够更加自由的选定回收对象,设定回收时间,代价的话就是需要更多的空间来记录这些模块化的信息。如何选择还是要看实际的业务场景。
上面第三部分只是简单提了回收过程,涉及到过程中用到的SATB,TAMS,BitMap都没有详细去写,希望阅读完关键源码后能补上。