JVM垃圾回收机制(GC)

1、概念

1.1、什么是GC

  1. JVMGC是JVM的垃圾回收算法,现在的JVM基本采用分代收集,Young区收集频繁,Old区收集较少,Perm(永久代,永久代实现的基本就是方法区中规定的内容,JDK1.8之后取消了方法区并引入元空间,自然永久代也跟着弃用了)基本不回收。JVM进行GC时大部分是对新生代的回收,少量的全局回收。
  2. GC按照作用的区域分为:
    • Minor GC:作用域新生代
    • Major FC (Full GC):作用域老年代,偶尔也会回收老年代和永久代。
  3. GC针对的区域
    • 我们从JVM内存模型介绍可知,程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈的操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
    • 而Java堆和方法区这两个区域则有着很多显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,这部分内存的分配和回收是动态的。这就是JVMGC需要关注的区域。
  4. 查看GC日志
    • 在我们程序运行时,我们可以通过配置JVM参数-XX:+printGCDetails来查看GC日志。

1.2、如何定位垃圾

1.2.1、引用计数法

  1. 引用计数法很简单,他实际上是通过在对象头中分配一个空间来保存该对象引用的次数。如果该对象被其他对象引用,则它的引用计数加一,如果删除对该对象的引用,那么它的引用计数减一,当该对象的引用计数为0时,那么该对象会被标记为垃圾对象。
  2. 优点:
    实现简单,判定高效,可以很好地解决大部分场景的问题
  3. 缺点:
    • 很难解决对象之间相互循环引用的问题,当两个对象不再被访问时,因为相互引用对象,导致引用计数不为0;
    • 开销较大,频繁且大量的引用变量,带来大量的额外运算,主流JVM都没有选用引用计数法来管理内存。

引用计数法
如上图所示。第10行str引用了“ABC”则“ABC”的计算器等于1,。第11行str释放了该引用,所以“ABC”的计数器就减一。

1.2.2、可达性分析法(根搜索算法)

  1. 可达性分析法通过一系列“GC Roots”对象作为起始点,开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,认为对象不可达,则证明该对象是不可用的。

可达性分析算法

想要理解可达性算法,首先要弄明白一下几个问题:

- 什么是对象可达?
  - 对象可达指的就是:双方存在直接或间接的引用关系。根可达或GC Roots可达就是指对象到GC Roots存在直接或者间接的引用关系。

- GC Roots是什么?
  - 垃圾回收时,JVM首先要找到所有GC Roots,这个过程称作[枚举根节点],这个过程是需要暂停用户线程的,即触发STW(Stop The Word)。然后再从GC Roots这些根节点向下搜寻,可达的对象就保留,不可达的对象就回收。
  - GC Roots就是对象,而且是JVM确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象)。只有找到这种对象,后面的搜寻过程才有意义,不能被回收的对象所依赖的其他对象肯定会不能回收。当JVM触发GC时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW,然后枚举根节点,找到所有的GC Roots,然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。即使是号称几乎不停顿的CMS、G1等收集器,在枚举根节点时,也要暂停用户线程。GC Roots是一种特殊的对象,是Java程序在运行过程中所必须的对象,而且是根对象。

- 哪些对象可以作为GC Root对象?(GCRoot可以是一个也可以是多个)
  - 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法栈使用到的参数、局部变量、临时变量等;
  - 方法区中类的静态属性引用的对象,譬如Java类的引用型静态变量;
  - 方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;
  - 本地方法栈中JNI(Native方法)引用的对象;
  - Java虚拟机的内部引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器;
  - 所有被同步锁(synchronized关键字)持有的对象;
  - 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
public class MyObject {
	private String objectName;//对象名
	private MyObject refrence;//依赖对象

	public MyObject(String objectName) {
		this.objectName = objectName;
	}

	public MyObject(String objectName, MyObject refrence) {
		this.objectName = objectName;
		this.refrence = refrence;
	}

	public static void main(String[] args) {
		MyObject a = new MyObject("a");
		MyObject b = new MyObject("b");
		MyObject c = new MyObject("c");
		a.refrence = b;
		b.refrence = c;
		new MyObject("d", new MyObject("e"));
	}
}

