admin管理员组

文章数量:1530517

        我们知道,java为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,采用了自动的垃圾回收机制,也就是我们熟悉的GC。

        有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。

        当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。

一、什么样的对象应该被回收

        自动化的管理内存资源,垃圾回收机制必须要有一套算法来进行计算,其中有一个最重要的问题就是判断哪些是有效的对象,哪些是无效的对象,对于无效的对象就要进行回收处理。常见计算无效对象的方法有两种,分别是:引用计数算法、可达性分析算法。

1.1、引用计数算法

        引用计数是历史最悠久的一种算法,最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用。

1.1.1、原理

        假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。

1.1.2、优缺点

优点:

  1. 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收;
  2. 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报outofmember 错误;
  3. 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。

缺点:

  1. 每次对象被引用时,都需要去更新计数器,有一点时间开销;
  2. 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计;
  3. 无法解决循环引用问题。(最大的缺点)。

1.1.3、引用计数算法的循环引用

循环引用:即AB两个对象相互依赖从而形成一个闭环的现象。

相关代码示范如下:

public class CircularReference {

    public static void main(String[] args) {
        A a = new A();
        B b = new B();
        a.setB(b);
        b.setA(a);
        System.out.println(a.toString());
        System.out.println(b.toString());
    }
}

class A {
    private B b;
    public B getB() {
        return b;
    }
    public void setB(B b) {
        this.b = b;
    }
}

class B {
    private A a;
    public A getA() {
        return a;
    }
    public void setA(A a) {
        this.a = a;
    }
}

 debug的结果如下:

 可以看到,a和b两个对象存在着循环引用,哪怕是后续将a,b都置为null,它们之间的循环关系依然存在,这样就会导致a,b永远不会被回收。

1.2、可达性分析算法

        通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,就说明从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,就是可以回收的对象。

如图所示:对象1-4因为有1在连接GC root,因此这些对象是可达的,而5-8没有任何对象与GC root相连,因此他们虽然互相有引用,但仍然是不可达的。

1.2.1、GC root

在JVM虚拟机中,可作为GC Roots的对象包括以下几种:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象;
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器;
  6. 所有被同步锁(synchronized关键字)持有的对象;
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

1.2.2、可达性算法的二次回收

不可达的对象不是立马被回收,而是暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。
  2. 当对象没有重写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,直接进行第二次标记。
  3. 如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。
  4. 这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,因为如果一个对象在 finalize() 方法中执行缓慢,将很可能会一直阻塞 F-Queue 队列,甚至导致整个内存回收系统崩溃。

1.2.3、可达性算法与finalize方法

        jvm在回收对象之前会先检查这个对象是否重写了finalize方法,以及这个finalize方法是否已经执行过,待这两个检查都执行完之后才会去清理这个对象,因此我们可以在对象回收之前即在finalize方法中处理一些东西,比如关闭文件、套接字和数据库连接等。

但是,子类重写finalize方法也不可避免的带来一些问题:

  1. 在执行finalize方法时会可能会导致对象复活;
  2. finalize方法的执行时间是没有保障的,它完全由Gc线程决定,极端情况下,若不发生GC,则finalize方法将没有执行机会。因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收;
  3.  一个糟糕的finalize方法会阻塞 F-Queue 队列,严重影响Gc的性能。

1.3、对象的引用(强弱软虚)

        在jdk1.2之前,对象的引用只有两种状态:被引用和未被引用,1.2之后对对象的引用类型进行扩展,出现了强软弱虚四大引用类型,具体的可以参考我之前的文章:

Java的四大引用之强软弱虚

二、垃圾回收算法

2.1、标记清除法

标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
        标记:从根节点开始标记引用的对象。
        清除:未被标记引用的对象就是垃圾对象,可以被清理。
标记清除法可以说是最基础的收集算法,因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。

缺点:

  1. 执行效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。
  2. 通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。

