1

本文就JVM的内存分配与垃圾收集,做一些简单的梳理。

一、内存自动管理概述

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

1.1 GC区域

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

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

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

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

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

之前的《JVM知识梳理之一_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相关知识。

二、如何找到需要回收的内存

GC针对的内存区域是堆和方法区。堆中存放着JVM几乎所有的对象实例,所以GC的第一件事情,就是要确定哪些对象实例还"存活"着,哪些已经"死去"。"死去"即没有任何途径能再使用到这个对象实例。另外,方法区中的废弃常量和不再使用的类型,也是可以回收的。

2.1 对象存活判断

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

2.1.1 引用计数算法

引用计数算法思路很简单:在对象中添加一个引用计数器;每当被引用时,计数器就加1;每当失去一个引用时,计数器就减1;只要计数器为零,该对象就不能再被使用。

这个算法目前基本没有主流的Java虚拟机采用,了解一下即可。原因是,虽然思路简单,但实现起来有很多例外场景,需要很多额外处理。比如对象之间的循环引用。

只有两个对象相互循环引用的话,其实也意味着这两个对象都无法被其他地方使用了,也就意味着可以被回收了。但单纯的引用计数无法解决这个问题。

2.1.2 可达性分析算法

目前主流的Java虚拟机,都是通过可达性分析算法来判断对象是否存活的。

可达性分析算法基本原理

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

image

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

在JVM中,GC Roots包括:

  • 虚拟机栈中引用的对象,即各个线程当下压入栈中的栈帧(即方法)的参数变量,局部变量所引用到的对象。(可以回顾一下《JVM知识梳理之一_JVM运行时内存区域与Java内存模型》中对虚拟机栈的梳理。)
  • 方法区中的静态变量与常量所引用的对象,比如引用类型静态变量,字符串的字面量。(可以回顾一下《JVM知识梳理之二_JVM的常量池》中对字符串常量池的梳理。)
  • 本地方法栈中JNI引用的对象。
  • JVM内部的引用,比如类加载器,一些常驻的异常对象(空指针,内存溢出等),基本数据类型对应的Class对象。
  • 被同步锁(synchronized)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVM TI中注册的回调、本地代码缓存等。
  • 分代收集和局部收集的场景里,如果只针对部分区域进行收集,还要考虑关联区域中对象对本区域对象的引用。

对于引用关系,大部分情况下,指的都是传统的强引用,对象之间只有被引用未被引用的关系。但有些特殊的场景下,可能需要这样一种像缓存一样的功能:当内存空间还很充足的时候能够保留一些失去强引用的对象,但GC之后空间还很紧张的话,就释放它们。因此Java还提供了除强引用以外的几种特殊的引用关系,但它们需要显式使用对应的类。Java的引用,从强到弱分别是:强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似Object obj = new Object()这种引用关系。
  • 软引用针对的是还有用,但不是必须的对象。在JVM将要发生内存溢出异常前,会将被"软引用"的对象列入收集范围进行二次收集,这样也许能避免这次的内存溢出异常。软引用通过SoftReference类实现,比如new SoftReference(new Object())
  • 弱引用针对的也是还有用,但不是必须的对象,但强度上比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。通过WeakReference类实现。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。通过PhantomReference类实现。
并发可达性分析

原则上,可达性分析算法的两个基本步骤(GcRoots枚举与引用链推导)都对一致性有很强的要求,所以都是需要暂时冻结所有用户线程的,以避免分析期间对象引用关系发生变化。

GcRoots枚举带来的停顿是很短暂且固定的,不会随着Java堆内存容量的增加而上升。但引用链推导的耗时则是不固定的。如果我们可以预测到可达对象实际很少,那么引用链推导其实快得很;但当可达对象很多的时候,引用链推导就会随着可达对象数量的上升而越来越耗时,这就会带来JVM很长时间的停顿(其他用户线程被暂停)。为了解决这个问题,就需要在可达对象比较多的时候,采用并发策略来做引用链推导。