根据上面的代码我们可以画出他们的可达性分析图,假设a是GC Roots的话,那么b、c就是可达的,d、e就是不可达的。
代码可达性分析图

  1. 优点:

    • 更加精准和严谨,可以分析出循环数据结构相互引用的情况
    • 主流的调用程序语言(Java、C#等)在主流的实现中,都是通过可达性分析来判定对象是否存活的。
  2. 缺点:

    • 实现比较复杂
    • 需要分析大量数据,消耗大量时间
    • 分析过程需要GC停顿(引用关系不能发生变化),即停顿所有Java执行线程(称为“Stop The Word”,是垃圾回收重点关注的问题)

1.3、再谈引用

  1. 在JDK1.2版本之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另一块内存的其实地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并不是不对,但是有一些局限性。譬如我们想要描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就抛弃这些对象--很多系统缓存功能都符合这样的应用该场景。这对这种场景,传统的reference定义是解决不了此类问题的。
  2. 在JDK1.2版本之后,Java对应用的概念进行了扩充,将应用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom-Reference)4种,这4种引用强度逐次减弱。
    • 强引用:是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论在任何情况下,只要强引用关系还存在,垃圾回收器就永远不会回收掉引用的对象。
    • 软引用:是用来描述一些还有用,但非必须的对象。只被软引用关联者的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
    • 弱引用:也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
    • 虚引用:也称为“幽灵引用”或者“幻影引用”。它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

1.4、对象是否死亡

  1. 其实在可达性分析算法中判定为不可达对象,也是不会“非死不可”的,这时候他们还是处于“缓刑”状态,要真正宣告一个对象死亡,至少需要两次标记过程;如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的方法就是此对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机执行过了,那么虚拟机将这两种情况都视为“没有必要执行”。

  2. 如果一个对象被判定为确有必要执行finalize()方法,那么该对象会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动创建的、低调度优先级的Finalizer线程去执行它们的finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。不过finalize()方法并不推荐使用,使用try-finally或者其他方式可以做的更好。

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive() {
        System.out.println("yes, i am still alive");
    }
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        // 对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
        // 但是下面的代码再次执行就会被回收了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

/**
运行结果
finalize method executed!
yes, i am still alive
no, i am dead :(
*/

1.5、方法区需要回收吗?

  1. 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支类卸载),方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收由于苛刻的判断的条件,其区域垃圾收集的回收成果往往远远低于此。
  2. 方法区垃圾回收两部分内容:废弃的常量和不再使用的类型。
    • 回收废弃的常量:和Java堆中对象非常类似。举个例子假如一个字符串“ java”曾经进入常量池 中,但是当前系统又没有任何一个字符串对象的值是“ java”,换句话说,已经没有任何字符串对象引用 常量池中的“Java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且 垃圾收集器判断确有必要的话,这个“ java”常量就将会被系统清理出常量池。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。
    • 回收不再被使用的类:需要满足三个条件
      • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
      • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替代类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
      • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2、年轻代和老年代

2.1、年轻代(Young Generation)

年轻代又可以细分为:Eden区、Survivor区(From区、To区)默认比例是:Eden:From:To=8:1:1。当我们创建一个对象时,JVM首先讲对象分配在Eden区,其中大部分对象在该区域死亡,成为垃圾对象。当Eden区内存空间不足,年轻代将执行Young GC回收垃圾对象,在此阶段,年轻代利用Survivor去GC复制算法来收集垃圾。经过几次Young GC后仍然存活的对象,将根据相关策略晋升至老年代。

2.2、老年代(Old Generation)

对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Young GC后存活了下来),则会被复制到老年代,老年代的空间一般比年轻代大,能存放更多的对象,在老年代上发生GC次数也会比年轻代少。当老年代内存不足时,将执行Full GC。如果对象比较大(比如长字符串或大数组),Young空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应用少,更应避免使用短命的大对象)。

HotSpot JVM的GC机制的基本算法是:分代收集算法。在年轻代中的Young GC使用的GC复制算法;在老年代中,将使用标记-清除算法、标记-压缩算法。

3、垃圾收集算法及经典垃圾收集器

3.1、 垃圾收集算法法分类

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。