2.2、标记压缩算法

        标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题。

该算法解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有一定的影响。

缺点:
● 效率不高:不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。

2.3、标记复制算法

        复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

  1. 在GC开始的时候,对象只会存在于Eden区和名为"From"的Survivor区,Survivor区"To"是空的。
  2. 紧接着进行GC,Eden区中所有存活的对象都会被复制到"To",而在"From"区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到"To"区域。
  3. 经过这次GC后,Eden区和From区已经被清空。这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次GC前的"From",新的"From"就是上次GC前的"To"。不管怎样,都会保证名为To的Survivor区域是空的。
  4. GC会一直重复这样的过程,直到"To"区被填满,"To"区被填满之后,会将所有对象移动到年老代中。

优点:

  1. 在垃圾对象多的情况下,效率较高;
  2. 清理后,内存无碎片。

缺点:

  1. 在垃圾对象少的情况下,不适用,如:老年代内存;
  2. 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低。

2.4、分代算法

        分代收集算法的思想是按对象的存活周期不同将内存划分为几块一般是把 Java 堆分为新生代和老年代(还有一个永久代,是 HotSpot 特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法,比如在jvm中,年轻代适合使用复制算法,老年代适合使用标记清除或标记压缩算法,具体的还要看相关的垃圾回收器采用哪种算法。
垃圾回收的相关概念:
1、部分收集(Partial GC)

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集,需要注意的是目前只有CMS是单独收集老年代。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,需要注意的是目前只有G1会有这种行为

2、整堆收集(Full GC)

接下来我将通过一个案例讲解一个对象object在分代垃圾回收中轨迹。

  1. object新建,出生于新生代的Eden区域;
  2. minor GC,object还存活,移动到From suvivor空间,此时还在新生代;
  3. minor GC,object仍然存活,此时会通过复制算法,将object移动到To区域,此时object的年龄+1;
  4. minor GC,object仍然存活,此时survivor中和object同龄的对象并没有达到survivor的一半,所以此时通过复制算法,将From和To 区域进行互换,存活的对象被移动到了To;
  5. minor GC,object仍然存活,当object年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置),会被移动到了老年代区域;
  6. object存活一段时间后,发现此时object不可达GC roots,而且此时老年代空间比率已经超过了阈值,触发了majorGC(也有可能是fullGC,但具体需要垃圾收集器来决定),此时object被回收了。fullGC会触发stop the world。

注意:在以上的新生代中,我们有提到对象的age,对象存活于survivor状态下,不会立即晋升为老年代对象,以避免给老年代造成过大的影响,它们必须要满足以下条件才可以晋升:

  1. minor gc 之后,存活于survivor 区域的对象的age+1,默认当超过15的时候(可以通过-XX:MaxTenuringThreshold来设置),转移到老年代;
  2. 动态对象,如果survivor空间中相同年龄所有的对象大小的综合和大于survivor空间的一半,年级大于或等于该年纪的对象就可以直接进入老年代。

三、垃圾回收器

        前面我们讲了垃圾回收的算法,还需要有具体的实现。在jvm中,实现了多种垃圾收集器,常见的十种垃圾回收器分别是:Serial、ParNew、ParallelScavenge、SerialOld、ParallelOld、CMS、G1、ZGC、Shenandoah、Epsilon。其中Epsilon是jdk11提出debug使用的,它只负责控制内存分配,但是不执行任何垃圾回收工作,因此本篇文章不再赘述。

这些垃圾回收器按照垃圾分代可分为以下几类:

  1. 年轻代:Serial、ParNew、ParallelScavenge
  2. 老年代:SerialOld、ParallelOld、CMS
  3. 整堆:G1、ZGC、Shenandoah。

按照串行和并行划分可分成以下几类:

串行:Serial、SerialOld

并行:ParNew、ParallelScavenge、ParallelOld、CMS

        串行垃圾收集器,是指使用单线程进行垃圾回收,垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)。

        并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。(这里是指,并行能力较强的机器)当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。