如何预测可达对象多还是少,这在后面的分代收集理论中有梳理。

(1) 串行、并行与并发

这里首先需要定义一下讨论GC时的几个名词的含义:

  • GC线程:指的是JVM用来执行垃圾收集的相关线程,它与用户线程相对应。
  • 用户线程:指的是JVM用来执行GC以外处理的所有线程,它与GC线程相对应。
  • STW:stop the world,指的是JVM暂停所有用户线程,引起JVM停顿。
  • 串行:指的是JVM暂停所有用户线程(STW)并使用单线程执行GC相关处理。
  • 并行:指的是JVM暂停所有用户线程(STW)并使用多线程执行GC相关处理,通常取CPU核数个线程或小于CPU核数。
  • 并发:指的是JVM并不暂停用户线程,而是使用单个或少量线程作为GC线程,与用户线程在某个时间段内"一起"执行。此时GC线程数量一般控制在1/4的CPU核数,但并不意味着GC线程可以"抢到"1/4的CPU负荷,因为总的线程数往往是远大于CPU核数的,它们不会严格地"同时"在运行,总会有CPU切换线程执行。

在明确以上定义之后,就可以说,并发可达性分析,指的是在做可达性分析(主要是引用链推导)时GC线程与用户线程并发执行。很显然,并发可达性分析主要是为了提高引用链推导,即标记过程的效率,充分发挥多核CPU的硬件性能。

但并发策略会带来新的问题:对象消失。这个问题需要引入三色分析法来说明。

(2) 三色标记法

三色标记是一种辅助引用链推导的方法,它把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  1. 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,除了GCRoots,所有的对象都是白色的;但在分析结束之后,仍然白色的对象即代表不可达。
  2. 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有对其他对象的引用也都已经扫描过。黑色的对象代表已经扫描过,它是可达的。如果在后续扫描过程中有其他对象引用指向了黑色对象,则无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  3. 灰色:一种中间临时状态,表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
后续梳理的各种垃圾收集器在标记阶段给对象做的可达性分析结果就是这个三色标记。

(3) 对象消失

为什么说采用并发标记的话,可能会有对象消失现象?可以参考下图理解:

image

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

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

(4) 增量更新和原始快照

如何解决并发标记中的对象消失问题?目前有两种方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次,包括其直接引用和间接引用,这是深度扫描。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。但注意,这时扫描的仅仅只是被记录下来的原先灰色对象对白色对象的引用,不会扫描其他引用关系。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

原始快照可能会导致实际只被删除引用没有被其他黑色对象新增引用的对象保留下来,成为浮动垃圾,要等到下次GC时再做可达性分析时才会被标记为白色。而增量更新没有这样的问题。但增量更新的效率是没有原始快照高的,因为原始快照的重新扫描仅仅是被删除的"灰色->白色"关系,并不会整个重新扫描灰色对象的所有直接和间接引用关系。

2.1.3 垃圾审判流程

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

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

image

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

2.2 方法区的回收判断

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

废弃的常量指的常量池中不再有地方使用的字面量或符号引用。比如一个字符串"曾经沧海难为水",曾经被使用,字面量进入了常量池(运行时常量池与字符串常量池),但当前JVM里没有任何一个String对象的值是"曾经沧海难为水",那么此时发生GC的话,这个"曾经沧海难为水"字面量就有可能被清理出常量池。

不再使用的类型的判断要复杂的多,同时满足下面三个条件,才有可能回收对应的加载过的类信息:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件只有实现了可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

三、分代收集理论与经典垃圾收集器

从如何判断对象存活的角度看,垃圾收集算法可以划分为引用计数式垃圾收集(也叫直接垃圾收集),和追踪式垃圾收集(也叫间接垃圾收集)两大类。目前主流的Java虚拟机采用的都是基于可达性分析算法追踪式垃圾收集策略,且大多都遵循了分代收集理论进行设计。

3.1 分代收集理论

