内存自动管理概述

内存如何分配取决于 JVM 使用哪种垃圾收集(GC)策略。垃圾收集策略事实上就是内存自动管理的策略。这里说的内存,特指 JVM 的堆区和方法区。这里说的垃圾收集策略,是对什么内存需要回收,什么时候回收,如何回收这三件事情的统一描述。

GC 区域

在《JVM 知识梳理之一_JVM 运行时内存区域与 Java 内存模型》中,已经描述了 JVM 运行时的五个内存区域:程序计数器,虚拟机栈,本地方法栈,堆,方法区。

其中,程序计数器,虚拟机栈,本地方法栈这三个区域是线程私有的,其内存分配多少与生命周期基本上是编译期可知的,所以它们的内存分配和回收是比较固定的。因此它们不在 JVM 的垃圾收集策略的范围内。

而 Java 堆与方法区是线程共享的,它们内部存储的是对象,常量,类信息等等,它们是动态的,不确定的,只有到了运行期才能知道具体加载了多少类,创建了多少对象。因此 JVM 的垃圾收集策略针对的就是堆和方法区的内存回收。

方法区的垃圾收集相对固定且较少,因此更多的时候,JVM 的 GC 特指的是 Java 堆的内存管理与回收。

GC 策略决定了内存如何分配

之前的 JVM 运行时内存区域与 Java 内存模型中,梳理了 JVM 运行时内存区域的划分,并在讲述 Java 堆区的时候,简单讲了分代收集的大致流程。

分代收集算法大致过程:

  1. JVM 新创建的对象会放在 eden 区域。
  2. eden 区域快满时,触发 Minor GC 新生代 GC,通过可达性分析将失去引用的对象销毁,剩下的对象移动到幸存者区 S1,并清空 eden 区域,此时 S2 是空的。
  3. eden 区域又快满时,再次触发 Minor GC,对 edenS1 的对象进行可达性分析,销毁失去引用的对象,同时将剩下的对象全部移动到另一个幸存者区 S2,并清空 edenS1
  4. 每次 eden 快满时,重复上述第 3 步,触发 Minor GC,将幸存者在 S1S2 之间来回倒腾。
  5. 在历次 Minor GC 中一直存活下来的幸存者,或者太大了会导致新生代频繁 Minor GC 的对象,或者 Minor GC 时幸存者对象太多导致 S1S2 放不下了,那么这些对象就会被放到老年代。
  6. 老年代的对象越来越多,最终会触发 Major GCFull GC,对老年代甚至整堆的对象进行清理。通常 Major GCFull GC 会导致较长时间的 STW,暂停 GC 以外的所有线程,因此频繁的 Major GCFull GC 会严重影响 JVM 性能。

这个流程实际上也是 JVM 目前主流的内存分配和垃圾收集的过程。

从上面的流程可以看出,正是由于采取了分代收集的垃圾收集策略,JVM 分配内存时才需要按照 eden 区域,幸存者区域,老年代这样的划分去分配内存。JVM 的内存分配是由垃圾收集策略决定的,或者说,内存分配和垃圾收集是一体的,都属于 JVM 的 内存自动管理。因此关键在于垃圾收集策略。确定了垃圾收集策略,也就自然确定了内存如何分配。所以本文其实就是在梳理 GC 相关知识。

判断对象是否存活

如何判断一个对象是否处于存活状态,有两种基本的算法:引用计数算法,可达性分析算法。

引用计数算法(虚拟机中基本不用)

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance = objB 及 objB.instance = objA,除此之外,这两个对象再无任何引用,(objA 引用 objB, objB 引用了 objA) 互相引用,objA 的计数器是 1, objB 的计数器也是 1,如果使用引用计数算法,永远不会被回收,但是极大可能是无用的对象。

实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。 所以 主流的 Java 虚拟机不选用该算法。

可达性分析算法

什么是可达性分析算法?

当前主流的 商用程序语言(Java、C#,上溯至前面提到的古老的 Lisp)的内存管理子系统,都是通过 可达性分析(Reachability Analysis)算法来判定对象是否存活的

可达性分析(Reachability Analysis)采用图论算法,从一些被称为 GC Roots 的根对象出发,根据引用关系向下推导可以到达的对象,形成 引用链(Reference Chain),也叫对象图。如果某个对象与 GC Roots 之间没有任何引用链相连,就认为从 GC Roots 到该对象是不可达的。不可达的对象即不可能再被使用,是可以被回收的对象。可达性分析算法可以轻松解决循环引用的问题,如下图所示:

很显然,可达性分析算法的关键点有二:一是 GC Roots 的确定;二是对象间引用关系的确定。

如图所示,对象 object 5、object 6、object 7 虽然互有关联,但是 它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象。

GCRoots

在 Java 技术体系里面,固定 可作为 GC Roots 的对象包括以下七种:(面试最好可以回答出 4 + 1 个答案)

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

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。

譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。(跨代引用,(记忆集,卡表))

引用

在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用 是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用 是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用
  • 弱引用 也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象 只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用
  • 虚引用 也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了 能在这个对象被收集器回收时收到一个系统通知

如果对象不可达,就一定会被回收吗?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于 “缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被 第一次标记

  2. 随后进行一次筛选,筛选的条件是 此对象是否有必要执行 finalize()方法。假如 对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

    • 如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机 自动建立 的、低调度优先级的 Finalizer 线程执行它们的 finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并 不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
    • finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

代码示例:GcTest.java (纯纯的是为了演示 finalize 方法执行对垃圾收集的一个影响。)实际工作中,远离 finalize()方法,就当没有这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package pers.fulsun;

public class GcTest {

public static GcTest A;

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize 被调用");
A = this;
}

public static void main(String[] args) throws InterruptedException {
A = new GcTest();

// 将 对象 变为垃圾对象
A = null;

// 进行垃圾回收
System.gc();
// 由于 gc 的优先级比较低 等待 1000ms 看 finalize 是否被调用
Thread.sleep(1000);

// 本次 GC 调用之前 先调用了 finalize 方法 为对象添加了 GC Root 引用链
if (A == null) {
System.out.println("对象被回收");
} else {
System.out.println("对象未被回收"+A);
}

// 将 对象 变为垃圾对象
A = null;

// 进行垃圾回收
Runtime.getRuntime().gc();
// 由于 gc 的优先级比较低 等待 1000ms 看 finalize 是否被调用
Thread.sleep(1000);

// 本次调用 GC 会直接判断对象是否有引用链, 不再调用 finalize 方法, 发现没有引用链 , 直接回收
if (A == null) {
System.out.println("对象被回收");
} else {
System.out.println("对象未被回收");
}
}
}

// 结果
finalize 被调用
对象未被回收pers.fulsun.GcTest@75b84c92
对象被回收

finally 和 finalize 方法的区别是什么?

  • finally 是一个用于异常处理的关键字,用于定义在 try 块执行之后无论是否发生异常都要执行的代码块。
  • finalizeObject 类的一个方法,用于垃圾回收前进行对象的清理工作,但不建议过度使用,因为垃圾回收的时机不确定,不能保证及时执行。

垃圾审判流程

通过 GC Roots 和引用链推导,JVM 就能够判断一个对象是否可达。但要注意,不可达的对象并非一定会被回收。

在 JVM 的 GC 设计中,真正回收一个对象,这个对象还要经历一个审判过程:两次标记和一次筛选。如下图所示:

第一次标记是可达性分析结果为不可达,此时它处于“等待秋后问斩”的状态,还有“上访”的机会;筛选是指每个对象都继承了 Object 对象的 finalize() 方法,如果它的类重写了该方法,且之前没有被 JVM 调用过该对象的 finalize() 方法,那么这个对象会加入一个队列等待执行 finalize() 方法;第二次标记是指当 finalize() 被执行时,就是这个对象翻盘拯救自己的最后机会,就看它在这个方法里能否与 GC Roots 的引用链重新关联上;如果未能重新关联,或者压根没有自己重写覆盖 finalize(),那这个对象就真的“死路一条”了。