3.1.1 分代假说简述

  1. 分代假说分类

    • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
    • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
    • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
  2. 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

  3. 因为把不同类型的分代集中在一起,因此就有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分也才能够针对不同的区域安 排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算 法”“标记-整理算法”等针对性的垃圾收集算法。

  4. 关于跨代引用假说,我们知道老年代中的对象是有可能引用到新生代中的对象的,这种引用被称之为跨代引用。在进行“Minor GC”过程中当存在跨代引用的情况下是否还需要扫描整个老年代呢?答案当然是否定的。如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代分成若干小块,表示出老年代的哪一块内存会存在跨代引用,此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

  5. 分代收集定义

    • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
      • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
      • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
      • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
    • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.1.2、标记-清除算法

最早出现的也是最基础的收集器算法是“标记-清除”(Mark-Sweep)算法,首先标记出需要回收的对象,标记完成之后统一清除被标记的对象;当然也可以标记出存活的对象,然后清除未标记的对象。为什么说它是最基础的算法呢,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
标记-清除算法示意图

  1. JVM实现原理

    • 标记:从根集合开始扫描,标记存活的对象
    • 清除:扫描整个堆内存空间,回收未被标记的对象,使用free-list记录可以使用的区域
  2. 优点:
    基于最基础的可达性分析算法,他是最基础的收集算法;而后续的收集算法都是基于这种思路并对其不足进行改进得到的。

  3. 缺点:

    • 效率问题:执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这是必须进行大量标记和清除动作,导致标记和清除两个过程执行效率都随对象数量增长而降低
    • 空间问题:标记清楚后会产生大量不连续的内存碎片,这会导致分配大内存对象时,无法找到足够连续内存,从而需要提前触发一次垃圾收集动作。
    • stop-the-word:在标记时需要暂停JVM用户进程
  4. 应用场景:
    针对老年代

3.1.3、标记-复制算法

标记-复制算法被简称为复制算法。为了解决标记-清除算法面对大量可回收对象执行效率低问题,最开始提出的是“半区复制”(Semispace Copying)的垃圾收集算法,将可用内存容量划分为大小相等的两块,每次只使用其中的一块,当这一块使用完之后,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间清理掉。如果内存中多数对象都是存活的,这种算法将会昌盛大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按照顺序分配即可。
标记-复制算法示意图

  1. JVM实现原理:现在商用Java虚拟机大多都优先采用这个收集算法去回收新生代,不过是一种更优化的“半区复制”分代策略,现在被称为“Appel式回收”。由上面描述我们知道Survivor区,一块叫From,一块叫To,而且我们新创建的对象一般都是存在存放到Eden区的。当Eden区满需要进行GC时,Eden去存活的对象全部移到To块,而Form中,存活的对象按照年龄值决定去向,当达到一定值(年龄阈值,可通过JVM参数-XX:MaxTenuringThreshold设置,默认是15)的对象会移到老年代中,没有达到的年龄阈值的对象会复制到To区,然后直接清空Eden和From。之后把From和To交换角色,新的From即为原来的To块,新的To块即为原来的From块,且把From块的年龄加1。注意一个问题,如果从Eden区存活下来的对象较多,from区内存不足以存放下来时,Appel还提供了一个“逃生门”安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。点击可查看更多有关“Appel式回收”相关知识。

  2. 优点:
    内存分配时不用考虑内存碎片等问题;实现简单,运行高效;可以利用指针碰撞(bump-the-pointer)实现快速内存分配

  3. 缺点:

    • 空间浪费:可用内存缩减为原来的一半,太过浪费(解决:可以修改,分配比例修改为8:1:1)
    • 效率随对象存活率升高而变低;当对象存活率较高时,需要进行较多复制操作(对象的引用地址需要复制),效率将会变低,所以该算法不适合对象存活率较高的场景或者区域。
  4. 应用场景

    • 现在商业JVM都采用这种算法(通过改良缺点1)来回收新生代
    • 如Serial收集器,ParNew收集器,Parallel Scavenge收集器,G1(从局部看)。

3.1.4、标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以对应被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对这个问题,提出了“标记-整理”(Mark_Compact)算法,其中的标记过程仍然与“标记-复制”算法一样,但是后续不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-整理算法示意图

