Toc
  1. 一、什么是 GC
    1. 1、需要 GC 的内存区域
    2. 2、GC 的对象以及判断方法
      1. 引用计数
      2. 可达性分析(Reachability Analysis)
      3. 两次标记与 finalize()方法
    3. 3、什么时候触发 GC
    4. 4、GC 做了什么事
  2. 二、GC 常用算法
    1. 1. 标记 - 清除算法
    2. 2. 标记 - 压缩(整理)算法
    3. 3. 复制算法
    4. 4. 分代收集算法
  3. 三、垃圾收集器
    1. 新生代收集器
      1. 1. Serial 收集器
      2. 2. ParNew 收集器
      3. 3. Parallel Scavenge 收集器
    2. 老年代收集器
      1. 1. Serial Old 收集器
      2. 2. Parallel Old 收集器
      3. 3. CMS 收集器
    3. G1 收集器
      1. 横跨整个堆内存
      2. 建立可预测的时间模型
      3. 避免全堆扫描 —— Remembered Set
  4. 四、总结
Toc
0 results found
关于 GC
2020/05/21 Java Java JVM GC

GC(Garbage Collect)是 Java 虚拟机最著名的一个机制——内存回收

我们按着下面的流程来讲述 GC:

  1. 什么是 GC
  2. GC 常用算法
  3. 垃圾收集器是什么
  4. finalize()方法详解
  5. 总结

一、什么是 GC

在我们的开发工作中,几乎所有人都遇到过内存溢出的情况。因为程序运行时,内存空间是有限的,那么如何及时的把不再使用的对象清除将内存释放出来,这就是 GC 要做的事。

理解 GC 机制就从:『GC 的区域在哪里』,『GC 的对象是什么』,『GC 的时机是什么』,『GC 做了哪些事』几方面来分析。

1、需要 GC 的内存区域

我们先来从一个较高的维度来看 JVM 与系统调用之间的关系:

由上图可见,JVM 中,虚拟机栈、本地方法栈和程序计数器因为是 线程私有 (也即线程隔离),都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了 自动的内存清理 。因此,我们的内存垃圾回收主要集中于 java 堆和方法区 中,在程序运行期间,这部分内存的分配和使用都是动态的。

并且,JVM 运行时的数据区大致可以分为 5 个部分:

  1. 方法区

    这个区域 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。JVM 运行时,Class Loader 将会提取 class 文件里面的类信息,并将其存放在方法区中。例如类的名称、类的类型(枚举、类、接口)、字段、方法等等。

  2. 堆( Heap)

    熟悉 c/c++ 编程的同学们应该相当熟悉 Heap 了,而对于 Java 而言,对大多数应用来说,堆是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被 所有线程共享 的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是 存放对象实例 ,几乎所有的对象实例都在这里分配内存。 每个应用都唯一对应一个 JVM 实例 ,而 每一个 JVM 实例唯一对应一个堆。堆由 JVM 的自动内存管理机制所管理,也即本文的主角 —— GC(Garbage Collection)。

  3. 虚拟机栈(VM Stack)

    操作系统内核 为某个进程或者线程建立的存储区域 ,是线程私有的, 它的生命周期与线程相同 。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。 每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

  4. 本地方法栈

    用来调用其余语言的本地方法,为虚拟器使用到的 native 方法服务,例如 C/C++ 写的本地代码,这些方法在本地方法栈中执行,而不会在 Java 栈中执行。

  5. 程序计数器

    线程私有 。有时也叫『PC 寄存器(PC Register)』,是一块较小的内存空间,它可以看作是当前线程所执行的字节码的 行号指示器,用于存放下一条指令的地址,每一个线程都有一个程序计数器。

    小知识
    JVM 中,只有程序计数器部分不会出现 OOM。

