关于java:JVM知识梳理之三内存分配与垃圾收集

本文就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到该对象是不可达的。不可达的对象即不可能再被应用,是能够被回收的对象。可达性剖析算法能够轻松解决循环援用的问题,如下图所示:

很显然,可达性剖析算法的关键点有二:一是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) 对象隐没

为什么说采纳并发标记的话,可能会有对象隐没景象?能够参考下图了解:

数学上曾经证实了当且仅当以下两个条件同时满足时,会产生“对象隐没”的问题,即本来应该是彩色的对象被误标为红色:

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

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

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

  • 增量更新要毁坏的是第一个条件,当彩色对象插入新的指向红色对象的援用关系时,就将这个新插入的援用记录下来,等并发扫描完结之后,再将这些记录过的援用关系中的彩色对象为根,从新扫描一次,包含其间接援用和间接援用,这是深度扫描。这能够简化了解为,彩色对象一旦新插入了指向红色对象的援用之后,它就变回灰色对象了。
  • 原始快照要毁坏的是第二个条件,当灰色对象要删除指向红色对象的援用关系时,就将这个要删除的援用记录下来,在并发扫描完结之后,再将这些记录过的援用关系中的灰色对象为根,从新扫描一次。但留神,这时扫描的仅仅只是被记录下来的原先灰色对象对红色对象的援用,不会扫描其余援用关系。这也能够简化了解为,无论援用关系删除与否,都会依照刚刚开始扫描那一刻的对象图快照来进行搜寻。

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

2.1.3 垃圾审判流程

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

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

第一次标记是可达性剖析后果为不可达,此时它处于“期待秋后问斩”的状态,还有“上访”的机会;筛选是指每个对象都继承了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。

标记革除算法图示如下:

标记革除算法尽管简略,但有两个毛病:

  1. 执行效率不稳固,如果Java堆中对象太多,且大部分都能够回收,比方新生代,那么就须要大量的标记和革除动作,效率会随着对象数量的减少而升高;
  2. 标记-革除动作之后会产生大量的内存碎片,如果有大对象须要调配间断空间,可能会提前触发下一次GC。

针对标记革除算法的特点,它比拟适宜对象存活率高的场合,比方老年代。新生代因为其对象生存率极低,不适宜应用标记革除算法。

即便是老年代,总是应用标记革除算法也会造成碎片化重大的问题,所以当初垃圾收集器们也很少在老年代间接用标记革除算法。

3.2.2 标记复制算法

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

标记复制算法针对的是新生代这样的,每次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):可达性剖析标记存活对象,将存活对象向内存区域的一端挪动,这样存活对象在内存区域中就变成了间断散布,而后间接将存活对象边界之外的局部间接革除即可。如下图所示:

标记整顿实质上是对复制的另一种应用,它与标记革除的不同就在于是否挪动存活对象,而不论挪动与否,都是优缺点并存的危险决策:

  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。参考下图:

  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,如下图所示:

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

Serial收集器是最根底的收集器,尽管简略且STW工夫绝对较长,但在单核或内存受限的环境下,反而是很高效的一个垃圾收集器。例如一个只调配了单核CPU和较小内存(1G以内)的虚拟机上运行的客户端模式的JVM,就很适宜应用Serial收集器。

3.3.2 ParNew收集器

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

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收集器的老年代版本,采纳标记整顿算法,同样是单线程收集。示意如下:

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吞吐量。其运行示意如下:

在重视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过程和用户线程是并发执行的。如下图所示:

实际上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的工作过程,以及这个过程中对内存的调配:

※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的残缺的流动时序。

(2) 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再从新扫描。

(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。它们在低提早方面与之前梳理过得经典垃圾收集器比拟如下:

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

  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位Linux等 JDK15以上 提早真低 不反对windows,须要足够的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,目标在于缩小跨代援用,升高从新标记时的开销

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理