JVM 垃圾回收之常见垃圾回收算法

0、背景

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

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

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

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

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

在堆里存放着几乎所有的 Java 对象实例,自动化的管理内存资源,垃圾回收机制必须要有一套算法来进行计算,其中有一个最重要的问题就是判断哪些是有效的对象,哪些是无效的对象,对于无效的对象就要进行回收处理。

常见计算无效对象的方法有两种,分别是:引用计数算法、可达性分析算法。

1.1、引用计数算法(Reference Counting)

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

1.1.1、原理

引用计数算法比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

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

1.1.2、优缺点

优点:

  1. 实时性较高,能够及时回收不再被引用的对象,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为 0,就可以直接回收;
  2. 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报 outofmember 错误;
  3. 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
  4. 引用计数法的实现非常简单,适用于一些简单的应用场景。它不需要像可达性分析法那样对整个对象图进行搜索,因此在某些情况下可以更高效地回收垃圾。

缺点:

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

引用计数算法,虽然不能解决循环引用问题,但是优点也确实很明显,是很多语言的 GC 选择,例如 Python 更是同时支持引用计数和垃圾收集机制。

Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。那 Python 如何解决循环引用?

  • 手动解除:很好理解,就是在合适的时机,解除引用关系。
  • 使用弱引用 weakref,weakref是 Python提供的标准库,旨在解决循环引用。
  • Python 还有标记清除和分代回收算法来解决循环应用。

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

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

相关代码示范如下:

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
public class CircularReference {

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

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

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

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

1.2、可达性分析算法

可达性分析(也称根搜索算法、追踪性垃圾收集)。相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

相较于引用计数算法,这里的可达性分析就是 Java、C# 选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)。

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

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

1.2.1、GC root

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

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

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

例如:如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虛拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GC Roots 集合中去考虑,才能保证可达性分析的准确性。

GCRoots 集合小技巧:由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。

注意:

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作 必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。

也就是说在分析期间,整个执行系统就像被冻结在某个时间点上,不能在分析过程中对象的引用关系还在不停变化。这点也是导致 GC进行时必须 Stop The World(SWT) 的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

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

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

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

1.2.3、可达性算法与 finalize() 方法

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。Jvm 在回收对象之前会先检查这个对象是否重写了 finalize() 方法,以及这个 finalize() 方法是否已经执行过,待这两个检查都执行完之后才会去清理这个对象,因此我们可以在对象回收之前即在 finalize() 方法中处理一些东西,比如关闭文件、套接字和数据库连接等。

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

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

从功能上说,finalize() 方法与 C++ 中的析构函数比较相似,但是 Java 采用的是基于垃圾回收器的自动内存管理机制,所以 finalize() 方法在本质上不同于 C++ 中的析构函数。

1.2.4、虚拟机的三种可能的状态

由于 finalize() 方法的存在,虚拟机中的对象一般处于 三种可能的状态

如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的3种状态。如下:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活
  • 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize() 只会被调用一次。

以上3种状态中,是由于 finalize()方法的存在,进行的区分。只有在对象 不可触及 时才可以被回收。

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。

  2. 进行筛选,判断此对象是否有必要执行 finalize() 方法

    1. 如果对象 objA 没有重写 finalize()方法, 或者 finalize() 方法已经被虛拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的(直接死)。
    2. 如果对象 objA 重写了finalize()方法,且还未执行过,那么 objA 会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法执行。
    3. finalize() 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize() 方法中与引用链上的任何一一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。
    4. 之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize 方法只会被调用一次。

1.3、引用计数和可达性分析对比

学术界一般观点,引用计数 对程序执行性能的影响比 可达性分析 要大很多。这主要是由于 引用计数 要求所有的指针赋值都要改变计数值,多线程的情况下这个操作还要加锁。而可达性分析只有在用完内存的情况下才需要做,相对指针赋值而言这个频率几乎可以忽略不计,其他时候对程序执行的性能没有影响。所以简单混用的话,相当于一边全盘吃了 引用计数 对性能影响的 debuff,另一边也还是不能避免 STW,并不划算。另一方面,基于 可达性分析 的 GC 现在也有很多手段避免长时间的 STW,当然曾经有很多人做过各种研究,把 引用计数 和其他基于 可达性分析 的 GC 技术结合起来试图达到更好的平衡。不过好像最终并没有什么特别有影响力的结果。