“标记-整理”算法相对于老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动的操作必须全程暂停用户应用程序才能进行,这就更让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”

  1. JVM实现原理:

    • 标记:和“标记-清除”算法一致,从根集合开始扫描,标记存活的对象
    • 整理:扫描整个堆内存空间,将存活的对象都向一端移动,并更新引用其对象的指针,然后直接清理掉边界以外的内存。整理的目的就是整合零散分布的空间碎片为一个连续的空间。
  2. 优点:

    • 效率和复制算法相反,标记-整理算法效率随对象存活率升高而变高
    • 不会和标记清除算法一样产生内存碎片,因为清除前,进行了整理,存活的对象都集中到空间一侧;
  3. 缺点:
    主要是效率问题,除了标记清除算法的操作以外,还需要整理内存碎片的过程,效率更低了,相当于是用时间换空间。

  4. 应用场景:
    回收老年代

3.1.5、标记-清除-整理

该算法是标记清除和标记整理的结合,标记-清除会产生碎片,标记-整理每次都进行整理效率不高;标记-清楚-整理 是如果老年代内存中没有一块连续续的空间可以存放将要进入对象,就进行整理;如果内存中的空间可以存放将要进入的对象,就进行标记-清除,这样就节省了整理的步骤可以提高效率。总结一句话:不是所有的时候都需要整理的,因为整理也付出代价。主要应用于老年代。

3.1.6、总结

没有最好的算法,只有最合适的应用场景

内存效率:复制算法>标记清除算法>标记整理算法
内存整起度:复制算法=标记整理算法>标记清除算法
内存利用率:标记整理算法=标记清除算法>复制算法(分半)

想了解更多内容可参考博客
关于垃圾收集器介绍可以点击参考此篇博客

3.2、经典垃圾收集器

如果说垃圾回收算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。JDK11之前各款经典收集器之间的关系如图
HotSpot虚拟机垃圾收集器

图中其中作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则代表它是属于新生代收集器抑或是老年代收集器。在介绍收集器各自特性之前,我们再来明确一个点:虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来,虽然垃圾收集器的技术在不断进步,但直到现在还没有最好的收集器出现,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器。如果已经出现了最好的垃圾回收器,那也没必要实现那么多种不同的收集器了不是?

3.2.1、Serial收集器