再谈方法区回收(了解)

有些人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java 虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 ZGC 收集器就不支持类卸载),方法区垃圾收集的“性价比”通常也是比较低的:在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70%至 99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java 堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

(1)该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。

(2)加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。

(3)该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

分代收集理论

概述

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

(1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

(2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”(回收整个堆空间 – 方法区和堆)这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

新生代和老年代

把分代收集理论具体放到现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

(3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就 不应 再为了少量的跨代引用去 扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为 “记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。

标记清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在 1960 年由 Lisp 之父 John McCarthy 所提出。

如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在前一节讲述垃圾对象标记判定算法时其实已经介绍过了。

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。

它的主要缺点有两个:

  1. 第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;

  2. 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如图

标记复制算法

标记-复制算法常被简称为复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间 复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

标记-复制算法的执行过程如图:

Appel 式回收(分代)

1989 年,Andrew Appel 针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel 式回收”

  • HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。
  • Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间每次分配内存只使用 Eden 和其中一块 Survivor。
  • 发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。
  • HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 ∶ 1 ∶ 1。

标记整理算法

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

针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如图所示。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策

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

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

垃圾收集(标记)

根节点枚举

根节点:固定可作为 GC Roots 的节点,主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。

根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS、G1、ZGC 等收集器,枚举根节点时也是必须要停顿的。

现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。这样合适吗?

不合适!

当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在 HotSpot 的解决方案里,是使用 一组称为 OopMap 的数据结构来达到这个目的

一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会 在特定的位置记录 下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。

OopMap

在 JVM 中,一个线程为一个栈,一个栈由多个栈桢组成,一个栈桢对应一个方法,一个栈帧可能有多个 OopMap。

假设,这两个方法都只有一个 OopMap,并且是在方法返回之前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 方法 1 存储在栈帧 3
public void testMethod1() {
// 栈里和寄存器内的引用
DemoD demoD = new DemoD();
}

// 方法 2 存储在栈帧 8
public void testMethod2() {
// 栈里和寄存器内的引用
DemoA demoA = new DemoA();
// 对象内的引用
demoA.setDemoC(new DemoC());

// 栈里和寄存器内的引用
DemoA demoB = new DemoB();
}

那么 testMethod1() 和 testMethod2() 的 OopMap 如下图所示:

可以理解为 OopMap 就是商场的商品清单,清单上记录着每一种商品的所在位置和数量,通过清单可以直接到对应的货架上找到商品。

如果没有这份清单,需要寻找一件商品的时候,就只能从头开始,按顺序翻找每一个货架上的商品,直到找到对应的商品。

安全点

OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上 HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为 安全点(Safepoint)

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如 方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)主动式中断(Voluntary Suspension)

(1)抢先式中断

抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。

(2)主动式中断

而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地在安全点设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于 轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot 使用内存 保护陷阱 的方式,把 轮询操作精简至只有一条汇编指令的程度。

下面代码清单中的 test 指令就是 HotSpot 生成的轮询指令,当需要暂停用户线程时,虚拟机把 0x160100 的内存页设置为不可读,那线程执行到 test 指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。

安全区域

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程 处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入 安全区域(Safe Region)来解决

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的 安全点

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

记忆集与卡表

记忆集

讲解分代收集理论的时候,提到了 为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如 G1、ZGC,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,记忆集是一种用于记录 从非收集区域指向收集区域的指针集合的抽象数据结构。

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中 ,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

卡表

卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的。以下这行代码是 HotSpot 默认的卡表标记逻辑:

字节数组 CARD_TABLE 的每 一个元素 都对应着其标识的内存区域中一块 特定大小的内存块,这 个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N 次幂的字节数。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。

解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏?

卡表元素何时变脏的答案是很明确的——有其他 分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?

写屏障

在 HotSpot 虚拟机里是通过 写屏障(Write Barrier)技术维护卡表状态的。先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。

并发的可达性分析——三色标记

曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。

要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。

想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

(1)白色表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

(2)黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

(3)灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。(大概率将来也是存活对象)

三色标记对象消失的问题

关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是 把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面表演示了这样的致命错误具体是如何产生的。

解决方案

当且仅当以下 两个条件同时满足 时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象(代表已经彻底扫描过的可达对象)到某个白色对象(还没扫描到,或正要被 “从灰色对象扫描到对它”,这里叫它小白)的新引用;
  • 赋值器删除了原先全部的从灰色对象到该白色对象(小白)的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

(1)增量更新 要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

(2)原始快照 要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通 过写屏障 实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1 则是用原始快照来实现。

垃圾收集器

这里介绍的经典垃圾收集器指的是 HotSpot 虚拟机中实现的,区别于革命性创新的低延迟收集器 ZGC 与 Shenandoah 的,采用分代收集理论的垃圾收集器。包括:

  • Serial
  • ParNew
  • Parallel Scavenge
  • Serial Old
  • Parallel Old
  • CMS
  • Garbage First(简称 G1)

除了 G1,前 6 种收集器都只适用于一个区域,要么新生代,要么老年代。JVM 启动时会通过参数指定或默认使用其中两种搭配作为 JVM 的垃圾回收策略,除了 G1。参考下图:

  1. 上图中,收集器之间的连线代表这两个收集器可以搭配使用,颜色相同代表在一组搭配中。
  2. Serial/ParNew/CMS/Serial Old 这四个收集器的使用了同一套分代架构,因此本来它们的新生代和老年代可以随意搭配使用。但 Serial + CMSParNew + Serial Old 这两种搭配关系从 Java8 开始声明废弃,在 Java9 中正式移除。
  3. ParNew + CMS + Serial Old 三者一起搭配组合使用,其中 Serial Old 是作为 CMS 的备用方案存在的。
  4. Parallel ScavengeParallel Old 采用了新的分代架构,跟 Serial/ParNew/CMS/Serial Old 的分代架构不一样,所以它们是不能与 Serial/ParNew/CMS/Serial Old 搭配使用的。但 Parallel Scavenge + Serial Old 这个搭配比较特殊:实际与 Parallel Scavenge 这个新生代收集器搭配的其实并不是 Serial Old 收集器,而是 Parallel Scavenge 中重新对 Serial Old 的实现,叫 PS MarkSweep。只是因为 PS MarkSweep 的实现只是接口变化了,实际上与 Serial Old 共用了大部分实现代码。因此在这张图里将它们作为搭配组合连接起来了。

Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是 HotSpot 虚拟机新生代收集器的唯一选择。大家只看名字就能够猜到,这个收集器 是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在 它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

似乎已经把 Serial 收集器描述成一个最早出现,但目前已经老而无用,食之无味,弃之可惜的“鸡肋”了,但事实上,迄今为止,它依然是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如图

ParNew 收集器除了支持多线程并行收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。

ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果。当然,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。它 默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如 32 个,现在 CPU 都是多核加超线程设计,服务器达到或超过 32 个逻辑核心的情况非常普遍)的环境中,可以使用-XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器也是能够并行收集的多线程收集器……Parallel Scavenge 的诸多特性从表面上看和 ParNew 非常相似,那它有什么特别之处呢?

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

1
吞吐量 = 用户线程运行时间 / (用户线程运行时间 + GC时间)

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别 是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis 参数以及直接设 置吞吐量大小的-XX:GCTimeRatio 参数

  • -XX:MaxGCPauseMillis : 最大垃圾收集停顿时间,该值是一个目标值,JVM 会尽量控制每次垃圾回收的时间不超过这个值。允许的值是一个 大于 0 的毫秒数,收集器将 尽力保证 内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接 导致垃圾收集发生得更频繁原来 10 秒收集一次、每次停顿 100 毫秒现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。用该参数的理论效果:MaxGCPauseMillis 越小,单次 MinorGC 的时间越短,MinorGC 次数增多,吞吐量降低。

  • -XX:GCTimeRatio : 吞吐量指标。,它不是 GC 时间占比的意思!它是 GC 耗时目标公式 1/(1+n) 中的 n 参数而已。GCTimeRatio 的默认值为 99,因此,GC 耗时的目标占比应为 1/(1+99)=1%,即允许最大 1%的垃圾收集时间。。使用该参数的理论效果:GCTimeRatio 越大,吞吐量越大,GC 的总耗时越小。有可能导致单次 MinorGC 耗时变长。参数的值则应当是一个大于 0 小于 100 的整数。如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即 1/(1+19)),默认值为 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 收集器的一个重要特性。