3.1、Serial回收器

Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。

Serial收集器作为HotSpot中client模式下的默认新生代垃圾收集器,它采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。

除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记压缩算法,与Serial一样,Serial Old也是是运行在Client模式下默认的老年代的垃圾回收器。

Serial Old在Server模式下主要有两个用途:

  1. 与新生代的Parallel scavenge配合使用;
  2. 作为老年代CMS收集器的后备垃圾收集方案。

Serial收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)

优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在Client模式下的虚拟机是个不错的选择。

在HotSpot虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用Serial GC,且老年代用Serial Old GC

总结:

  1. 目前的垃圾收集器已经不用串行的了,而且在限定单核cpu才可以用。现在大家的电脑应该都不是单核的了。
  2. 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。

 Serial/SerialOld垃圾回收时图谱:

3.2、ParNew回收器

        如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。Par是Parallel的缩写,New:只能处理的是新生代

        ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制,他是很多JVM运行在Server模式下新生代的默认垃圾收集器。

        与ParallelScavenge的区别就是,ParNew能更好的和CMS配合使用,ParNew的响应时间优先,ParallelScavenge的吞吐量优先

ParNew垃圾回收时图谱: 

ParNew的优势:

  • 对于新生代,回收次数频繁,使用并行方式高效;
  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)。

        在开发时我们可以通过选项"-XX:+UseParNewGC"手动指定使用ParNew收集器执行内存回收任务,需要注意的是它表示年轻代使用并行收集器,并不会影响老年代,除此之外,我们还可以通过-XX:ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数。

3.2.1、ParNew一定比Serial效率高吗?

不一定。

ParNew 收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。

但是在单个CPU的环境下,ParNew收集器不比Serial 收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

3.3、Parallel回收器

HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制

那么Parallel 收集器的出现是否多此一举?

和ParNew收集器不同,ParallelScavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。

自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。

高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Parallel 收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。Parallel Old收集器采用了标记压缩算法并行回收和"Stop-the-World"机制

Parallel垃圾回收时图谱:

在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。在Java8中,默认是此垃圾收集器。

相关参数配置;

  • -XX:+UseParallelGC 手动指定年轻代使用Parallel并行收集器执行内存回收任务。默认jdk8是开启的。
  • -XX:+UseParallelOldGC 手动指定老年代都是使用并行回收收集器。默认jdk8是开启的。
  • -XX:ParallelGCThreads 设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
  • -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STw的时间)。单位是毫秒。为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制,该参数使用需谨慎。
  • -XX:GCTimeRatio 垃圾收集时间占总时间的比例(=1/(N+1))。用于衡量吞吐量的大小。取值范围(0, 100),默认值99,也就是垃圾回收时间不超过1%。与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
  • -XX:+UseAdaptivesizePolicy 设置Parallel Scavenge收集器具有自适应调节策略。在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。 

需要注意的是默认-XX:+UseParallelGC和-XX:+UseParallelOldGC参数开启一个,另一个也会被开启,即他们俩会互相激活。

3.4、CMS

在JDK1.5时期,Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

CMS的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-World"。

不幸的是,CMS作为老年代的收集器,却无法与JDK1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个

CMS垃圾回收图谱:

CMS整个过程比之前的收集器要复杂,整个过程分为6个主要阶段,即初始标记阶段、并发标记阶段、并发预清理阶段、重新标记阶段、并发清除阶段和并发重置阶段:

  1. 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
  2. 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 并发预清理(Concurrent-Preclean) :并发预清理阶段仍然是并发的。在这个阶段,虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代, 或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段"重新标记"的工作,因为下一个阶段会Stop The World。这一阶段的结束是可以由我们控制的,比如扫描多长时间(默认5秒)或者Eden区使用占比达到期望比例(默认50%)就结束本阶段。
  4. 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  5. 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的,尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。
  6. 并发重置(Concurrent-Reset)阶段:这个阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。

尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

3.4.1、CMS为什么不采用标记压缩算法?

答案其实很简单,因为当并发清除的时候,用压缩算法整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。因此标记压缩算法更适合“Stop the World” 这种场景下使用。

3.4.2、CMS的优缺点

优点:并发收集、低延迟

缺点:

  1. 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发FullGC。
  2. CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  3. CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。

3.4.3、CMS相关参数

  • -XX:+UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收任务。开启该参数后会自动将-xx:+UseParNewGC打开。即:ParNew(Young区用)+CMS(Old区用)+ Serial Old的组合。

  • -XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收,JDK6及以上版本默认值为92%。如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Ful1Gc的执行次数。
  • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
  • -XX:CMSFullGCsBeforeCompaction 设置在执行多少次Full GC后对内存空间进行压缩整理。
  • -XX:ParallelcMSThreads 设置CMS的线程数量。CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

注意:CMS会在若干次垃圾回收之后进行一次碎片化的整理

3.5、G1(重要)

3.5.1、为什么要发布Garbage First(G1)?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

3.5.2、为什么叫 Garbage First(G1)呢?

因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。

G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)。

G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

在JDK1.7版本正式启用,移除了Experimenta1的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。

与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。

3.5.3、region

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB~32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB等等。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

一个region有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,S表示属于survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果一个对象超过0.5个region,G1收集器就认为这是一个巨型对象,这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。

每个Region都是通过指针碰撞来分配空间:

3.5.4、G1回收器的特点(优势)

与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:

并行与并发

  • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW;
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。

分代收集

  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

空间整合

  • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。

可预测的停顿时间模型(即:软实时soft real-time)

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于CMSGC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

3.5.5、G1垃圾收集器的缺点

相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

3.5.6、G1之Young GC

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收Eden区和Survivor区

首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。 

然后开始如下回收过程:

  1. 第一阶段,扫描根。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  2. 第二阶段,更新RSet。处理Regio中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
  3. 第三阶段,处理RSet。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  4. 第四阶段,复制对象。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用。处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢?

根对象可能是在年轻代中,也可以在老年代中,那么老年代中的所有对象都是根么?

如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set(记忆集),其作用是跟踪指向某个堆内的对象引用。

每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。

3.5.7、G1之Mixed GC(混合回收)

当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

Mixed GC什么时候触发? 由参数 -XX:InitiatingHeapOccupancyPercent=n 决定。默认:45%,该参数的意思是:当老年代大小占整个堆大小百分比达到该阀值时触发。

Mixed GC发生时,会执行如下步骤:

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

全局并发标记:

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
  2. 根区域扫描(Root Region Scanning):G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在YoungGC之前完成。
  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被YoungGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。
  5. 独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集,而是等待Evacuation来回收。

拷贝存活对象:

Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。

3.5.8、G1之Full GC

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

  • Evacuation的时候没有足够的to-space来存放晋升的对象;
  • 并发处理过程完成之前空间耗尽。

3.5.9、G1收集器的常见操作步骤

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:

  1. 第一步:开启G1垃圾收集器
  2. 第二步:设置堆的最大内存
  3. 第三步:设置最大的停顿时间

G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。

3.5.10、G1收集器的适用场景

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。

用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:

  • 超过50%的Java堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC停顿时间过长(长于0.5至1秒)

HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

3.5.11、G1使用建议

年轻代大小

  • 避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。
  • 固定年轻代的大小会覆盖暂停时间目标。

暂停时间目标不要太过严苛

  • G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。
  • 评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意承受更多的垃圾回收开销,而这会直接影响到吞吐量。