在 JVM 中绝大部分对象都是英年早逝的,在编码时大部分堆中的内存都是短暂临时分配的,所以无论是效率还是开销方面,随着分配的对象增多,GC 的时间与开销将会越来越大。所以,JVM 的内存被分为了三个主要部分:新生代,老年代和永久代

  • 新生代

    所有新产生的对象一律都在新生代中, Eden 区保存最新的对象,有两个 Survivor Space —— S0 和 S1,三个区域的比例大致为 Eden:S0:S1 = 8:1:1。当 Eden 区满时,触发 Minor GC。Minor GC 是一种 Stop the World 事件,在回收时会暂停当前线程正在做的所有事情。

  • 老年代

    老年代用来存储存活时间较长的对象,老年代区域的 GC 是 Major GC,老年代中的内存不够时,就会触发一次。这也是一个 Stop the World 事件,但是看名字就知道,这个回收过程会相当慢,由于这 包括了对新生代和老年代所有对象的回收,也叫 Full GC。

  • 永久代(JDK1.7 及之前)

    永久代位于方法区,主要存放元数据,例如 Class、 Method 的元信息,与 GC 要回收的对象其实关系并不是很大,我们可以几乎忽略其对 GC 的影响。除了 JavaHotSpot 这种较新的虚拟机技术,会回收无用的常量和的类,以免大量运用反射这类频繁自己设置 ClassLoader 的操作时方法区溢出。

2、GC 的对象以及判断方法

需要进行 GC 的对象就是已经没有存活的对象,判断一个对象是否存活常用的有这几种办法:引用计数 可达性分析 两次标记 finalize()

引用计数

引用计数算法是在 JVM 中被摒弃的一种对象存活判定算法,不过它也有一些知名的应用场景(如 Python、FlashPlayer),因此在这里也简单介绍一下。

用引用计数器判断对象是否存活的过程是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加 1;当引用失效时,计数器减 1;任何时刻计数器为 0 的对象就是不可能再被使用的 。此方法简单,但 无法解决对象相互循环引用的问题

比如下面的代码:

public class ReferenceCountingGC {

public Object instance = null;

private static final int _1MB = 1024 * 1024;

/** * 这个成员属性的唯一意义就是占点内存,以便在能在 GC 日志中看清楚是否有回收过 */
private byte[] bigSize = new byte[2 * _1MB];

public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

// 假设在这行发生 GC,objA 和 objB 是否能被回收?
System.gc();
}
}

在上面这段代码中,对象 objA 和对象 objB 都有字段 instance,赋值令objA.instance = objB;objB.instance = objA;,除此之外,这两个对象再无引用。如果 JVM 采用引用计数算法来管理内存, 这两个对象不可能再被访问 ,但是他们 互相引用着对方 ,导致它们 引用计数不为 0,所以引用计数器无法通知 GC 收集器回收它们。

而事实上执行这段代码,objAobjB 是可以被回收的,下面一节将介绍 JVM 实际使用的存活判定算法。

可达性分析(Reachability Analysis)

在主流商用程序语言的实现中,都是通过可达性分析(tracing GC)来判定对象是否存活的。此算法的基本思路是:通过一系列的称为『GC Roots』的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。用下图来加以说明:

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

可以看到,GC Roots 在对象图之外,是特别定义的『起点』,不可能被对象图内的对象所引用。

准确地说,GC Roots 其实不是一组对象,而通常是一组特别管理的指向引用类型对象的指针 ,这些指针是 tracing GC 的 trace 的起点。它们不是对象图里的对象,对象也不可能引用到这些『外部』的指针,这也是 tracing GC 算法不会出现循环引用问题的基本保证。因此也容易得出, 只有引用类型的变量才被认为是 Roots ,值类型的变量永远不被认为是 Roots。只有深刻理解引用类型和值类型的内存分配和管理的不同,才能知道为什么 root 只能是引用类型。

在 Java 语言中,可以做为 GC Roots 的对象包括:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性实体引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 JNI 引用的对象。

可以概括得出,可作为 GC Roots 的节点主要在 全局性的引用 执行上下文 中。要明确的是,tracing gc 必须以当前存活的对象集为 Roots,因此必须选取确定存活的引用类型对象。GC 管理的区域是 Java 堆,虚拟机栈 方法区 本地方法栈 不被 GC 所管理,因此选用这些区域内引用的对象作为 GC Roots ,是不会被 GC 所回收的。其中虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是 GC roots 的一部分。

在 JDK1.2 之后,Java 将引用分为强引用、软引用、弱引用、虚引用 4 种,这 4 种引用强度依次减弱。