Serial Old 收集器

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

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本支持多线程并发收集,基于 标记-整理算法 实现。这个收集器是直到 JDK 6 时才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于相当尴尬的状态,原因是如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如 CMS 无法与它配合工作。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用 Parallel Scavenge 收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比 ParNew 加 CMS 的组合来得优秀。

直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。Parallel Old 收集器的工作过程如图

CMS 收集器

概述

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

从名字(包含“Mark Sweep”)上就可以看出 CMS 收集器 是基于标记-清除算法实现 的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

1)初始标记(CMS initial mark) —“Stop The World” 初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快

2)并发标记(CMS concurrent mark)并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程(扫描引用量),这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行

3)重新标记(CMS remark) —“Stop The World” 而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录

4)并发清除(CMS concurrent sweep)最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

实际上 CMS 有 7 个阶段,这里省略了三个阶段:1.在重新标记之前,其实还有 Concurrent Preclean 并发预清理阶段和 Concurrent Abortable Preclean 并发可中止预清理阶段,它们主要是为了应对可能发生的这种情况:在比较耗时的并发标记阶段,又发生了新生代 GC,甚至可能多次,导致有对象从新生代晋升到老年代。这两个阶段是为了尽量减少这种未被可达性分析标记过的老年代对象。也可以简单地认为它们也属于并发标记阶段。2.在并发清除之后,还有一个 Concurrent Reset 并发重置阶段,该阶段将重置与 CMS 相关的数据结构,为下个周期的 GC 做好准备。

CMS 的缺点

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

首先CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS 默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大。

然后,由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的 Full GC 的产生。在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。 这一部分垃圾就称为“浮动垃圾”。

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

还有最后一个缺点,在本节的开头曾提到,CMS 是一款 基于“标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片 过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前 触发一次 Full GC 的 情况。CMS 为了解决这个问题,提供了 XX:+UseCMS-CompactAtFullCollection 开关参数和 XX:CM SFullGCsBefore-Compaction 参数,让 CMS 在执行过若干次标记清除的 GC 之后,下一次 GC 的时候先进行碎片整理。但这样又会使停顿时间变长。

尽管 CMS 有很多不如意的缺点,甚至在 G1 成熟后 CMS 已经被 Oracle 抛弃(在 Java9 中弃用并在 Java15 中正式移除)。但在目前依然占据了大部分生产环境的 Java8 上,ParNew + CMS 依然是大多数 Java 服务的首选 GC 策略。(尽管不是默认 GC 策略)

G1 收集器

概述

Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器 面向局部收集的设计思路和基于 Region 的内存布局形式

早在 JDK 7 刚刚确立项目目标、Oracle 公司制定的 JDK 7 RoadMap 里面,G1 收集器就被视作 JDK 7 中 HotSpot 虚拟机的一项重要进化特征。从 JDK 6 Update 14 开始就有 Early Access 版本的 G1 收集器供开发人员实验和试用,但由此开始 G1 收集器的“实验状态”(Experimental)持续了数年时间,直至 JDK 7 Update 4,Oracle 才认为它达到足够成熟的商用程度,移除了“Experimental”的标识;到了 JDK 8 Update 40 的时候,G1 提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的 G1 收集器才被 Oracle 官方称为“全功能的垃圾收集器”(Fully-Featured Garbage Collector)。

G1 是一款主要面向服务端应用的垃圾收集器。HotSpot 开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉 JDK 5 中发布的 CMS 收集器。现在这个期望目标已经实现过半了,JDK 9 发布之日,G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为服务端模式下的默认垃圾收集器,而 CMS 则沦落至被声明为不推荐使用(Deprecate)的收集器。

在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个 Java 堆(Full GC)。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成 回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

Region 的堆内存布局

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

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应 为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都 把 Humongous Region 作为老年代 的一部分来进行看待。

G1 的新生代和老年代

虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。

更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis 指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region这也就是“Garbage First”名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。

跨 Region 引用

问:将 Java 堆分成多个独立 Region 后,Region 里面存在的跨 Region 引用对象如何解决?

解决的思路我们已经知道:使用 记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多 ,它的每个 Region 都维护有自己的记忆集这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1 的记忆集在存储结构的本质上是一种哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”) 比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作

并发标记阶段

问:在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

CMS 收集器采用增量更新算法实现而 G1 收集器则是通过原始快照(SATB)算法来实现的

此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1 为 每一个 Region 设计了 两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与 CMS 中的“Concurrent Mode Failure”失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World”。

停顿预测模型(不用深究)

问:怎样建立起可靠的停顿预测模型?

用户通过-XX:MaxGCPauseMillis 参数指定的停顿时间只意味着垃圾收集发生之前的期望值。

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

G1 的运作过程

G1 收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。**–stop the world**
  • 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。 –stop the world
  • 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。 –stop the world

从上述阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望

G1 的优势

从 G1 开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个 Java 堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从 G1 开始兴起的,所以说 G1 是收集器技术发展的一个里程碑。

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

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

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

按照笔者的实践经验,目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着 HotSpot 的开发者对 G1 的不断优化,也会让对比结果继续向 G1 倾斜。

G1 工作过程对内存的分配

※1 region 分区

堆内存会被切分成为很多个固定大小区域 Region。每个 Region 内部都是地址连续的虚拟内存。每个 Region 的大小可以通过 -XX:G1HeapRegionSize 参数指定,范围是 [1M~32M],值必须是 2 的幂次方。默认会把堆内存按照 2048 份均分。每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代(包括巨型区域 H,后续有梳理)。每个 region 并不会固定属于某个分代,而是会随着 G1 垃圾收集进程而不断变化。

JVM 某个时刻的 Region 分区对应的逻辑分代示意,如下图所示:

※2 为用户线程分配内存

刚开始的时候,大部分 Region 都是空白状态。G1 会为每个用户线程分配一个 TLAB(Thread Local Allocation Buffers)空间,可能对应有多个 TLAB 块。每个 TLAB 块的大小不会超过一个 Region,这些 TLAB 块都在 eden 的 regions 中。一个用户线程创建对象申请内存时,G1 会优先从该线程的 TLAB 块中的空闲部分分配,如果不够再从 eden 的 regions 中申请新的 TLAB 块。这种方式的好处有二:一是在对象创建时,只用检查自己的 TLAB 块最后一个对象的后面的空闲空间还够不够即可,从而大大加快内存分配速度;二是使得多个用户线程的内存空间在分配时相互独立(但仍然相互可见),使得多用户线程下的内存分配可以同时进行,变得无锁。