  • 在剩余内存充裕的情况下:

    • tracing GC 的两次 trace 之间的间隔可以比较长。而它并不关心两次 trace 之间对象图到底发生了怎样的变化,只关心每次trace时发现的对象图的引用状况。所以如果在一次trace之后,有一个很大的对象图被创建了出来,然后在下一次trace之前它就死掉了的话,其实tracing GC是根本就不会发现这个对象图曾经存在过,自然也就可以不付出任何代价就回收其空间(例如不需要单独sweep步骤的copying GC)。
    • 引用计数需要跟踪所有引用关系的变化——每次增加引用和减少引用都要反映到引用计数里。同样是上面那种很大的对象图的情况,维持其中的引用计数的开销可以任意大(这些对象死前,相互的引用关系可以发生任意多次的改变)。

    在零开销与没有bounds的开销之间,差别还是很明显的。

  • 在剩余内存紧张的情况下,上述优劣对比就反转了:

    • 由于剩余内存不足,tracing GC 会被迫经常触发收集,每次 trace 都要遍历整个对象图,开销相当大;
    • 而引用计数丝毫不受剩余内存量的影响,该怎么搞还是怎么搞。

Hans Boehm 在 2003 年发了篇论文提醒大家引用计数(朴素和延迟)都有值得关注的开销:

The Space Cost of Lazy Reference Counting

有经验的 C++ 程序员都知道:

  • 当对象的所有权(ownership)不重要时,用裸指针;
  • 当对象的所有权可以唯一确定时,用 unique_ptr。能用 unique_ptr 绝对不要用 shared_ptr
  • 要处理所有权复杂的情况时,可以用 shared_ptr 但不要滥用;当引用关系不影响所有权时,用 weak_ptr

C++ 程序员大都听说过不要滥用 shared_ptr,而 CPython 的现实就像是一个彻底滥用了 shared_ptr 的 C++ 程序一样,连 PyIntObject/PyLongObject 也是引用计数的还有啥好说呢。

CPython 采用的引用计数是最朴素的实现方式:局部变量、全局变量和对象字段都参与到引用计数中,而且引用计数的更新是在锁下同步的;外加朴素的 mark-sweep 备份来处理循环引用。

然而在朴素引用计数的基础上有许多改进的方案。其实只要能让局部变量不参与到引用计数中,程序的吞吐量性能(throughput)就可以有不少提升——这种做法叫做延迟引用计(deferred reference counting,DRC)。这种做法最初在这篇论文提出:

An Efficient, Incremental Garbage Collector

,发表于1976年。近年来还有些进一步优化引用计数实现的办法,例如这两篇论文所总结/创新的:

CPython的实现中,GIL 难以有效的去除的原因之一就是为了迁就引用计数。当一个 Python 线程在运行时,它会获取 GIL 以保证它对对象引用计数的更新是全局同步的。由于引用计数的实现细节被 CPython 的 C API 暴露到了外部的 C 扩展,要保持这些扩展完全兼容,就得维持或模拟 CPython 引用计数的实现,这就麻烦了…

想稍微多了解些的话,请参考 Python 官方 wiki:

GlobalInterpreterLock

参考:为什么 Python 工程师很少像 Java 工程师那样讨论垃圾回收?

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

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

2、垃圾回收算法

当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

目前在 JVM 中比较常见的三种垃圾收集算法是 标记-清除 算法(Mark-sweep)、复制算法(Copying)、标记-压缩 算法(Mark-Compact)。

2.1、标记-清除法(Mark-Sweep)

标记-清除 算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在1960年提出并并应用于 Lisp 语言。

当堆中的有效内存空间(available memory) 被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象(注意:是非垃圾对象)。一般是在对象的 Header 中记录为可达对象。
  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

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

优点:非常基础和常见、易于理解。速度也非常快,适合存活对象多,需要回收的对象少的场景。

缺点:

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

注意:何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址 保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放。

2.2、标记压缩算法(也叫标记-整理、Mark-Compact)

1970 年前后,G.L.Steele、C.J.Chene 和D.S.Wise 等研究者发布 标记-压缩 算法。在许多现代的垃圾收集器中,人们都使用了 标记-压缩 算法或其改进版本。

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

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

标记-压缩 算法的最终效果等同于 标记-清除 算法执行完成后,再进行一次内存碎所整理,因此,也可以把它称为 标记-清除-压缩 (Mark-Sweep-Compact)算法。

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

优点:

  • 消除了 标记-清除 算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点:

  • 效率不高:不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上要低于复制算法,,更低于 标记-清除 算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • 移动过程中,需要全程暂停用户应用程序。即: STW。

2.3、标记复制算法

为了解决 标记-清除 算法在空间碎片和可回收对象如果太多会影响其性能等方面的缺陷,M.L.Minsky 于1963年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CAPLISP Garbage Collector Algorithm Using Serial Secondary Storage )”。M.L.Minsky 在该论文中描述的算法被人们称为 复制(Copying) 算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。

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

研究表明,新生代中的对象大都是“朝生夕死”的,即生命周期非常短而且对象活得越久则越难被回收。在发生 GC 时,需要回收的对象特别多,存活的特别少,因此需要搬移到另一块内存的对象非常少,所以不需要 1:1 划分内存空间。而是将整个新生代按照 8:1:1 的比例划分为三块,最大的称为 Eden(伊甸园)区,较小的两块分别称为 To SurvivorFrom Survivor

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

优点:

  1. 在垃圾对象多的情况下,效率较高。
  2. 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点:

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

在新生代,对常规应用的垃圾回收,一次通常可以回收 70%-99% 的内存空间(新生代对象几乎都是朝生夕死)。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代(年轻代往 To 区,To 区往 From 区)。

2.4、分代算法(Generational Collecting)

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如: String 对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都是采用分代收集(Generational Collecting)算法执行垃圾回收的

在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代(Young Gen)。
    • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 HotSpot 中的两个 survivor 的设计得到缓解(新生代:老年代=1:2,新生代中eden:s0:s1=8:1:1)。
  • 老年代(Tenured Gen)。
    • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
    • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由 标记-清除 或者是 标记-清除标记-整理的混合实现。
      ➢ Mark 阶段的开销与存活对象的数量成正比。
      ➢ Sweep 阶段的开销与所管理区域的大小成正相关。
      ➢ Compact 阶段的开销与存活对象的数据成正比。

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

垃圾回收的相关概念:

1、部分收集(Partial GC)

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。因为新生代的特点,MinorGC 非常频繁,且回收速度比较快,每次回收的量也很大。
  • 老年代收集(Major GC/Old GC/FullGC):指目标只是老年代的垃圾收集,需要注意的是目前只有 CMS 是单独收集老年代。速度比较慢,相对于 MinorGC 慢 10 倍左右。进行一次 FullGC 通常会伴有多次多次 MinorGC
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,需要注意的是目前只有 G1 会有这种行为。

2、整堆收集(Full GC)

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

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

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

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

2.5 总结

除了上述的算法之外,还有增量收集算法、分区收集算法等。这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都是复合算法,并且并行和并发兼备。

3、垃圾回收器

前面讲了垃圾回收的算法,还需要有具体地实现。在 JVM 中,实现了多种垃圾收集器,常见的十种垃圾回收器分别是:Serial、ParNew、ParallelScavenge、SerialOld、ParallelOld、CMS、G1、ZGC、Shenandoah、Epsilon

其中 Epsilon 是 JDK11 提出 debug 使用的,它只负责控制内存分配,但是不执行任何垃圾回收工作,因此本篇文章不再赘述。

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

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

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

  1. 串行:Serial、SerialOld
  2. 并行:ParNew、ParallelScavenge、ParallelOld、CMS

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

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

3.1、Serial 回收器

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

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

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

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

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

  1. 在 JDK1.5 以及以前的版本中,与新生代的 Parallel scavenge 配合使用;
  2. 作为老年代 CMS 收集器的后备垃圾收集方案。

在 HotSpot 虚拟机中,使用 -XX:+UseSerialGC 参数可以指定年轻代和老年代都使用 Serial 收集器。-XX:+UseSerialOldGC 指定老年代用 Serial Old 收集器