分代收集理论建立在三个经验假设之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的,生命周期很短。
  2. 强分代假说:熬过越多次垃圾收集过程(多次可达性分析均可达)的对象就越难以消亡。
  3. 跨代引用假说:跨代引用相对于同代引用来说占比极小。

前两个假说奠定了分代收集的理论基础:垃圾收集器应该将Java堆划分为不同的区域,并根据其年龄(对象熬过垃圾收集的次数)分配到不同的区域。这样对于比较年轻的区域,GC更加频繁,因为可以回收更多的内存空间;而对于较老的区域,因为难以消亡,GC频率就会较低。这种分代收集理论可以兼顾垃圾收集的时间开销和内存利用率。

主流JVM中,一般至少会把堆划分为新生代(Young Generation)和老年代(Old Generation)。结合前面的可达性分析相关内容,我们可以推断:新生代的引用链推导会很快,因为实际可达的对象应该不多;老年代的引用链推导会比较慢,因为实际可达对象比较多。从后面的实际的垃圾收集器来看,对停顿时间比较在意的垃圾收集器都会在老年代的标记阶段采用并发标记,即并发可达性分析算法。

但只有前两个假说会带来一个问题:当单独对一个区域比如新生代进行垃圾收集时,由于新生代的对象有可能被老年代的对象所引用,因此需要在GC Roots中添加所有老年代的对象。反过来一样,对老年代进行垃圾收集时,需要将新生代的对象加入GC Roots。毫无疑问的是,将关联区域的所有对象加入GC Roots会给垃圾收集带来很大的性能负担。因此又有了上面的第三个假说。根据第三个假说,跨代引用比较少,只用在新生代建立一个全局的数据集,将老年代划分为若干小块,标识哪些块上存在跨代引用;当新生代GC时,不用将老年代的所有对象都加入GC Roots,只需要将有跨代引用的块加入即可。当然,这种方法需要在对象引用关系创建或改变时同时维护这个全局数据集,增加了部分性能开销,但相比将整个老年代加入GC Roots进行可达性分析来说,还是很划算的。

第三个假说其实也是前两个假说的隐含结论:存在引用关系的两个对象,应该会倾向于同一个区域。比如,假如新生代某个对象被老年代某个对象引用,那么这个新生代的对象也会经历多次GC后被放到老年代去。

以HotSpot为例,它的经典垃圾收集器都是遵循分代收集理论的,将堆划分为新生代和老年代,由此出现了基于分代的GC名词:

  • 部分收集,Partial GC,指仅对部分分代区域进行的垃圾收集,它又可以分为新生代收集老年代收集混合收集
  • 新生代收集,Minor GCYoung GC,针对新生代的垃圾收集。
  • 老年代收集,Major GCOld GC,针对老年代的垃圾收集。(Major GC 有时也指整堆收集Full GC。)
  • 混合收集,Mixed GC,目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集,Full GC,针对整个Java堆个方法区的垃圾收集。

3.2 基础垃圾收集算法

根据分代收集理论,针对不同区域的不同特点,应该有对应的垃圾收集算法。比如新生代的对象存活率很低,大部分都熬不过第一轮的回收;而老年代的对象往往能熬过很多轮的回收,即存活率比较高。

JVM基于分代收集理论的垃圾收集器通常称为经典垃圾收集器,它们针对新生代和老年代,分别使用到了下面的3个基础的垃圾收集算法:

  • 标记清除算法
  • 标记复制算法
  • 标记整理算法

3.2.1 标记清除算法

标记清除(mark-sweep)算法是最基础的一种垃圾收集策略,它的思路很简单,将垃圾收集分为标记清除两个阶段:首先标记出所有可以被回收的对象;然后将被标记的对象回收掉。标记阶段采用可达性分析,并需要将不可达对象标记为清除对象。整个过程只有标记阶段需要STW。

标记清除算法图示如下:

image

标记清除算法虽然简单,但有两个缺点:

  1. 执行效率不稳定,如果Java堆中对象太多,且大部分都可以回收,比如新生代,那么就需要大量的标记和清除动作,效率会随着对象数量的增加而降低;
  2. 标记-清除动作之后会产生大量的内存碎片,如果有大对象需要分配连续空间,可能会提前触发下一次GC。