要注意的是:

  1. TLAB 块并不是 Region 内部的区域划分,它甚至不是 G1 独有的设计。它是给每个用户线程分配的固定大小不定数量的内存块(每个块小于 region 容量),虽然位于 eden Regions 之中,但只是在有用户线程运行时才会有对应的 TLAB 块被分配出去。
  2. 上图中 Region1 与 Region2 并不代表两者是地址相邻的两个 Region。G1 从 Eden 的 Regions 中分配 TLAB 时,当一个 Region 的空间都被分出去了,就会另外找个空闲的 region 来继续分,至于这个 region 在哪,不一定。。。

※3 触发 Young GC

G1 建立了一个 Pause Prediction Model 停顿预测模型,根据期望停顿时间来计算每次需要收集多少 Regions。当分配内存持续进行导致 eden 的 regions 数量到达这个值时,就会触发 G1 的 Young GC,对所有的 eden 的 regions 进行垃圾收集。这里的算法采用的是并行的标记复制,即,对所有 eden regions 中的对象进行可达性分析和标记,将所有的存活对象整齐地复制到空闲的 Regions 中。这些存活对象就晋升为 Survivor 对象,所属的 Region 则从空闲状态变为 S 状态(Survivor),原先 eden regions 会被统统清空重新回到空闲状态。

类似 Serial 等经典的新生代收集器对新生代的比例划分,G1 对 Survivor 的 Regions 数量也有 --XX:TargetSurvivorRatio 来控制,默认也是 8:1。逻辑上来讲,G1 的 young GC 也是一个 eden 两个 survivor 进行复制的。

同时,Young GC 还负责其他的一些数据统计工作,比如维护对象年龄相关信息,即存活对象经历过 Young GC 的总次数。

很显然,G1 的 Young GC 是 STW 的,但基于分代收集理论我们知道对新生代的可达性分析实际能够可达的对象很少,所以停顿时间很短暂。

※4 晋升老年代

JVM 经过多轮 young GC 之后,那些总能存活下来的对象就会晋升到老年代。对于 G1 来说,就是在 young GC 时,将满足晋升条件的对象从 survivor region 复制到老年代 region 中。如果需要使用空闲 region 来存放晋升的对象,那么这个空闲 region 就变为了老年代 region。

这里有个概念,叫 Promotion Local Allocation Buffers,即 PLAB,类似于 TLAB。但 PLAB 不是给用户线程分配的,是给 GC 线程分配的。当对象晋升到 survivor 分区或者老年代分区时,复制对象所需内存是从每个 GC 线程自己的 PLAB 空间分配的。使用 PLAB 的好处与 TLAB 相同,更有效率且多线程无锁。

※5 混合收集

当老年代空间在整个堆空间中占比(IHOP)达到一个阈值(默认 45%)时,G1 会进入 并发标记周期(后面有梳理),然后会进入 混合收集周期。收集的目标是 整个新生代的regions + 老年代中回收价值较高的regions。相当于 新生代收集 + 部分老年代收集

G1 每次收集,会将需要收集的 region 放入一个叫 CSet 的集合,结合下图来理解 young GCMixed GC 的收集对象不不同:

所谓回收价值,是 G1 对每个老年代 region 做出的基于回收空间/回收耗时及期望暂停时间的价值评估。G1 在进行混合回收时,并不是对所有老年代 region 都进行回收,而是根据这个回收价值,选取价值较高的那些老年代进行收集。

这里就能看到为什么叫 Garbage First,因为会首先收集垃圾比较多的 Region。。。

混合收集中的老年代收集和新生代收集一样,采用的是也是并行的复制算法。

※6 巨型区域(Humongous Region)

Region 中还有一类特殊的 Humongous Region,专门用来存储巨型对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为巨型对象。而对于那些超过了整个 Region 容量的巨型对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。

至此,大致梳理了 G1 如何管理内存的问题。接下来,继续梳理 G1 的完整的活动时序。

G1 垃圾收集活动时序

G1 垃圾收集的完整周期比较复杂,参考下面的 G1 垃圾收集活动时序图:

整个 G1 的垃圾收集活动,大致可以分为以下几个时期:

  • 用户线程运行时期,此时 G1 只有 RSet 维护线程在与用户线程同时运行。RSet 是 Region 之间用于保存相互之间对象引用关系的记忆集,每个 region 都有一个 RSet。每个 RSet 记录的是哪些 Region 对自己的 Region 中的对象存在引用关系。其结构是一个 hashtable,key 是其他 Region 的起始地址,value 是一个集合,里面的元素是其他 Region 中的 Card 的 index。Card 是 Region 内部的堆内存最小可用粒度,512 字节,有一个 Global Card Table 全局卡片表用来记录所有 Region 的 Card。对象会分配在单个或物理连续的多个 Card 上。如果 Region1 的对象 A 引用了 Region2 的对象 B,而对象 A 的起始地址位于 Region1 的某个 card,那么在 Region2 的 RSet 中就会有 Region1 的记录,key 是 Region1 的起始地址,value 集合中包含 Region1 对应 Card 在全局卡片表中的 Index。记忆集主要用于解决可达性分析中的跨 Region 引用的问题,利用 Card 技术可以快速定位到对象。
  • young GC 时期,前面已经梳理,这里不再赘述。
  • 并发标记周期。当老年代的整堆空间占比(IHOP)达到阈值(45%)时,触发并发标记周期。这个周期看起来很像 CMS。它有以下几个阶段:初始标记,收集所有 GC 根及其直接引用,这一阶段是与 young GC 一起完成的,即图中的 Young GC with Initial Mark;并发标记,标记存活对象,并发标记期间可能又会触发 young GC,类似 CMS 中的对应阶段;重新标记,同样类似于 CMS 中的对应阶段,是为了解决并发标记导致的 “由黑变灰” 问题,但 CMS 使用的是增量更新算法,而 G1 用的是原始快照算法;清除阶段,识别回收价值较高的老年代 Region 并加入 CSet,并直接回收已经没有任何存活对象的 Region 使之回到空闲 Region 状态。重新标记与清除阶段都是并行执行的。
  • 混合收集周期,在并发标记周期结束之后,会开始混合收集周期。有两点要注意。第一是混合收集并不会马上开始,而是会先做至少一次 youngGC,因为前面并发标记周期的清除阶段可能已经清除了不少完全没有存活对象的 Region,此时不必着急回收已经进入 CSet 的那些回收价值较高的老年代 Region。第二,混合收集并不是一次性回收 CSet 中所有 region,而是分批收集。每次的收集可能有新生代 region 的收集,可能有老年代 region 的收集。具体采用的算法已经在前面叙述过,这里不再赘述。混合收集次数可以通过不同的 JVM 参数配合控制,其中比较重要的有:-XX:G1MixedGCCountTarget,指定次数目标;-XX:G1HeapWastePercent,每次混合收集后计算该值,达到指定值后就不再启动新的混合收集。

1.关于记忆集与卡表,其实这种设计并非 G1 独有,所有的基于分代收集理论的收集器在解决跨代引用的问题时,都需要使用记忆集与卡表技术。只是 G1 的 RSet 与卡表的设计相对而言更复杂。G1 之前的分代收集器只用考虑新生代和老年代,比如新生代收集时,对应老年代有个卡表,凡是有跨代引用的对象,其卡表中的元素的值会被标记为 1,成为变脏,垃圾收集时通过扫描卡表里变脏的元素就能得出包含跨代引用的 Card,将其中的对象加入 GcRoots 即可。G1 则如前所述,因为 Region 的数量远超出分代数量,因此给每个 Region 设计了一个 RSet,同时还有一个全局卡表。

2.关于 增量更新原始快照,为什么 G1 用 SATB?CMS 用增量更新?

SATB 相对增量更新效率会高(当然 SATB 可能造成更多的浮动垃圾),G1 因为 region 数量远多于 CMS 的分代数量(CMS 就一块老年代区域),重新深度扫描增量引用的根对象的话,G1 的代价会比 CMS 高得多,所以 G1 选择 SATB,等到下一轮 GC 再重新扫描。