3.5.12、G1相关参数

  • -XX:+UseG1GC:使用 G1 垃圾收集器
  • -XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标(会尽力实现,但不保证达到),默认值是 200 毫秒。
  • -XX:G1HeapRegionSize=n:设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的1/2000。
  • -XX:ParallelGCThreads=n:设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n的值与逻辑处理器的数量相同,最多为 8。
  • -XX:ConcGCThreads=n:设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。
  • -XX:InitiatingHeapOccupancyPercent=n:设置触发Mixed GC 的Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

3.6、Shenandoah

Shenandoah作为第一款不由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器,不可避免地会受到一些来自“官方”的排挤。 Oracle明确拒绝在OracleJDK 12中支持Shenandoah收集器,并执意在打包OracleJDK时通过条件编译完全排除掉了Shenandoah的代码,换句话说, Shenandoah是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器, “免费开源版”比“收费商业版”功能更多,这是相对罕见的状况。如果读者的项目要求用到Oracle商业支持的话,就不得不把Shenandoah排除在选择范围之外了。

最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189。这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1, Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。

Shenandoah和G1非常类似,更像是对G1的升级改造,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码。

3.6.1、Shenandoah模型

虽然Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region……但在管理堆内存方面,它与G1有三个明显的改进之处:

支持并发的整理算法, G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能。

Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。

Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如图所示,如果Region 5中的对象Object C引用了Region 3的Object BObject B又引用了Region 1的Object A,那连接矩阵中的5行3列、 3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。

3.6.2、Shenandoah工作原理

Shenandoah收集器的工作过程大致可以划分为以下九个阶段(在最新版本的Shenandoah 2.0中,进一步强化了“部分收集”的特性,初始标记之前还有Initial Partial、 Concurrent Partial和Final Partial阶段,它们可以不太严谨地理解为对应于以前分代收集中的Minor GC的工作):

  1. 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
  2. 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
  3. 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
  4. 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
  5. 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段, Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难, Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决(讲解完Shenandoah整个工作过程之后笔者还要再回头介绍它)。并发回收阶段运行的时间长短取决于回收集的大小。
  6. 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
  7. 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
  8. 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
  9. 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

以上对Shenandoah收集器这九个阶段的工作过程的描述可能拆分得略为琐碎,我们只需要关注其中三个最重要的并发阶段(并发标记、并发回收、并发引用更新),就能比较容易理清Shenandoah是如何运作的了。

3.6.3、并行整理核心—Brooks Pointer

Shenandoah用以支持并行整理的核心概念——Brooks Pointer:
Rodney A.Brooks在论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。此前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。

Brooks Pointers示意图:

Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。

从结构上来看, Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。

有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度,例如以下所示:

mov r13,QWORD PTR [r12+r14*8-0x8]

不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作。如图所示。 

Brooks Pointers示意图:

需要注意, Brooks形式的转发指针在设计上决定了它是必然会出现多线程竞争问题的,如果收集器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该是一样的,这个场景还可以有一些“偷懒”的处理余地;但如果发生的是并发写入,就一定必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。读者不妨设想以下三件事情并发进行时的场景:

  1. 收集器线程复制了新的对象副本;
  2. 用户线程更新对象的某个字段;
  3. 收集器线程更新转发指针的引用值为新副本地址。

如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上Shenandoah收集器是通过比较并交换(Compare And Swap, CAS)操作来保证并发时对象的访问正确性的。

转发指针另一点必须注意的是执行频率的问题,尽管通过对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性,这件事情只从原理上看是不复杂的,但是“对象访问”这四个字的分量是非常重的,对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作, Shenandoah不得不同时设置读、写屏障去拦截。

之前介绍其他收集器时,或者是用于维护卡表,或者是用于实现并发标记,写屏障已被使用多次,累积了不少的处理任务了,这些写屏障有相当一部分在Shenandoah收集器中依然要被使用到。除此以外,为了实现Brooks Pointer, Shenandoah在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价,这是比写屏障更大的。代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。Shenandoah是本书中第一款使用到读屏障的收集器,它的开发者也意识到数量庞大的读屏障带来的性能开销会是Shenandoah被诟病的关键点之一,所以计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier) 的实现,所谓“引用访问屏障”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。