针对标记清除算法的特点,它比较适合对象存活率高的场合,比如老年代。新生代由于其对象生存率极低,不适合使用标记清除算法。

即使是老年代,总是使用标记清除算法也会造成碎片化严重的问题,所以现在垃圾收集器们也很少在老年代直接用标记清除算法。

3.2.2 标记复制算法

针对标记清除算法的缺点,人们提出了标记复制算法:将可用内存按容量划分为大小相等的两块,同一时刻只使用其中一块。当这块用完了就将存活的对象复制到另一块去,然后将满了的那块一次性清空。标记阶段依然是可达性分析,但只用标记可达对象。图示如下:

image

标记复制算法针对的是新生代这样的,每次GC大部分对象都会被清理(存活率低)的场景。它解决了标记清除算法的两个缺点:因为存活的对象属于少数,因此标记-复制的就少;同时复制的时候也顺便清理了内存碎片。

标记复制算法的缺点是空间浪费比较多,总有一半内存用不上。但对于新生代来说,大部分对象都熬不过第一轮GC,不需要按1:1的比例划分内存空间,所以目前主流JVM都会使用改良优化后的标记复制算法来回收新生代。这种改良的算法是半区复制策略,它将新生代分为一块较大的Eden空间和两块较小的Survivor空间,两个Survivor总有一个是空的。内存分配到Eden,GC时将存活的对象(包括Eden中的和非空Survivor中的)移动到空的那块Survivor去并清空Eden和非空Survivor;下次GC则将存活对象移动到另一块Survivor。HotSpot默认Eden和Survivor的大小比例为8:1,即总有10%的新生代空间是空闲的。

这种设计的前提是对象存活率低,十分之一的幸存者区足够放下存活下来的对象。如果很不幸,某次新生代GC需要复制的对象超过了Survivor的大小,那么就将多余的对象移动到老年代去,这叫分配担保,Handle Promotion。分配担保是这种幸存者区较小的设计的后路,不常用到,但一定要有。

复制算法还有一个缺点,复制时也需要STW。因为复制对象的引用地址发生了变化,需要暂停使用它们的用户线程,并在移动后更新全部引用地址。但一般来说,采用复制算法的新生代的存活对象比较少,所以这个停顿很短暂,大部分场景属于"可以不计较"的代价。

3.2.3 标记整理算法

如前所述,标记清除适合老年代,标记复制适合新生代,似乎足够了。但事实上老年代的垃圾收集只用标记清除的话,还是有内存碎片化的问题的。而标记复制算法也不适合老年代的垃圾收集。对于老年代,它的对象的存活率很高,如果使用复制算法的话,很明显复制的对象数量会很多,会拉低垃圾收集的效率。而且如果不想浪费50%的空间,就必须保证有分配担保,但对老年代而言这显然是很难实现的。

所以针对老年代的特点,人们又提出了标记整理算法(Mark-Compact):可达性分析标记存活对象,将存活对象向内存区域的一端移动,这样存活对象在内存区域中就变成了连续分布,然后直接将存活对象边界之外的部分直接清除即可。如下图所示:

image

标记整理本质上是对复制的另一种使用,它与标记清除的不同就在于是否移动存活对象,而不管移动与否,都是优缺点并存的风险决策:

  1. 如果不移动直接清除的话,会造成内存碎片化问题,随着垃圾收集轮次的增加,势必会导致JVM对内存的分配更加复杂,增加JVM负担,降低总的吞吐量。即,不移动会导致以后内存分配更复杂。
  2. 如果移动的话,那就和复制算法一样,移动对象的内存位置需要更新所有对该对象的引用。这种操作不但对JVM造成很大的负担,同时还会STW。即,移动会导致本次回收更复杂。

在JVM的经典垃圾收集器里,Parallel Scavenge的目标在于提高JVM整体吞吐量,它就使用了标记整理算法;而CMS的目标在于低延迟或者说低停顿,它就使用了标记清除算法。