G1 的垃圾收集担保

G1 在对象复制/转移失败或者没法分配足够内存(比如巨型对象没有足够的连续分区分配)时,会触发 Full GC,使用的是与 Serial Old 收集器相同的 MSC 算法实现对整堆进行收集。所以一旦触发 Full GC 则会 STW 较长时间,执行效率很低。

Java10 之前是单线程 MSC,Java10 中改进为多线程 MSC

G1 对比 CMS

相比 CMS,G1 有以下优势:

  • Pause Prediction Model 停顿预测模型,支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。用户可以设定整个 GC 过程的期望停顿时间,参数 -XX:MaxGCPauseMillis 指定一个 G1 收集过程目标停顿时间,默认值 200ms,不过它不是硬性条件,只是期望值。G1 是通过停顿预测模型计算出来的历史数据来预测本次收集需要选择的 Region 数量,从而尽量满足用户设定的目标停顿时间。
  • 基于 Region 的内存布局,使得内存分配与回收更加灵活。
  • 按回收价值动态确定回收集,使得老年代的回收更加高效。
  • 与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个 Region 之间)上看又是基于“标记-复制”算法实现。无论如何,这两种算法都意味着 G1 运作期间产生内存空间碎片要少得多,垃圾收集完成之后能提供更多规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次 GC。

从工作时序上看,CMS 在对老年代的回收上,采用了清除算法可以并发执行,而 G1 的老年代回收采用整理算法,会导致 STW。似乎 CMS 的停顿应该少一点。从设计理念上说,采用整理算法的 G1 确实应该是更注重吞吐量而非低延迟的。但由于 G1 采用了很多新的设计思路,特别是停顿预测模型、Region 与回收价值,导致实际上 G1 很容易做到比 CMS 更低的停顿,特别是内存充足的场景。

相比 CMS,G1 的劣势是,无论是内存消耗还是 CPU 负载,G1 都比 CMS 要消耗更多的资源。

例如,由于 Region 分区远比新生代老年代的分区数量多,且 RSet 维护成本更高,导致用户线程运行期间 G1 的 RSet 维护线程要消耗更多的资源。G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20%乃至更多的内存空间,这要比 CMS 多得多。

综上,目前在小内存(4G 以下)应用上,较大概率仍然是 CMS 表现更好;而当内存到达 6G 或 8G 以上的时候,G1 的优势就明显起来了。但随着 G1 越来越成熟,尤其是 Java9 以后,无脑使用 G1 是一个没啥大问题的选择。

事实上,Oracle 对 G1 的定位就是 Fully-Featured Garbage Collector,全功能的垃圾收集器。

经典垃圾收集器小结

收集器 执行方式 新生代 or 老年代 算法 关注点 适用场景
Serial 串行 新生代 标记复制 响应速度优先 单 CPU 环境下的 Client 模式
Serial Old 串行 老年代 标记整理 响应速度优先 单 CPU 环境下的 Client 模式,CMS 与 G1 的后备方案
ParNew 并行 新生代 标记复制 响应速度优先 多 CPU 环境时在 Server 模式下与 CMS 配合
Parallel Scavenge 并行 新生代 标记复制 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记整理 吞吐量优先 在后台运算而不需要太多交互的任务,与 Parallel Scavenge 配合
CMS 并发 老年代 标记清除 响应速度优先 集中在互联网站或 B/S 系统的与用户交互较多的服务端上的 Java 应用
G1 并发 两者 标记-整理+复制 响应速度优先 面向服务端应用,用来替代 CMS

低延迟垃圾收集器

衡量一款垃圾收集器有三项最重要的指标:

  • 内存占用(Footprint)
  • 吞吐量(throughput)
  • 延迟(Latency)

这三者大约是一个不可能三角,一款优秀的收集器通常可以在其中一到两项上做到很好。而随着硬件的发展,这三者中,延迟的重要性越来越凸显出来。原因有三:一是随着内存越来越大还越来越便宜,我们越来越能容忍收集器占用多一点的内存;二是硬件性能的增长,比如更快的 CPU 处理速度,这对软件系统的处理能力是有直接提升的,它有助于降低收集器运行时对应用程序的影响,换句话说,JVM 吞吐量会更高;三,与前两者相反,有些硬件提升,特别是内存容量的提升,并不会直接降低延迟,相反,它会带来负面效果,更多的内存回收必然使得回收耗时更长。

因此,在当下,垃圾收集器的主要目标就是低延迟。对于 HotSpot 而言,目前有两款转正不久的低延迟垃圾收集器:ShenandoahZGC。它们在低延迟方面与之前梳理过得经典垃圾收集器比较如下:

从上图可以看出垃圾回收器的发展趋势:

  1. 尽量增加并发,以减少 STW。G1 目标是 200ms,但实际只能做到四五百 ms 上下,Shenandoah 目前可以做到几十 ms 以内,ZGC 就牛逼了,10ms 以内。
  2. 尽量减少空间碎片,以保证吞吐量。少用标记清除算法,事实上除了 CMS 也没谁用。

除了 Parallel,从 CMSG1,再到 ShenandoahZGC,都是在想办法并发地完成标记与回收,以达到降低延迟的目的。同时为了尽可能保证吞吐量,在回收阶段也尽量使用整理算法而不是清除算法。

继 G1 之后,ShenandoahZGC 的目标就是,在尽可能对吞吐量影响不太大的前提下,实现任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。即:大内存,低延迟。

ShenandoahZGC 在 Java15 中都已经成为正式特性(但默认 GC 还是 G1),下面简单梳理一下 ShenandoahZGC 的特点,它们的关键都在于,如何实现并发整理。

Shenandoah 收集器

Shenandoah 收集器在技术上可以认为是 G1 的下一代继承者。但它不是由 Oracle 主推发展的收集器,它是由 RedHat 公司主推的,属于 OpenJDK 的特性,而非 OralceJDK 的特性。它在 OpenJDK12 中引入成为实验特性,在 OpenJDK15 中成为正式特性。

Shenandoah 与 G1 一样,使用基于 Region 的堆内存布局,有用于巨型对象存储的 Humongous Region,有基于回收价值的回收策略,在初始标记、并发标记等阶段的处理思路上高度一致,甚至直接共享了一部分实现代码。这使得 G1 的一些改善会同时反映到 Shenandoah 上,Shenandoah 的一些新特性也会出现在 G1 中。例如 G1 的收集担保 Full GC,以前是单线程的 MSC,就是由于合并了 Shenandoah 的代码,才变为并行的多线程 MSC

Shenandoah 相比 G1,主要有以下改进:

  1. 回收阶段支持并发整理算法;
  2. 不支持分代收集理论,不再将 Region 区分为新生代和老年代;
  3. 不使用 RSet 记忆集,改为全局的连接矩阵,连接矩阵就是一个二维表,RegionN 有对象引用 ReginM 的对象,就在二维表的 N 行 M 列上打钩。