两次标记与 finalize()方法

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

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被 第一次标记 并且进行一次筛选,筛选的条件是 此对象是否有必要执行 finalize()方法。当对象没有覆写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为『没有必要执行』。

  2. 如果这个对象被判定为有必要执行 finalize() 方法,那么此对象将会放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的『执行』是指虚拟机会触发此方法,但并不承诺会等待它运行结束,原因是:如果一个对象在 finalize() 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致 F-Queue 队列中的其它对象永久处于等待,甚至导致整个内存回收系统崩溃。

    finalize()方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 队列中的对象进行 第二次小规模的标记 。如果对象想在finalize() 方法中成功拯救自己,只要 重新与引用链上的任何一个对象建立关联即可 ,例如把 自己(this 关键字)赋值给某个类变量或者对象的成员变量,这样在第二次标记时它将被移出『即将回收』的集合;如果对象这时候还没有逃脱,基本上它就真的被回收了。

值得注意的是,如果代码中有两段一模一样的代码段,执行结果却是一次逃脱成功,一次失败。这是因为任何一个对象的 finalize() 方法都只会被系统调用一次,如果对象面临下一次回收,它的 finalize() 方法不会再被执行,因此第二次逃脱行动失败。

需要说明的是,使用 finalize() 方法来『拯救』对象是不值得提倡的,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做的一个妥协。它的 运行代价高昂,不确定性大,无法保证各个对象的调用顺序 finalize() 能做的工作,使用 try-catch-finally 或者其它方法都更适合、及时,所以兔子建议大家可以忘掉此方法的存在。

3、什么时候触发 GC

  • 程序调用 System.gc() 时可以触发
  • 系统自身来决定 GC 触发的时机(根据 Eden 区和 From Space 区的内存大小来决定。当内存大小不足时,则会启动 GC 线程并停止应用线程)。

上文提到了 Minor GC 和 Full GC,这两种 GC 触发的条件也不太相同:

  • Minor GC 触发:当 Eden 区满时触发
  • Full GC 触发
    • 调用 System.gc() 时,系统建议执行 Full GC,但是不必然执行
    • 老年代空间不足
    • 方法区空间不足
    • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
    • 由 Eden 区、S0 区向 S1 区复制时,对象大小大于 S1 可用内存,则把该对象转存到老年代,且该对象大小大于老年代的可用内存

4、GC 做了什么事

主要做了清理对象,整理内存的工作。Java 堆分为 新生代 老年代,采用了不同的回收方式。(回收方式即回收算法详见后文)

二、GC 常用算法

GC 常用算法有:标记 - 清除算法 标记 - 压缩算法 复制算法 分代收集算法

目前主流的 JVM(HotSpot)采用的是分代收集算法。

1. 标记 - 清除算法

为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。如图所示:

  • 优点 :标记—清除算法中 每个活着的对象的引用只需要找到一个即可,找到一个就可以判断它为活的。此外,更重要的是,这个算法并不移动对象的位置。
  • 缺点 :效率比较低(递归与全堆对象遍历)。每个活着的对象都要在标记阶段遍历一遍;所有对象都要在清除阶段扫描一遍,因此算法复杂度较高。没有移动对象,导致可能 出现很多碎片空间无法利用 的情况。

2. 标记 - 压缩(整理)算法

标记 - 压缩 标记 - 清除 的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记 - 整理的目的。如图所示:

左边是标记阶段,右边是整理之后的状态。可以看到,该算法不会产生大量碎片内存空间。

  • 优点:该算法不会像标记 - 清除算法那样产生大量的碎片空间。
  • 缺点:如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。

3. 复制算法

该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。如图所示:

这个算法与标记 - 压缩(整理)算法的区别在于,该算法不是在同一个区域复制,而是将所有存活的对象复制到另一个区域内。

  • 优点:实现简单;不产生内存碎片
  • 缺点:每次运行,总有一半内存是空的,导致可使用的内存空间只有原来的一半。

4. 分代收集算法

现在的虚拟机 GC 大多采用这种方式,它根据对象的生存周期,将堆分为新生代(Young Generation)和老年代(Tenured Generation)。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用 复制算法 。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用 标记 - 整理 或者 标记 - 清除

新生代又分为 Eden 区、From Survivor 0(S0)区、To Survivor 1(S1)区。

老年代不分区。

如下图所示:

当系统创建一个对象的时候,总是在 Eden 区操作,当这个区满了,那么就会触发一次 Young GC,也就是年轻代的垃圾回收。一般来说这时候不是所有的对象都没用了,所以就会把还能用的对象复制到 S0 区。

Minor GC 与 Young GC 并不相同。Minor GC 只负责 Eden 区,而 Young GC 负责 Eden + S0 + S1 三个区