CMS对内存碎片化也有优化措施,当几轮的CMS垃圾收集过后,发现碎片化程度已经到达某种会影响到内存分配的程度后,会采用标记整理算法收集一次。

至此,我们介绍了三种基础垃圾收集算法,JVM基于分代收集理论的几个经典垃圾收集器都是基于这三者实现的。

3.3 经典垃圾收集器

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

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

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

image

  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共用了大部分实现代码。因此在这张图里将它们作为搭配组合连接起来了。

下面开始梳理这几种垃圾收集器。

3.3.1 Serial收集器

Serial收集器采用标记复制算法对新生代进行垃圾收集,GC采用单线程,并STW,如下图所示:

image

Safepoint,安全点,是指用户线程所执行的代码指令流能够停下来以进行垃圾收集的位置。GC线程会等到所有用户线程都达到最近的安全点后再开始执行。

Serial收集器是最基础的收集器,虽然简单且STW时间相对较长,但在单核或内存受限的环境下,反而是很高效的一个垃圾收集器。例如一个只分配了单核CPU和较小内存(1G以内)的虚拟机上运行的客户端模式的JVM,就很适合使用Serial收集器。

3.3.2 ParNew收集器

ParNew就是Serial的GC多线程并行版本。如下图所示:

image

G1成熟之前,主流的Java服务都会采用ParNew + CMS组合作为垃圾收集策略。之所以新生代用ParNew其实是由于实现框架的原因,目前只有ParNew能和CMS配合使用。如果老年代不使用CMS的话,那就也不会使用ParNew作为新生代垃圾收集器。

ParNew在单核或低核环境下是不如Serial的,因为多线程GC并行的话,会有线程间切换的损耗。而多核环境下,其实新生代有更好的垃圾收集器选择。

3.3.3 Parallel Scavenge收集器

Parallel Scavenge也是新生代收集器,与ParNew在执行时基本相同,同样是基于标记复制算法实现,同样是并行收集。它与ParNew的不同之处在于:

  1. 能够通过参数控制JVM吞吐量。
  2. 具备自适应调节策略,把内存管理的调优任务交给虚拟机自己完成。
  3. 由于底层框架不同的原因,导致不能和CMS配合使用。

(1) 控制JVM吞吐量

Parallel Scavenge与其他收集器重点关注如何减少单次GC的停顿时间不同,它更关注的是如何提高JVM吞吐量。所谓JVM吞吐量,Throughput,它指的是用户线程运行时间占JVM总运行时间的比例。其公式如下:

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

它是通过以下两个参数控制吞吐量:

  • -XX:MaxGCPauseMillis : 最大垃圾收集停顿时间,该值是一个目标值,JVM会尽量控制每次垃圾回收的时间不超过这个值。要注意,并不是说这个值设置的越小,GC就越快!该值设置越小的话,Parallel Scavenge触发GC的频率就越高,因为这样每次需要收集的垃圾就越少,从而保证时间不超过设定值。这样的话,每次的停顿时间虽然变小,但吞吐量一定也会下降。使用该参数的理论效果:MaxGCPauseMillis越小,单次MinorGC的时间越短,MinorGC次数增多,吞吐量降低。
  • -XX:GCTimeRatio : 吞吐量指标。不要被该参数的字面迷惑了,它不是GC时间占比的意思!它是GC耗时目标公式1/(1+n)中的n参数而已。GCTimeRatio的默认值为99,因此,GC耗时的目标占比应为1/(1+99)=1%。使用该参数的理论效果:GCTimeRatio越大,吞吐量越大,GC的总耗时越小。有可能导致单次MinorGC耗时变长。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此Parallel Scavenge适合后台计算为主的Java服务。

(2) 自适应调节内存管理

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

3.3.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,采用标记整理算法,同样是单线程收集。示意如下:

image