在大的流程上,因为不再基于分代收集理论,Shenandoah 并没有所谓 YoungGCOldGCMixedGC。它的主要流程类似 G1 的并发标记周期和混合收集周期:

  • 初始标记:标记与 GCRoots 直接关联的对象,短暂 STW。
  • 并发标记:与 G1 类似,并发执行,标记可达对象。
  • 重新标记:使用 SATB 原始快照算法重新标记,并统计出各个 Region 的回收价值,将最高的 Regions 组成一个 CSet,短暂 STW。
  • 并发清理:将没有任何存活对象的 Region 直接清空。
  • 并发回收:从这里开始,就是 Shenandoah 和 G1 的关键差异,Shenandoah 先把回收集里面的存活对象先复制一份到其他未被使用的 Region 中,并利用转发指针、CAS 与读写屏障等技术来保证并发运行的用户线程能同时保持对移动对象的访问(※1)。
  • 初始引用更新:并发回收阶段复制对象结束后,需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。初始引用更新 这个阶段实际上并未做具体的更新处理,而是建立一个线程集合的时间点,确保所有并发回收阶段中的 GC 线程都已完成分配给它们的对象复制任务而已。初始引用更新时间很短, 会产生一个非常短暂的 STW。
  • 并发引用更新:开始并发执行引用更新,找到对象引用链中的引用所在,将旧的引用地址改为新的引用地址。
  • 最终引用更新:更新 GCRoots 中的引用。
  • 并发清理:回收 CSet 中的 Regions。

※1 实现并发整理的关键技术:转发指针、CAS 与读写屏障

转发指针,Brooks PointerBrooks 是一个人的名字,转发指针技术由其提出。该技术在原有对象布局结构的最前面统一增加一个新的引用字段, 在正常不处于并发移动的情况下, 该引用指向对象自己;当对象被复制,有了一份新的副本时,只需要修改旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作。而当引用地址全部被更新之后,旧对象就不会再被访问到,转发指针不再由永无之地,随着旧对象一起被释放。

当然,使用转发指针会有线程安全问题。比如 GC线程复制对象用户线程对旧对象执行写操作 这两个动作如果同时发生,就有可能出现线程安全问题。Shenandoah 在这里是采用 Compare And Swap CAS 技术来保证线程安全的。

CAS 是一种乐观锁,比较并替换。

另外,在对象被访问时触发转发指针动作,需要使用读写屏障技术,在对象的各种读写操作(包括读,写,比较,hash,加锁等等)上做一个拦截,类似 AOP。

注意,这里的读写屏障并不是 JMM 中的内存屏障。内存屏障类似同步锁,是多线程环境下工作线程与主存之间保证共享变量的线程安全的技术。
事实上,在之前介绍的其他收集器中,已经利用到了写屏障技术,比如 CMS 与 G1 中的并发标记等。Shenandoah 不仅要用写屏障,还要用读屏障,这是它之前的性能瓶颈之一,但在 Java13 中得到了改善,改为使用 Load Reference Barrier,引用访问屏障,只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置屏障所带来的消耗。

目前,Shenandoah 在停顿时间上与 G1 等经典收集器相比有了质的飞跃,已经能够做到几十毫秒;但一方面还没有达到预期的 10ms 以内,另一方面却引起了吞吐量的明显下降,尤其是和 Parallel Scavenge 相比。

ZGC 收集器

Z Garbage Collector,简称 ZGC。ZGC 是 Oracle 主推的下一代低延迟垃圾收集器,它在 Java11 中加入 JVM 作为实验特性,在 Java15 中成为正式特性。目前还不是默认 GC,但很有可能成为继 G1 之后的 HotSpot 默认垃圾回收器。

ZGC 虽然目标和 Shenandoah 一样,都是把停顿时间控制在 10ms 以内,但它们的思路却完全不一样。就目前而言,ZGC 基本已经做到了,而 Shenandoah 还需要继续改善。

ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。其主要特点如下:

  1. 动态的 Region 布局
  2. 回收阶段支持更巧妙的并发整理

1. 内存布局

ZGC 的 Region 是动态创建和销毁的,且大小不是全部相同的。在 X64 平台下,ZGC 的 Region 有大、中、小三个容量:

  • 小型 Region,容量固定为 2M,用于存放小于 256K 的小对象。
  • 中型 Region,容量固定为 32M,用于存放[256K~4M)的对象。
  • 大型 Region,容量不固定,但必须是 2M 的整数倍,用于存放 4M 及以上的大对象。每个大型 Region 只会存放一个大对象,这意味着会有小于中型 Region 的大型 Region,比如最小的大型 Region 只有 4M,就比 32M 的中型 Region 小。大型 Region 在 ZGC 中是不会被移动的。

2. 更巧妙的并发整理

ZGC 的运行阶段大致如下(初始标记什么的就不写了):

  • 并发标记:对所有 Regions 做并发的可达性分析,但标记的结果记在引用地址的固定位置上。这个技术叫染色指针技术(※1)。
  • 并发预备重分配:根据标记结果将所有存活对象所属的 Region 计入重分配集 Relocation Set
  • 并发重分配:并发地将重分配集中的存活对象复制到空闲 Region 中,并未重分配集中的每一个 Region 维护一个转发表 Forward Table,记录旧对象到新对象的转发关系。由于染色指针技术的使用,ZGC 仅仅从引用上就能获知该对象是否在重分配集中(存活对象),通过预置好的读写屏障,如果用户线程并发访问了该对象,就会被截获并根据转发表转发到复制的新对象上,同时自动修正引用地址到新对象地址,这种自动修改引用地址的行为被称为 自愈。ZGC 这种设计的好处是,转发只会发生一次,并发期间用户线程再次访问该对象就没有转发的损耗了,不像 Shenandoah 的转发指针技术,在并发期间用户线程每次访问该对象都要转发一下。另外的好处是,由于染色指针技术,重分配集中的存活对象只要被复制完,就可以立即清空这个 Region,只要保留其对应转发表即可。
  • 并发重映射:修正整个堆中对重分配集中旧对象的所有引用。这个阶段并不是一个迫切要完成的阶段,因为在 ZGC 的设立里,引用可以 “自愈”。但这么做还是有好处:释放转发表。因此 ZGC 选择将并发重映射这一步骤放到下一次 GC 的并发标记阶段去完成,这样省下了一次遍历引用链的过程。

※1 染色指针技术

染色指针技术 Colored Pointer 是 ZGC 的标志性设计。一般收集器在可达性分析标记对象的三色状态时,都是标记在对象或与对象相关的数据结构上。而染色指针技术是将三色状态直接标记到引用这个 “指针”,或者说内存地址上。以 64 位的 linux 为例,它支持的内存地址空间去掉保留的高位 18 位不能使用,还剩下 46 位。ZGC 将这剩下的 46 位中的高 4 位拿出来存储 4 个标志信息,包括三色状态,对象是否已经被复制,是否只能通过 finalize 方法访问。

染色指针技术要直接修改操作系统的内存地址,这是需要操作系统和 CPU 的支持的。在 x86-64 平台上,就需要利用到虚拟内存多重映射技术了。

染色指针技术带来的收益:

  1. 一旦重分配集中的某个 Region 的存活对象全部复制结束后,该 Region 能够立即清空,马上就可以拿来分配新对象。这使得 ZGC 可以在空闲 Region 极少的极端情况下依然保证能够完成回收。
  2. 大幅减少在对象上设置读写屏障导致的性能损耗。因为可以直接从指针读到三色标记,是否已被复制等信息。这使得 GC 对用户线程的性能影响减低,即,减少了 ZGC 对吞吐量的影响。
  3. 染色指针是一种可以扩展的技术,比如现在不能使用的高位 18 位,如果开发了这 18 位,ZGC 就不必侵占目前的 46 位,从而扩大支持的堆内存容量,也可以记录一些其他的标志信息。

染色指针技术的劣势:

  1. 不支持 32 位操作系统,因为没有地址空间不够。
  2. 由于把内存地址的高 4 位拿来做染色指针的存储了,所以导致能够管理的内存不能超过 2 的 42 次幂,即 4TB。目前来说,倒是完全够用。大内存的 Java 应用有上百 G 就不得了了。与这点限制相比,它能带来的收益要大得多。