优缺点:

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

总结:

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

Serial/Serial Old 垃圾回收时图谱:

3.2、ParNew 回收器

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

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

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

ParNew 垃圾回收时图谱:

ParNew 的优势:

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

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

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

不一定。

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

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

3.3、Parallel Scavenge 回收器

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

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

和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)(高效率的利用 CPU),它也被称为吞吐量优先的垃圾收集器。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

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

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

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

Parallel垃圾回收时图谱:

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

相关参数配置;

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

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

3.4、CMS(Concurrent-Mark-Sweep)

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

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

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

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

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

CMS 垃圾回收图谱:

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

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

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

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

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

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

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

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

在 CMS 并发收集阶段,如果用户线程对象创建速度过快,导致内存不够用,怎么办?

并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发 Full gc,也就是”Concurrent Mode Failure”,此时会进入全程 stop the world,用 serial old 垃圾收集器来回收。

CMS 的优缺点

优点:并发收集、低延迟

缺点:

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

CMS 相关参数

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

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

3.4(补充)三色标识

G1 和 CMS 里都有一个并发标记垃圾的过程,也就是此时 GC 线程和工作线程是同时工作的,也就是会出现非垃圾变为垃圾(此时直接在下次回收时清理掉就可以,并没有大的影响)、垃圾变为非垃圾的情况,而垃圾变为非垃圾时如果没有及时发现处理而被回收了,那么会产生非常严重的后果,JVM 是采用三色标记算法来解决这种问题的。

把 Gc roots 可达性分析遍历对象过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接指向某个白色对象。
  • 灰色: 表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用(即白色对象)还没有被扫描过。
  • 白色: 表示对象尚未被垃圾收集器访问过。在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。


  • 多标:在并发标记阶段,GC 线程本来标记某个对象是可达的,随后用户线程出栈,此栈帧中引用的对象实例没有其他地方引用了,对象就变成了垃圾对象(浮动垃圾)。

    对于夺标产生的浮动垃圾,JVM 该怎么处理呢??答案,下一次 GC 的时候处理。

  • 漏标:并发标记阶段,用户线程新创建的对象。漏标会导致被引用的对象被当成垃圾误删除,这是严重 bug,必须解决呀!!漏标的解决方案大致分为两种:增量更新原始快照

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

    • 原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
      在赋值之前将老的引用以快照的形式保存到一个集合里,在重新标记的过程中,集合里面的引用全部标记为黑色,在这一轮里就不会被回收(变成浮动垃圾了,在下一轮会被回收)。