Serial Old的主要用途:

  1. 供客户端模式下的老年代收集用。
  2. 服务端模式下,作为CMS收集失败后的Full GC备案,注意此时整堆都会采用Serail Old做垃圾收集,而不仅仅是老年代。

另外,Serial Old底层使用的是mark-sweep-compact算法实现,所以有时候又叫单线程MSC;而在Java10之前,G1收集器失败时的逃生Full GC用的是单线程的MSC,从Java10开始,G1的Full GC改为了多线程并行执行的MSC

3.3.5 Parallel Old收集器

Parallel Old就是Parallel Scavenge的老年代版本,支持GC多线程并行收集,基于标记整理算法,同样关注于控制JVM吞吐量。其运行示意如下:

image

在注重JVM吞吐量,后台运算较多而与用户交互较少的业务场景中,比较适合使用Parallel Scavenge + Parallel Old的组合。事实上,Parallel Old就是专门配合Parallel Scavenge的收集器。

例如Java8的服务端模式下的默认GC策略就是Parallel Scavenge + Parallel Old

3.3.6 CMS收集器

目前已经梳理的GC里,Serial/Serial Old/ParNew/CMS都是以减少STW时间为主要目标的。它们的适用场景一般都是互联网应用或基于浏览器的B/S架构的后台服务,因为这一类的服务比较重要的是用户交互体验,所以会很关注服务的响应速度。所以此时需要GC能够尽量减少STW的时间。

而CMS,就是这种场景下HotSpot中垃圾收集器的曾经的王者。它是HotSpot虚拟机上第一款支持并发回收的垃圾收集器。CMS的全称是Concurrent Mark Sweep,即,并发标记清除。它的运行过程相比前面几种收集器来说要复杂一些,整个过程可以简单地划分为四个阶段,分别是:

  1. 初始标记,Initial mark,仅仅只标记GC Roots能够直接关联到的对象,STW,但速度很快;
  2. 并发标记,Concurrent mark,从直接关联对象开始遍历整个对象图,这个过程耗时较长但并不STW,而是让用户线程和GC线程并发执行;
  3. 重新标记,Final remark,因为要并发标记,所以要基于增量更新的算法重新扫描"由黑变灰"的对象(参考前面的并发可达性分析章节),该阶段要STW,耗时一般比初始标记的耗时长,但也远比并发标记阶段的耗时短;
  4. 并发清除,Concurrent sweep,该阶段清理掉被标记为不可达的对象,与用户线程并发执行。

其中,初始标记与重新标记仍然会STW,但时间都很短(重新标记在某些极端场景下会比较耗时)。而耗时最长的并发标记和并发清除阶段,GC线程与用户线程是并发执行的。因此,CMS从总体上来说,它的GC过程和用户线程是并发执行的。如下图所示:

image

实际上CMS有7个阶段,这里省略了三个阶段:

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

CMS的缺点

CMS是HotSpot实现的第一款并发收集器,它的目标当然是尽量降低停顿时间。但它远远不够完善,至少有以下几个明显缺点:

  1. 对CPU资源敏感。虽然在几个并发阶段能够与用户线程并发执行,但因为占用了部分CPU资源,总会导致用户线程的并行数量下降,降低了系统整体的吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,如果cores数在四个以上,并发回收时垃圾收集线程只占用不超过25%的CPU资源,并且会随着cores数量的增加而下降。但是当cores数量不足四个时,CMS对用户线程的影响就变得很大。
  2. 存在并发收集失败进而导致完全STW的Full GC发生的风险。在并发标记和并发清理阶段,并发运行的用户程序可能会导致新的对象进入老年代,这部分对象只能等到下次GC再处理(浮动垃圾),因此CMS不能等到老年代快满时才触发,不然在并发阶段新进入老年代的对象将无处存放,但这个阈值一般设的比较高(默认90%以上),所以会有GC期间老年代内存不足导致发生Concurrent Mode Failure,从而启用备用的Serial Old收集器来进行整堆的Full GC,这样的话,STW就很长了。
  3. CMS采用的是标记清除算法,会导致内存空间碎片化问题。CMS为了解决这个问题,提供了XX:+UseCMS-CompactAtFullCollection开关参数和XX:CM SFullGCsBefore-Compaction参数,让CMS在执行过若干次标记清除的GC之后,下一次GC的时候先进行碎片整理。但这样又会使停顿时间变长。
  4. JVM参数调优相对比较麻烦,没有自适应调节内存管理的功能。