目前 ZGC 的优势还是很明显的,停顿时间方面,已经做到了 10ms 以内,而在 “弱势” 的吞吐量方面,居然也已经基本追平以吞吐量为目标的 Parallel Scavenge。基本上可以认为是完全超越 G1 的。但 ZGC 也有需要权衡的地方,ZGC 没有分代,不能针对新生代那种 “朝生夕灭” 的对象做针对性的优化,这导致 ZGC 能够承受的内存分配速度不会太高。即,在一个会连续的高速的大量的分配内存的场景下,ZGC 每次收集周期都会产生大量浮动垃圾,当回收速度跟不上浮动垃圾产生的速度时,堆中的剩余空间会越来越少,最后可能导致收集失败。目前只能通过加大堆内存的方式缓解这个问题。

其实从 CMS 到 G1,以及 Shenandoah 都有浮动垃圾的问题。但前两者的分代设计基本保证浮动垃圾不会太多,Shenandoah 其实也有类似 YoungGC 的阶段设计去处理大量的新生对象。

其他垃圾收集器

除了以上梳理的 7 种经典垃圾收集器和两种低延迟垃圾收集器,还有一些其他的 GC:

  1. Java11 增加了一个叫 Epsilon 的收集器。它的特点就是对内存的管理是只分配,不回收。用途之一是用于需要剥离垃圾收集器影响的性能与压力测试;另外的用途是那些运行负载极小不需要任何回收的小应用,比如 Function 服务,几秒就执行完的脚本,特点就是跑完就关闭 JVM。
  2. Azul 的 Pauseless GC,简称 PGC,和 Concurrent Continuously Compacting Collector,简称 C4。它们大约相当于 ZGC 的同胞前辈,但它们都是商用 VM 的 GC,早就做到了标记和整理的全程并发。PGC 运行在 Azul VM 上,C4 运行在 Zing VM 上,且 C4 支持分代。从技术上讲,PGC、C4、ZGC 一脉相承。目前 ZGC 相当于 PGC,由于技术复杂性的原因,还没有支持分代。但未来应该也会考虑做到 C4 那样支持分代。
  3. OpenJDK 现在除了 HotSpot 虚拟机,还支持 OpenJ9 虚拟机,它有自己的垃圾收集器如 ScavengerConcurrent MarkIncremental Generational 等等,不了解。

收集器的选择及相关参数

收集器的选择

考虑垃圾收集器的选择时,需要考虑以下事项:

  1. 对于不可能三角(内存占用/吞吐量/延迟),应用的主要功能更关注什么?比如一个 OLAP 系统,它的大部分功能是各种数据分析和运算,目标是尽快完成计算任务给出结果,那么吞吐量就是主要关注点。而一个 OLTP 系统,它需要与用户频繁交互,用户频繁地通过页面操作录入数据,那么延迟就是主要关注点。而如果是一个客户端应用或者嵌入式应用,那么内存占用就是主要关注点。
  2. 应用运行的基础设施如何?包括但不限于 CPU 核数,内存大小?操作系统是 Linux 还是 Windows 等等。
  3. 使用的 JDK 是什么版本?9 之前还是 9 以后?

简单列一个表格,可以参考该表格选择垃圾收集器:

垃圾收集器 关注点 硬件资源 操作系统 JDK 版本 优势 劣势
ZGC 低延迟 大内存,很多核 64 位系统 JDK15 以上 延迟真低 内存消耗大,需要足够的 JVM 调试能力
Shenandoah 低延迟 大内存,很多核 - openJDK15 以上 延迟很低 内存消耗大,需要足够的 JVM 调试能力
G1 低延迟 4G 以上内存,多核 - JDK9 以上 延迟相对较低,技术成熟 内存占用相对较多
ParNew+CMS 低延迟 4G 以下内存,多核 - JDK9 之前 延迟相对较低,技术成熟 已经被抛弃,更大的堆内存比不上 G1
Parallel(Scavenge+Old) 吞吐量 - - - 吞吐量高 延迟较高
Serial+Serail Old 内存占用 - - - 内存占用低,CPU 负荷低 延迟高,吞吐量低

简单总结一下就是,主流的,就做一个普通的 JavaWeb,或者微服务中的后台服务,与用户交互较多,那就根据 Java 版本来,不是 G1 就是 ParNew+CMS

  • Java 版本在 9 及 9 以上,那就无脑选择 G1,默认即可;
  • Java 版本还是 8 甚至更老,那就 ParNew+CMS;

有点追求的,根据情况来:

  • 有足够的 JVM 调优能力,且对低延迟有追求,可以尝试 ZGC 或 Shenandoah;
  • 主要做后台单纯数据运算的,比如 OLAP,可以尝试 Parallel(Scavenge+Old)
  • C/S 架构的客户端应用,或者嵌入式应用,可以尝试 Serial+Serail Old

GC 日志参数

Java9 之前,HotSpot 没有统一日志框架,参数比较混乱;Java9 之后,所有日志功能都归纳到了 -Xlog 参数上。

Java9 的 JVM 日志使用方式:

1
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]

说明:

  • selector:选择器,由标签 tag 和日志级别 level 组成。标签是功能模块,它指定输出哪个功能模块的日志,GC 日志的话,标签就是 gc;日志级别从低到高有:TraceDebugInfoWarningErrorOff
  • decorators:修饰器,指定每行日志的额外输出内容,包括:time,当前时间戳;uptime,VM 启动到现在的秒数;timemillis,当前时间毫秒数;uptimemillis,VM 启动到现在的毫秒数;timenanos,当前时间纳秒数;uptimenanos,VM 启动到现在的纳秒数;pid,进程 ID;tid,线程 ID;level,日志级别;tags,日志标签。默认是 uptimeleveltags

参考:Java9 前后 GC 相关日志参数对比

功能 Java9 及之后 Java9 之前
查看 GC 基本信息 -Xlog:gc -XX:+PrintGC
查看 GC 详细信息 -X-log:gc* -XX:+PrintGCDetails
查看 GC 前后空间变化 -Xlog:gc+heap=debug -XX:+PrintHeapAtGC
查看 GC 中并发时间和停顿时间 -Xlog:safepoint -XX:+Print-GCApplicationConcurrentTime-XX:+PrintGCApplicationStoppedTime
查看 GC 自适应调节信息 -Xlog:gc+ergo*=trace -XX:+PrintAdaptive-SizePolicy
查看 GC 后剩余对象年龄分布 -Xlog:gc+age=trace -XX:+PrintTenuring-Distribution

还有一个 -verbose:gc 参数,功能与 -Xlog:gc 以及 -XX:+PrintGC 一样。

Java8 的 GC 日志的查看,请参考文章:

java8 添加并查看 GC 日志(ParNew+CMS)

G1 相关 JVM 参数配置

G1 与 Parallel Scavenge 一样,支持自适应调节的内存管理,所以 G1 调优相对简单,指定目标停顿时间之后,尽量避免出现 Full GC 即可。

所以首先,不要在代码里出现 System.gc() 这么险恶的显式请求 GC。
该方法会请求 JVM 做一次 Full GC!而不是 G1 的 youngGC 或 MixedGC!虽然 JVM 不一定答应,答应了也不知道啥时候做。但这么做很大概率会导致 JVM 多做一次 Full GC!要记住,Full GC 是全程 STW 的,它是降低 JVM 性能表现的第一杀手!除非你的 application 的主要是利用堆外的直接内存(Java 的 NewIO 提供的功能)做一些类似大文件拷贝的业务,否则不要使用 System.gc()
可以使用 -XX:+DisableExplicitGC 禁止显示调用 GC。