Serial收集器是最基础、历史最悠久的收集器,使用标记-复制算法,曾经(在JDK1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。从名字来看我们就能猜到,这个收集器是一个单线程工作的收集器,但是它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,知道它收集结束。“Stop The World”这个词听起来也许很酷,但这项工作是由虚拟机在后台自动发起和自动完成的。下图是Serial/Serial Old收集器运行示意图。
Serial/Serial Old收集器运行示意图
Serial收集器作用于年轻代,使用的是标记复制算法;Serial Old收集器作用于老年代,使用的是标记整理算法。

虽然Serial收集器作为最早的收集器,且“Stop The World”带给用户恶劣体验,但事实上,迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说。Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制十几、几十毫秒,最多一百毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好地选择。

3.2.2、ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,同样使用标记-复制算法,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。下图是ParNew/Serial Old收集器运行示意图。
ParNew/Serial Old收集器运行示意图

ParNew收集器是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前的遗留系统中首选的新生代收集器,其中一个与功能、性能无关但其实很重要的原因就是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。

JDK5,HotSpot推出了在强交互引用中几乎可称为具有划时代意义的垃圾回收器-CMS收集器。它是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

遗憾的是,CMS作为老年代收集器,无法与JDK1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweep GC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制制定或者禁用它。

可以说知道CMS的出现才巩固了ParNew的地位,但是随着垃圾收集技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以从JDK9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了ParNew加Serial Old以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了XX:+UseParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。可以理解为从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。

从ParNew收集器开始,后面还将会接触到若干款涉及“并发”和“并行”概念的收集器,下面我们稍微介绍一下着两个名词:
* 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程时处于等待状态(Stop The World)。
* 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所有程序仍然能相应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序处理的吞吐量将受到一定影响。

3.2.3、Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,他同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
吞吐量
如果虚拟机完成某个任务,用户代码加上垃圾收集总共消耗了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
* -XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过并不是参数的值设置得越小就能使系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间代价来换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
* -XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。

扩展:
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。如果对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

3.2.4、Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用的。如果在服务端模式下,他也能有两种用途:
* 第一种:JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用;
* 第二种:作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Serial/Serial Old收集器运行示意图

需要说明一下:Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解。

3.2.5、Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器知道JDK6才开始提供。所以在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作(Parallel Scavenge没有使用原本HotSpot其它GC通用的那个GC框架,所以不能跟使用了那个框架的CMS搭配使用)。因此Parallel Scavenge收集器效率大大受限,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来的优秀。

知道Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
Parallel Scavenge/Parallel Old收集器运行示意图

3.2.6、CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

CMS从名字(包含“Mark Sweep”)就能看出该收集器是基于标记-清除算法实现的,它的运作过程比较复杂,整个过程分为四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是并不需要停顿用户线程,可以与垃圾收集器一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清楚阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清楚阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
Concurrent Mark Sweep收集器运行示意图

CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector).CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是他还远远达不到完美的程度,至少有以下三个明显缺点:

  1. 首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,他虽然不会导致用户线程停顿,但是却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个小时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运行能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从 JDK 7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK 9发布后i-CMS模式被完全废弃。

  2. 在CMS的并发标记和并发清理阶段,用户线程还是继续运行的,程序在运行自然就会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这部分垃圾就称为“浮动垃圾”。这样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间并发收集时程序运作使用。在JDK5默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6时,CMS收集器的启动 阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

  3. 还有最后一个缺点,CMS时一款基于“垃圾-清楚”算法实现的收集器,这个收集算法会产生大量的空间碎片。空间碎片过多时,将会给打对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前出发一次Full GC的情况。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的,此参数从 JDK9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象 ,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

3.2.7、Garbage First收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。早在JDK7刚刚确立项目目标、Oracle公司制定的JDK7RoadMap里面,G1收集器就被视作JDK7中HotSpot虚拟机的一项重要的特征。从JDK6 Update 14开始就有Early Access版本的G1收集器供开发人员实验和试用(实验状态-- Experimental),知道JDK 8 Update 40的时候,G1提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的G1收集器才被Oracle官方称为“全功能的垃圾收集器”(Fully Featured Garbage Collector)。

G1是一款主要面对服务端应用的垃圾收集器,开发着希望以此来替代JDK5中发布的CMS。JDK9发布之后,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则
沦落至被声明为不推荐使用(Deprecate)的收集器。如果在JDK9及以上版本通过参数-XX:+UseConcM arkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃:
CMS废弃警告

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RT SJ)的中 软实时垃圾收集器特征了。首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(M ajor GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1Heap RegionSize设定,取值范围为1M B~32M B,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。 这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
G1收集器Region分区示意图

G1收集器细节问题处理:

  1. 将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?解决的思路:使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

  2. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法是:CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致FullGC而产生长时间“Stop The World”。

  3. 怎样建立起可靠的停顿预测模型?用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(DecayingAverage)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

G1收集器的运行过程:

  1. 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

  2. 并发标记(Concurrent Marking):从GCRoot开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。
G1收集器运行示意图

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

3.2.8、CMS和G1对比

  1. 相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

  2. 不过,G1相对于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

  3. 就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。

  4. 在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

  5. 以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照各大佬的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。
    关于读写屏障的介绍可了解此篇博客

4、低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。由此,我们就不难理解为何延迟会成为垃圾收集器最被重视的性能指标了。现在我们来观察一下现在已接触过的垃圾收集器的停顿状况,如图所示。
各款收集器的并发情况
图中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。由图可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿;CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

同时也能看到最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-TimeGarbage Collector)。

4.1、Shenandoah收集器