尽管CMS有很多不如意的缺点,甚至在G1成熟后CMS已经被Oracle抛弃(在Java9中弃用并在Java15中正式移除)。但在目前依然占据了大部分生产环境的Java8上,ParNew + CMS依然是大多数Java服务的首选GC策略。(尽管不是默认GC策略)

3.3.7 Garbage First收集器

Garbage First(简称G1)是一款以Region(区域)为最小内存回收单位的,逻辑上采用分代收集理论的垃圾收集器。如果说CMS是曾经的王者,那么G1就是将CMS扫入"历史的垃圾堆"的替代者。

G1早在Java6中就作为实验性质的特性出现,Java7成为正式特性,Java8中继续完善,最终在Java9成为HotSpot的首选垃圾收集器,正式取代CMS,成为新一代的主流收集器。

从Java9到目前最新的Java16,G1一直是服务端模式下的默认垃圾收集器。以后也许会被ZGC取代,But Not Today!

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

G1仍然使用分代收集理论,但再也不是物理上将堆内存划分为连续分配的新生代和老年代,而是变成了逻辑上的,物理不连续的逻辑分代。而Region布局则是后续更先进的低延迟垃圾收集器比如Shenandoah和ZGC都沿用或继续改善的内存布局方式。Region布局最显而易见的好处,就是能够更灵活方便的分配与回收内存;在内存分配回收的灵活性与内存规整性(避免碎片化)这两者之间,使用Region这种布局方式更容易取得平衡。

G1提供了两种垃圾收集:Young GCMixed GC。这两种GC何时触发?如何基于region工作?下面先梳理一下G1的工作过程。

(1) G1工作过程对内存的分配

先看一下G1的工作过程,以及这个过程中对内存的分配:

image

※1 region分区

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

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

image

※2 为用户线程分配内存

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

image

要注意的是:

  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的收集对象不不同:

image

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

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

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

※6 巨型区域(Humongous Region)

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

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

(2) G1垃圾收集活动时序

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

image

整个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再重新扫描。

(3) G1的垃圾收集担保

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

Java10之前是单线程MSC,Java10中改进为多线程MSC
(4) 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,全功能的垃圾收集器。

3.3.8 经典垃圾收集器小结

对经典垃圾收集器做一个小结,对比如下:

收集器执行方式新生代 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。它们在低延迟方面与之前梳理过得经典垃圾收集器比较如下:

image

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

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

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

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

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

4.1 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 SwapCAS技术来保证线程安全的。

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

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

注意,这里的读写屏障并不是JMM中的内存屏障。内存屏障类似同步锁,是多线程环境下工作线程与主存之间保证共享变量的线程安全的技术。

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

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

4.2 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. 能够管理的内存不能超过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一脉相承。
  3. OpenJDK现在除了HotSpot虚拟机,还支持OpenJ9虚拟机,它有自己的垃圾收集器如ScavengerConcurrent MarkIncremental Generational等等,不了解。

六、垃圾收集器的选择及相关参数

在实际生产环境上应该如何选择垃圾收集器?相关参数如何设置?

这里只考虑OpenJDK/OracleJDK的HotSpot虚拟机上的垃圾收集器。没有将Azul和OpenJ9的垃圾收集器加入选择范围,因为不了解。

6.1 收集器的选择

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

  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

6.2 GC日志参数

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

Java9的JVM日志使用方式:

-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

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

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

6.3 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

6.4 CMS相关JVM参数配置

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

-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,目的在于减少跨代引用,降低重新标记时的开销

下塘烧饼
97 声望17 粉丝

个人文章迁移到知乎了:[链接]