3.6.4、Shenandoah性能

Shenandoah性能测试网上报告不一,在此笔者选择展示了一份RedHat官方在2016年所发表的Shenandoah实现论文中给出的应用实测数据,测试内容是使用ElasticSearch对200GB的维基百科数据进行索引。

如图所示。从结果来看,应该说2016年做该测试时的Shenandoah并没有完全达成预定目标,停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在十毫秒以内的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。读者可以从这个官方的测试结果来对Shenandoah的弱项(高运行负担使得吞吐量下降)和强项(低延迟时间)建立量化的概念,并对比一下稍后介绍的ZGC的测试结果。

3.7、ZGC(重要)

ZGC(The Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。

ZGC的目标是希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的低延迟。

它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)。

ZGC的内存布局与G1一样,也采用基于Region的堆内存布局,但不同的是,ZGC的Page(ZGC中称之为页面,道理和Region一样)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Page可以具有大、中、小三类容量:

  • 小型页面(Small Page):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型页面(Medium Page):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型页面(Large Page):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。

需要注意的是:每个大页面中只会存放一个大对象,这也预示着虽然名字叫作“大型Page”,但它的实际容量完全有可能小于中型Page,最小容量可低至4MB。

大型Page在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作)的,因为复制一个大对象的代价非常高昂。

3.7.1、ZGC的工作过程

ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,例如初始化GC Root直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异,笔者就不再单独解释了。 ZGC的运作过程具体如图所示。

  1. 并发标记(Concurrent Mark):与G1、 Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、 Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、 Shenandoah不同的是, ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、 Marked 1标志位(染色指针技术,后面会介绍)。
  2. 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器的回收集(Collection Set)还是有区别的, ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反, ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此, ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
  3. 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持, ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象, ZGC将这种行为称为指针的“自愈”(SelfHealing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
  4. 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此, ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

3.7.2、染色指针技术

染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束, 64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间, 64位的Windows系统甚至只支持44位(16TB)的物理地址空间。

尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。因此, ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如图所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂) 。

使用染色指针的好处:

虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针(-XX:
+UseCompressedOops)等诸多约束,但它带来的收益也是非常可观的。染色指针主要有三大优势:

  1. 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。这点相比起Shenandoah是一个颇大的优势,使得理论上只要还有一个空闲Region, ZGC就能完成收集,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region,这意味着堆中几乎所有对象都存活的极端情况,需要1∶ 1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集。至于为什么染色指针能够导致这样的结果,笔者将在后续解释其“自愈”特性的时候进行解释。
  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。实际上,到目前为止ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题)。内存屏障对程序运行时性能的损耗在前面章节中已经讲解过,能够省去一部分的内存屏障,显然对程序运行效率是大有裨益的,所以ZGC对吞吐量的影响也相对较低。
  3. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。现在Linux下的64位指针还有前18位并未使用,它们虽然不能用来寻址,却可以通过其他手段用于信息记录。如果开发了这18位,既可以腾出已用的4个标志位,将ZGC可支持的最大堆内存从4TB拓展到64TB,也可以利用其余位置再存储更多的标志,例如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。

3.7.3、读屏障

上面经常提到读屏障,那么到底什么是读屏障呢?

读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o  // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i =  obj.FieldB  //无需加入屏障,因为不是对象引用

ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。 

3.7.4、ZGC性能

下图1和下图2是ZGC与Parallel Scavenge、 G1三款收集器通过SPECjbb 2015的测试结果。在ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用的“Critical Throughput”的话, ZGC的表现甚至还反超了Parallel Scavenge收集器。

ZGC的吞吐量测试:

 