CMS 采用的增量更新,G1 采用的是原始快照,为什么这样:原始快照相对增量更新效率会高(当然原始快照可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而 CMS 对增量引用的根对象会做深度扫描,G1 因为很多对象都位于不同的 region,CMS 就一块老年代区域,重新深度扫描对象的话 G1 的代价会比 CMS 高,所以 G1 选择原始快照不深度扫描对象,只是简单标记,等到下一轮 GC 再深度扫描。

3.5、G1(重要)

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

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

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

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

3.5.2、为什么叫 G1 呢?

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

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

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

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

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

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

3.5.3、Region

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

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

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

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

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

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

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

并行与并发

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

分代收集

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

空间整合

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

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

这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。比如说老年代此时有 1000 个 Region 都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿 200 毫秒,那么通过之前回收成本计算得知,可能回收其中 800 个 Region 刚好需要 200ms,那么就只会回收 800 个 Region(Collection Set,要回收的集合),尽量把 GC 导致的停顿时间控制在我们指定的范围内。

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

3.5.5、G1 垃圾收集器的缺点

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

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

3.5.6、G1 之 Young GC

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

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

然后开始如下回收过程:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

全局并发标记:

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

拷贝存活对象:

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

3.5.8、G1 之 Full GC

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

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

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

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

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

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

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

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

3.5.10、G1 收集器的适用场景

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

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

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

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

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

3.5.11、G1 使用建议

年轻代大小

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

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

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

最好不要把停顿时间设置的太短,因为我们设置停顿时间太短,G1 每次回收的区域很少,用户线程执行过程中,最后没有区域可回收,JVM 不得不进行一次全方面的 Full GC,导致停顿时间更长,得不偿失。

3.5.12、G1 相关参数

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

3.6、Shenandoah

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

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

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

3.6.1、Shenandoah 模型

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

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

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

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

3.6.2、Shenandoah 工作原理

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

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

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

3.6.3、并行整理核心—Brooks Pointer

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

Brooks Pointers示意图:

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

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

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

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

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

Brooks Pointers示意图:

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

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

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

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

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

3.6.4、Shenandoah 性能

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

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

3.7、ZGC(The Z Garbage Collector)(重要)

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

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

它的设计目标包括:

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

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

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

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

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

3.7.1、ZGC 的工作过程

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

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

3.7.2、染色指针技术

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

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

使用染色指针的好处:

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

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

3.7.3、读屏障

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

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

读屏障示例:

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

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

3.7.4、ZGC 性能

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

ZGC的吞吐量测试:

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

ZGC 的停顿时间测试:

3.7.5、ZGC 的优缺点

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

3.7.6、ZGC 相关参数

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

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

3.8、垃圾回收器的组合

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

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

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

垃圾收集器 分类 作用区域 使用算法 特点 适用场景 整理过程
Serial 串行 新生代 复制算法 响应速度优先 适用于单CPU环境下的client模式 STW后,单线程将存活对象复制到另一块内存中
Serial Old 串行 老年代 标记压缩算法 响应速度优先 适用于单CPU环境下的Client模式 STW后,单线程清除对象,并将存活对象压缩
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境Server模式下与CMS配合使用 STW后,多线程将存活对象复制到另一块内存中
Parallel 并行 新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景 STW后,多线程将存活对象复制到另一块内存中
Parallel Old 并行 老年代 标记压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景 STW后,多线程清除对象,并将存活对象压缩
CMS 并行 老年代 标记-清除算法 响应速度优先 适用于互联网或B/S业务 第一次标记GC Roots时会STW,然后并发标记所有对象,再并行补充标记这一段时间发生变动的对象,最后并发清理所有需要清理的对象
G1 并发并行 全堆 标记压缩算法、复制算法 响应速度优先 面向服务端应用 年轻代:并发扫描根,更新纠正根,计算regio的card确定存活对象,复制存活对象。老年代:标记根节点可达对象(STW),并发标记对象,补充修正一次再清理(清理时会STW)
Shenandoah 并发并行 全堆 标记压缩算法、复制算法 响应速度优先 面向服务端应用 STW后标记GC roots,并发标记对象,补充修正一次,并发回收对象,并发更新对象的引用,并发更新GC Roots(STW),并发清理对象
ZGC 并发并行 全堆 标记压缩算法、复制算法 响应速度优先、高吞吐 更适用于大级别堆 STW后利用染色指针技术标记GC roots,并发标记对象,补充修正一次,计算需要回收的region,将要回收的region的存活对象复制到新的region,修正染色指针的指向(自愈,如果没有修正,第一次访问时会被自动修正)

4、如何选择垃圾回收器

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

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

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

5、安全点与安全区域

安全点:在做 GC 过程中不是想做就立刻做 GC,当用户线程在做 GC 之前会判断一下标志是什么(比如 0 和 1,1 表示达到安全点的位置,0 没有达到,所有用户线程都会轮询看一下这个标志)当用户线程标志都为 1 时,就会被挂起,当所有用户线程标志都为 1 时(都到达安全点时),就会触发 GC。

安全点的位置主要有以下几种:

  1. 方法返回之前;
  2. 调用某个方法之后;
  3. 抛出异常的位置;
  4. 循环的末尾;

安全区域:是针对一个正在执行的线程而定的,比如一个线程处于sleep状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point上。因此 JVM 引入了 Safe Region,它是指在一段代码块中,引用关系不会发生变化,这个区域内的任意地方开始 GC 都是安全的。

Reference


JVM 垃圾回收之常见垃圾回收算法
https://flepeng.github.io/021-Java-42-JVM-JVM-垃圾回收之常见垃圾回收算法/
作者
Lepeng
发布于
2024年4月19日
许可协议