不考虑 System.gc() 的情况下,G1 出现 Full GC 主要是由于由于晋升或大对象导致老年代空间不够了。所以 G1 调优的主要方向包括:

  • 增加堆大小,或适当调整老年代和年轻代比例。这样可以直接增加老年代空间,内存回收拥有更大的余地。
  • 适当增加并发周期线程数量,充分发挥多核 CPU 性能。目的是减少并发周期执行时间,从而加快回收速度。
  • 让并发周期尽早开始,更改 IHOP 阈值(默认 45%)。
  • 在混合收集周期回收更多老年代 Region。

G1 常用参数:

  • -XX:+UseG1GC:使用 G1 收集器,Java9 后默认使用。
  • -XX:MaxGCPauseMillis=200:指定停顿期望时间,默认 200ms。该值是期望值,G1 会根据该值自动调整内存管理的相关参数。
  • -XX:InitiatingHeapOccupancyPercent=45:IHOP 阈值,老年代 Regions 数量达到该值时,触发并发标记周期。
  • -XX:G1MixedGCLiveThresholdPercent=n:如果一个 Region 中的存活对象比例超过该值,就不会被挑选为垃圾分区。
  • -XX:G1HeapWastePercent:混合收集周期中,每次混合收集后计算该值,可回收比例小于该值后就不再启动新的混合收集。
  • -XX:G1MixedGCCountTarget=n:混合收集周期次数目标值,默认 8。
  • -XX:G1OldCSetRegionThresholdPercent:混合收集周期中,每次混合收集的最大老年代 Regions 数量,默认 10。
  • -XX:NewRatio=n:老年代/新生代的 Regions 比例,不要设置这个参数,否则停顿期望时间将失效,G1 最大的优势 停顿预测模型 将停止工作。
  • -XX:SurvivorRatio=n:新生代中 eden 与 survivor 区域的比例,默认 8,即 8 个 eden 区域对应 2 个 survivor 区域。
  • -XX:MaxTenuringThreshold =n:新生代晋升老年代的年龄阈值,默认 15。
  • -XX:ParallelGCThreads=n:并行收集时的 GC 线程数,不同平台默认值不同。
  • -XX:ConcGCThreads=n:并发标记时的 GC 线程数,默认是 ParallelGCThreads 的四分之一。
  • -XX:G1ReservePercent=n:堆内存的预留空间百分比,默认 10,即默认地会将 10% 的堆内存预留下来,用于降低老年代空间不足的风险。
  • -XX:G1HeapRegionSize=n:单个 Region 大小,取值区域为 [1M~32M],必须是 2 的次幂。默认根据堆空间大小自动计算,切成 2048 个。

G1 调优建议:

  1. 不要自己显式设置新生代的大小(-Xmn-XX:NewRatio),如果显式设置新生代的大小,会导致停顿期望时间这个参数失效。
  2. 由于 停顿预测模型 的存在,调优时应该首先调整 -XX:MaxGCPauseMillis 参数来让 G1 自动调整达到目标,其他参数先不要手动设置。调整期望时间主要是找到平衡点:太大当然不行,直接增加了停顿时间;但太小的话,则意味着新生代变小,youngGC 频率上升,也会减小混合收集周期中每次混合收集的 Region 数量,可能反而会导致老年代不能尽快回收从而发生 FullGC。指定该值时,应该作为 90%期望值,而不是平均值。
  3. 如果再怎么调整 -XX:MaxGCPauseMillis 参数都还是有 Full GC 发生,那么可以尝试手动调整:
  • 适当增加 -XX:ConcGCThreads=n 并发标记时的 GC 线程数,目的是加快并发标记速度,不能增加太多,会影响用户线程执行,降低吞吐量。
  • 适当降低 -XX:InitiatingHeapOccupancyPercent=45,适当降低该值可以提前触发并发标记周期,从而一定程度上避免老年代空间不足导致的 Full GC。但这个值如果设置得过小,又会导致 G1 频繁得进行并发标记与混合收集,会增加 CPU 负荷,降低吞吐量。通过 GC 日志可以判断该值是否合适:在一轮并发周期结束后,需要确保堆的空闲 Region 的比例小于该值。
  • 调整 G1 垃圾收集器的混合收集的工作量,即在一次混合垃圾收集中尽量多处理一些 Region,可以从另外一方面提高混合垃圾收集的效率。例如:适当调大 -XX:G1MixedGCLiveThresholdPercent=n,这个参数的值越大,某个 Region 越容易被识别为回收价值高;适当减小 -XX:G1MixedGCCountTarget=n,减小这个值,可以增加每次混合收集的 Region 数量,但是可能会导致停顿时间过长;

更多参数调优请参考资料:

Garbage First Garbage Collector Tuning

CMS 相关 JVM 参数配置

CMS 基本已被淘汰,这里给出一些生产上常用的参数:

1
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -XX:+CMSParallelRemarkEnabled -XX:+CMSScavengeBeforeRemark

其中:

  • -XX:+UseParNewGC : 指定新生代使用 ParNew 垃圾回收器。
  • -XX:+UseConcMarkSweepGC : 指定老年代使用 CMS 垃圾回收器。
  • -XX:+UseCMSInitiatingOccupancyOnly:使用设定的 CMSInitiatingOccupancyFraction 阈值。
  • -XX:CMSInitiatingOccupancyFraction : CMS 触发阈值,当老年代内存使用达到这个比例时就触发 CMS。
  • -XX:+CMSParallelRemarkEnabled : 让 CMS 采用并行标记的方式降低停顿。
  • -XX:+CMSScavengeBeforeRemark:在 CMS GC 前先启动一次 youngGC,目的在于减少跨代引用,降低重新标记时的开销

内存分配策略

对象优先在 Eden 分配,大对象直接进入老年代

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作

注意 -XX:PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款新生代收集器有效,HotSpot 的其他新生代收集器,如 Parallel Scavenge 并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew 加 CMS 的收集器组合。

长期存活的对象将进入老年代

HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度 (默认为 15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold 设置。

(对于这个对象年龄的设置,一般情况下,不要去做任何变更。如果我们的年轻代里边的新生对象大部分都是存活 1 岁,只有特别小的部分会存活时间很长,并且程序长时间运行的时候,这部分大对象所占用的空间保持不变,或者说,变化很小。可以考虑适当增大这个年龄。)

为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold 中要求的年龄

空间分配担保策略及垃圾回收过程

[空间分配担保策略 及 垃圾回收 Eden-s0-s1- 老年代请参见:](https://www.bilibili.com/video/BV1or4y1v7tB?spm_id_from = 333.999.0.0&vd_source = 1e3ae6eb163d710d4a73f4de6f0f3a54)

在发生 Minor GC 之前,虚拟机必须检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么这一次 Minor GC 可用确保是安全的。

如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure 参数的设置值是否允许担保失败(HandlePromotionFailure):

如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小

如果大于,则将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;

如果小于,或者-XX:HandlePromotionFailure 参数设置不允许冒险,那这时就要改为进行一次 Full GC。

年轻代:我要进行 MGC

老年代:稍等,我去检查一下最大可用的连续空间,是否能够容纳你所有的对象

年轻代:为什么要检查所有的对象呢?

老年代:我不知道你最后剩余多少对象存活,为了确保万无一失,我要检查你所有的对象大小

老年代:如果我的空间足够容纳你所有的对象,这就是一次安全的 MGC

老年代:如果空间不足,我去检查-XX:HandlePromotionFailure 参数,如果这个参数开启,那就检查历次晋升到老年代对象的平均大小。如果能够容纳,就开始一次有风险的 MGC,如果仍然不能够容纳这个平均值,就 FGC,如果参数-XX:HandlePromotionFailure 参数是关闭的,则直接 FGC

总结:建议开启-XX:HandlePromotionFailure 参数。可以一定程度上减少 FGC 的次数

参考

https://www.cnblogs.com/DarkSki/category/2255250.html