这样整个 Eden 区就被清理干净了,可以继续创建新的对象,当 Eden 区再次被用完,就再触发一次 Young GC。然后呢,注意,这个时候跟刚才稍稍有点区别。这次触发 Young GC 后,会将 Eden 区还在被使用的对象与 S0 区还在被使用的对象复制到 S1 区。

再下一次 Young GC 的时候,则是将 Eden 区还在被使用的对象与 S1 区中还在被使用的对象复制到 S0 区。

经过若干次 Young GC 后,有些对象在 S0 与 S1 之间来回游荡,每复制一次,年龄就 +1,这时候 S0 区与 S1 区亮出了底线(临界值,由 -XX:MaxTenuringThreshold 来设,HotSpot 虚拟机默认为 15),这些家伙要是到现在还没挂掉,恭喜你们,你们已经长大了,去(复制到)老年代吧。

老年代经过这么几次折腾,也就扛不住了(空间被用完),好,那就来次集体大扫除(Full GC),也就是全量回收。如果 Full GC 使用太频繁的话,无疑会对系统性能产生很大的影响。所以要合理设置年轻代与老年代的大小,尽量减少 Full GC 的操作。

三、垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。

Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求 组合出各个年代所使用的收集器。接下来讨论的收集器基于 JDK1.7 Update 14 之后的 HotSpot 虚拟机(在此版本中正式提供了商用的 G1 收集器,之前 G1 仍处于实验状态),该虚拟机包含的所有收集器如下图所示:

上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。收集器所处的区域,则表示它是属于新生代收集器还是老年代收集器。Hotspot 实现了如此多的收集器,正是因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。

先解释一些下面会用到的概念:

  • 并行和并发

    • 并行(Parallel):指 多条垃圾收集线程并行工作 ,但此时 用户线程仍然处于等待状态
    • 并发(Concurrent):指 用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个 CPU 上。
  • 吞吐量(Throughput)

    吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

下面逐个介绍上面的 7 种收集器。

新生代收集器

1. Serial 收集器