Shenandoah是第一款不由Oracle(包含之前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器(最初由RedHat公司独立发展,2014年后贡献给OpenJDK),在OracleJDK12中拒绝使用Shenandoah收集器,也就是说其只有OpenJDK包含。

4.1.1、Shenandoah收集器和G1收集器的区别?

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

  1. 最重要的当然是支持并发整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能后续在进行描述。

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

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

4.1.2、Shenandoah收集器工作流程

Shenandoah收集器的工作过程大致可以分为以下九个阶段(此处以2016年发表Shenandoah版本进行介绍)。

  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”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。

  6. 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作成为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。

  7. 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

  8. 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。

  9. 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都编程Immediate Garbage Region了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

Shenandoah收集器的工作过程
黄色的区域代表的是被选入回收集的Region,绿色部分就代表还存活的对象,蓝色就是用户线程可以用来分配对象的内存Region了。图中不仅展示了Shenandoah三个并发阶段的工作过程,还能形象地表示出并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正,此后回收集便不存在任何引用可达的存活对象了。

4.1.3、Shenandoah支持并行整理的核心概念--Brooks Pointer

“Brooks”是一个人的名字,他使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。此前,要做到类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程转发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。

因此Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,如图所示。
Brooks Pointer示意图
转发指针加入之后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其只想新对象,便可将所有对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作。
Brooks Pointer示意图

4.2、ZGC收集器

ZGC(“Z”并非什么专业名词的缩写,这款收集器的名字就叫做Z Garbage Collector)是一款由Oracle公司研发,在JDK11中新加入的具有实验性质的低延迟垃圾收集器。ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

4.2.1、ZGC的内存布局

与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage,为了方便后续介绍都称之为为Region)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有大、中、小三类容量:

  1. 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象;

  2. 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象;

  3. 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫做“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段),因为复制一个大对象的代价非常高昂。
    ZGC的堆内存布局

4.2.2、ZGC核心问题--并发整理算法的实现

Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但用的却是一条与Shenandoah完全不同,更加复杂精巧的解题思路。

ZGC收集器有一个标志性的设计是它采用的染色指针技术(ColoredPointer,其他类似的技术中可能将它称为TagPointer或者VersionPointer)。从前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?能不能从指针或者与对象内存无关的地方得到这些信息,譬如是否能够看出来对象被移动过?而ZGC的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上。尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如图所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。
染色指针示意

虽然染色指针有4TB的内存限制,不能支持32位平台,但它带来的收益也是非常可观的:

  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,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。

4.2.4、虚拟内存映射技术

要应用染色指针有一个必须解决的前置问题:Java虚拟机作为一个普普通通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?这是很现实的问题,无论中间过程如何,程序代码最终都要转换为机器指令流交付给处理器去执行,处理器可不会管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内存地址来对待。这里面的解决方案要涉及虚拟机内存映射技术。

在x86-64平台上,ZGC设计者采用虚拟内存映射技术,将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,多重映射是染色指针技术的伴生产物。意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了。
多重映射下的寻址
这块可以参照此篇博客理解

4.2.5、ZGC工作流程

ZGC的运作过程大致可划分为以下四个阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化GC Roots直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异。
ZGC运作过程

  1. 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked0、Marked1标志位。

  2. 并发预备重分配(Concurrent Prepare Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非是为了像G1那样做收益有限的增量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。此外,在JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。

  3. 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转换关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对此Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC对用户线程的运行时负载要比Shenandoah来的更低一些。还有另外一个直接的好处就是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多只想这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。

  4. 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即时是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧指针的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说并不是很“迫切”。因此,ZGC很巧妙地把并发重映射阶段要做的工作,和冰岛了下一次垃圾收集循环中的并发标记阶段里面去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历图对象的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放了。

ZGC是迄今垃圾收集器研究的最前沿成果,它与Shenandoah一样做到了几乎整个收集过程都全程可并发,短暂停顿也只与GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。

4.2.6、ZGC的优劣

相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择,譬如G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。

可是,必定要有优有劣才会称作权衡,ZGC的这种选择也限制了它能承受的对象分配速率不会太高,可以想象以下场景来理解ZGC的这个劣势:ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上(请读者切勿混淆并发时间与停顿时间,ZGC立的Flag是停顿时间不超过十毫秒),在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。

自此有关JVM介绍本文就到这里了,如果各位看官觉得还看得下去,可以收藏并关注博主哦!

posted @   菜鸡啄梦  阅读(23)  评论(1编辑  收藏  举报
努力加载评论中...
点击右上角即可分享
微信分享提示