而在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、 G1拉开了两个数量级的差距。不论是平均停顿,还是95%停顿、 99%停顿、 99.9%停顿,抑或是最大停顿时间, ZGC均能毫不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条(图3-24a),必须把结果的纵坐标从线性尺度调整成对数尺度(图2-b,纵坐标轴的尺度是对数增长的)才能观察到ZGC的测试结果。

ZGC的停顿时间测试:

3.7.5、ZGC的优缺点

  • 优点:低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小
  • 缺点:会产生浮动垃圾,启动时会占用大量的内存(相对于G1来说,有点用空间来换取时间的意味)

3.7.6、ZGC相关参数

在jdk11下,只能在linux 64位的平台上使用ZGC,如果想要在Windows下使用ZGC就需要升级jdk到14了,下面参数是以jdk11为例:

  • -XX:+UnlockExperimentalVMOptions:解锁实验参数
  • -XX:+UseZGC:启用ZGC垃圾收集器

3.8、垃圾回收器的组合

不同厂商、不同版本的虚拟机实现差距比较大,目前市面上主流的虚拟机仍然是HotSpot,它在JDK7/8后所有收集器及组合如下图所示:

  • 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  • 其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案。
  • (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial old这两个组合声明为Deprecated(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214)。

3.9、对各种垃圾回收器的总结

垃圾收集器分类作用区域使用算法特点适用场景整理过程
Serial串行新生代复制算法 响应速度优先适用于单CPU环境下的client模式STW后,单线程将存活对象复制到另一块内存中
Serial Old串行老年代标记压缩算法响应速度优先适用于单CPU环境下的Client模式STW后,单线程清除对象,并将存活对象压缩
ParNew并行新生代复制算法响应速度优先多CPU环境Server模式下与CMS配合使用STW后,多线程将存活对象复制到另一块内存中
Parallel并行新生代复制算法吞吐量优先适用于后台运算而不需要太多交互的场景STW后,多线程将存活对象复制到另一块内存中
Parallel Old并行老年代标记压缩算法吞吐量优先适用于后台运算而不需要太多交互的场景STW后,多线程清除对象,并将存活对象压缩
CMS并行老年代标记清除算法响应速度优先适用于互联网或B/S业务第一次标记GC Roots时会STW,然后并发标记所有对象,再并行补充标记这一段时间发生变动的对象,最后并发清理所有需要清理的对象
G1并发并行全堆标记压缩算法、复制算法响应速度优先面向服务端应用

年轻代:并发扫描根,更新纠正根,计算regio的card确定存活对象,复制存活对象。老年代:标记根节点可达对象(STW),并发标记对象,补充修正一次再清理(清理时会STW)

Shenandoah并发并行全堆标记压缩算法、复制算法响应速度优先面向服务端应用STW后标记GC roots,并发标记对象,补充修正一次,并发回收对象,并发更新对象的引用,并发更新GC Roots(STW),并发清理对象
ZGC并发并行全堆标记压缩算法、复制算法响应速度优先、高吞吐更适用于大级别堆STW后利用染色指针技术标记GC roots,并发标记对象,补充修正一次,计算需要回收的region,将要回收的region的存活对象复制到新的region,修正染色指针的指向(自愈,如果没有修正,第一次访问时会被自动修正)

4、如何选择垃圾回收器

Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。那么我们应该如何选择合适的垃圾收集器?

  1. 优先调整堆的大小让JVM自适应完成;
  2. 如果内存小于100M,使用串行收集器;
  3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器;
  4. 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择;
  5. 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器官方推荐G1,性能高。现在互联网的项目,基本都是使用G1;
  6. 如果是多CPU、追求低停顿时间、需快速响应,堆又非常的大,建议选择ZGC。

最后需要注意的是:每款垃圾回收器都有自己的优势和缺点,要根据自己项目的需要和要求选择适合自己的收集器,调优只是在特定场景特定需求下进行的,今天的最优解不一定是明天的最优解,也不存在一劳永逸的收集器。

本文标签: 垃圾算法器及常见