Serial(串行)收集器 是最基本、发展历史最悠久的收集器,它是采用 复制算法 的新生代收集器,曾经(JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。它是一个单线程收集器,只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至 Serial 收集器收集结束为止(也即 Stop the World)。这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说是难以接受的。

下图展示了 Serial 收集器(老年代采用 Serial Old 收集器)的运行过程:

为了消除或减少工作线程因内存回收而导致的停顿,HotSpot 虚拟机开发团队在 JDK 1.3 之后的 Java 发展历程中研发出了各种其他的优秀收集器,这些将在稍后介绍。但是这些收集器的诞生并不意味着 Serial 收集器已经『老而无用』,实际上到现在为止,它 依然是 HotSpot 虚拟机运行在 Client 模式下的默认的新生代收集器。 它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。

在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接受。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

2. ParNew 收集器

ParNew 收集器就是 Serial 收集器的多线程版本。除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与 Serial 收集器完全相同,两者共用了相当多的代码。

ParNew 收集器的工作过程如下图(老年代采用 Serial Old 收集器):

ParNew 收集器除了使用多线程收集外,其他与 Serial 收集器相比并无太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是,除了 Serial 收集器外,目前只有它能和 CMS 收集器(Concurrent Mark Sweep)配合工作,CMS 收集器是 JDK 1.5 推出的一个具有划时代意义的收集器,具体内容将在稍后进行介绍。

ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。在多 CPU 环境下,随着 CPU 的数量增加,它对于 GC 时系统资源的有效利用是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的情况下可使用 -XX:ParallerGCThreads 参数设置。

3. Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是 达到一个可控制的吞吐量(Throughput)

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在 后台运算而不需要太多交互的任务

Parallel Scavenge 收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。

另外值得注意的一点是,Parallel Scavenge 收集器无法与 CMS 收集器配合使用,所以在 JDK 1.6 推出 Parallel Old 之前,如果新生代选择 Parallel Scavenge 收集器,老年代只有 Serial Old 收集器能与之配合使用

Parallel Scavenge 收集器的工作过程如下图:

老年代收集器

1. Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个 单线程 收集器,使用『标记 - 整理』算法。

此收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Serve r 模式下,它还有两大用途:

  • 在 JDK1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CM 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

它的工作流程与 Serial 收集器完全相同。

2. Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和『标记 - 整理』算法。前面已经提到过,这个收集器是在 JDK 1.6 中才开始提供的,在此之前,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old 以外别无选择,所以在 Parallel Old 诞生以后,『吞吐量优先』收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作流程与 Parallel Scavenge 完全相同。

3. CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以 获取最短回收停顿时间 为目标的收集器,它非常符合那些集中在互联网站或者 B/S 系统的服务端上的 Java 应用,这些应用都非常重视服务的响应速度。从名字上就可以看出它是基于『标记 - 清除』算法实现的。

CMS 收集器工作的整个流程分为以下 4 个步骤:

  • 初始标记(CMS initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要 Stop The World。
  • 并发标记(CMS concurrent mark):进行 GC Roots Tracing 的过程,在整个过程中耗时最长。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要 Stop The World。
  • 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到 CMS 46 收集器的运作步骤中并发和需要停顿的时间:

优点 :CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了: 并发收集、低停顿,因此 CMS 收集器也被称为 并发低停顿收集器(Concurrent Low Pause Collector)

缺点

  • 对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量 +3)/ 4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个时(比如 2 个),CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。
  • 无法处理浮动垃圾(Floating Garbage)。可能出现『Concurrent Mode Failure』失败而导致另一次 Full GC 的产生。 由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。 这一部分垃圾出现在标记过程之后,CMS 无法再当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就被称为『浮动垃圾』。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记 - 清除算法导致的空间碎片。CMS 是一款基于『标记 - 清除』算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

G1 收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款 面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点:

  • 并行与并发 G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短『Stop The World』停顿时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行
  • 分代收集 与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象 来获取更好的收集效果。
  • 空间整合 G1 从整体来看是基于『标记 - 整理』算法实现的收集器,从局部(两个 Region 之间)上来看是基于『复制』算法实现的。这意味着 G1 运行期间 不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
  • 可预测的停顿 这是 G1 相对 CMS 的一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

横跨整个堆内存

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老生代,而 G1 不再是这样。G1 在使用时,Java 堆的内存布局与其他收集器有很大区别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但 新生代和老年代不再是物理隔离的了,而都是一部分 Region(不需要连续)的集合

建立可预测的时间模型

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以 有计划地避免在整个 Java 堆中进行全区域的垃圾收集 。G1 跟踪各个 Region 里面的垃圾堆积的 价值大小 (回收所获得的空间大小以及回收所需时间的经验值), 在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

避免全堆扫描 —— Remembered Set

G1 把 Java 堆分为多个 Region,就是『化整为零』。但是 Region 不可能是孤立的,一个对象分配在某个 Region 中,可以与整个 Java 堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个 Java 堆才能保证准确性,这显然是对 GC 效率的极大伤害。

为了避免全堆扫描的发生,虚拟机 为 G1 中每个 Region 维护了一个与之对应的 Remembered Set。虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking) 仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 NTMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的 Region 中创建对象,此阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking) 从 GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking) 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation) 首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

通过下图可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段(Safepoint 处):

用一张表看 7 种收集器的对比:

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

四、总结

根据 GC 的工作原理,我们可以通过一些技巧和方式,让 GC 运行更加有效率,更加符合应用程序的要求。一些关于程序设计的几点建议:

  1. 最基本的建议就是尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为 null。我们在使用这种方式时候,必须特别注意一些复杂的对象图,例如数组,队列,树,图等,这些对象之间有相互引用关系较为复杂。对于这类对象,GC 回收它们一般效率较低。如果程序允许,尽早将不用的引用对象赋为 null,这样可以加速 GC 的工作。

  2. 尽量少用 finalize() 函数。finalize()函数是 Java 提供给程序员一个释放对象或资源的机会。但是,它会加大 GC 的工作量,因此尽量少采用 finalize() 方式回收资源。

  3. 如果需要使用经常使用的图片,可以使用软引用类型(Soft Reference)。它可以尽可能将图片保存在内存中,供程序调用,而不引起 OutOfMemoryError.

  4. 注意集合数据类型,包括数组,树,图,链表等数据结构,这些数据结构对 GC 来说,回收更为复杂。另外,注意一些全局的变量,以及一些静态变量。这些变量往往容易引起悬挂对象(dangling reference),造成内存浪费。

  5. 当程序有一定的等待时间,程序员可以手动执行System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。使用增量式 GC 可以缩短 Java 程序的暂停时间。

打赏
支付宝
微信
本文作者:CodingRabbit
版权声明:本文首发于CodingRabbit的博客,转载请注明出处!