一文读懂“Java性能调优之JVM GC(垃圾回收机制)”

引言

GC垃圾回收器的简称,全称是Garbage Collection。Java 的垃圾回收器并不是特指一种,Java官方本身就提供了很多个GC回收器供用户选择,还有各个Java虚拟机厂商(例如 Azul 的PCG、C4)也自己设计开发了很多优秀的垃圾回收器。

Stop The World 也是一个很重要的关键词,它会在任何一种GC算法中发生,其实可以把它理解为JVM GC在清理内存时,整个程序的停顿时间。当 Stop The World 发生时,除GC所需的线程外,所有的线程都进入等待状态,直到 GC 任务完成。每一代的Java垃圾回收器,都把缩减 Stop The World 停顿时间作为很重要的目标。

01、概述

定义:垃圾回收机制时指在Java语言的生命周期中,Java运行环境提供的一个系统的[垃圾回收器]线程,负责自动回收那些没有引用与之相连的对象所占用的内存。这种清除无用对象回收内存的行为就叫做垃圾回收

作用:

Java语言中一个显著的特点就是引入了 垃圾回收机制,使其他编程语言最头疼的 内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java中的对象不再有**“作用域” 的概念,只有对象的引用才有“作用域” 。垃圾回收可以有效的防止内存泄露, 有效的使用空闲的内存

注意:

内存泄露是指该内存空间使用完毕之后未回收,在不涉及复杂数据结构的一般情况下,Java 的内存泄露表现为 一个内存对象的生命周期超出了程序需要它的时间长度,我们有时也将其称为“ 对象游离”。

02、垃圾回收机制中的算法

2.1、怎么判断对象可以被回收了?

简单来说就是:对象没有引用了或者对象不可达

怎么判断对象是否存活?常见的有两种算法,分别是 引用计数法可达性分析法

方法:

在类的实例化对象被创建时, JVM会自动给该对象分配内存,调用该对象的构造方法并开始跟踪该对象。当该对象停止使用时,JVM便会 通过垃圾回收器回收该对象所占的内存

垃圾回收算法一般要做2件基本的事情:

  • 发现无用信息对象

  • 回收被无用对象占用的内存空间,使该空间可被程序再次使用

2.2、引用计数法
引用计数是垃圾收集器中的早期策略。在这种方法中, 堆中每个对象实例都有一个 引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。 当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用 超过了生命周期或者被设置为一个新值时对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

**优点:**引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

public class Main {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
         
        object1.object = object2;
        object2.object = object1;
       
        object1 = null;
        object2 = null;

    }
}

所以,市面上主流的Java虚拟机都没有使用这个算法,而是使用可达性分析法做为判断对象是否存活的算法。

2.3、可达性分析法(根搜索算法)

根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。

java中可作为GC Root的对象有:

1.虚拟机栈帧中引用的对象(本地变量表)
2.方法区中静态属性引用的对象
3.方法区中常量引用的对象
4.本地方法栈中引用的对象(Native对象)
5.所有synchronized同步锁的持有对象
6.反映JVM内部情况的JMXBean、JVMTI注册的回调、本地代码缓存等
2.4、总结

Java目前在用的主流虚拟机,都是采用可达性分析法来判断对象是否可被回收的,它通过若干个根节点组成的集合(GC Roots),向下遍历搜索,遍历的过程就是一条引用链,没有在这个链条上面的对象,是不可能被再次使用的,可以判定为可回收的对象

2.5、JVM GC什么时候执行?

当程序创建一个新的对象或者基本类型的数据,内存空间不足时,会触发GC的执行。

不同的垃圾回收器,会有不同的回收策略,但大致可以分为两类:分代回收局部回收两种策略。

03、分代回收机制

大多数的商业虚拟机,都采用分代回收的理论来设计垃圾收集器,这个理论建立在两个分代假说上:

弱分代假说:绝大多数对象都是朝生夕死的。
强分代假说:熬过越多次的垃圾回收的对象,就越难消亡

既然绝大多数对象都熬不过几次垃圾回收,而熬过多次回收的对象又很难消亡,那么可以根据年龄把它们划分到不同的区域,例如新生代区域老年代区域,然后分而治之

例如新生代,绝大多数对象都是朝生夕死的,每次触发GC,这个区域里大部分对象都会被回收,非常适合使用可达性分析法,因为从根节点顺着引用链遍历下去,只有在这个引用链上的才是存活的,假设本次触发GC,这个区域里90%的对象都要被回收,但实际上只需要关注引用链上10%的对象就可以了,使用复制算法把这10%移动到一个幸存者区域,剩下的直接释放即可。

对于熬过很多次依然存活的对象,这种对象一般很难被回收了,这样的情况下,每次GC都对他们进行搜索标记,太浪费资源。把它们放到老年代区,这样JVM就能以较少的频率来回收这个区域,假如老年代的空间占比是60%,在不触发老年代回收的情况下,只需要对占比40%内存空间的新生代进行搜索和释放,效率提升还是很明显的!

3.1、新生代(Young generation)

绝大多数新创建的对象都会被分配到这里,这个区域触发的垃圾回收称之为:Minor GC

空间结构:

默认情况下,新生代(Young generation)、老年代(Old generation)所占空间比例为 1 : 2 。

它被分成三个空间:

· 1个伊甸园空间(Eden)

· 2个幸存者空间(Fron Survivor、To Survivor)

默认情况下,新生代空间的分配:Eden : Fron : To = 8 : 1 : 1

为什么要这样的布局?是因为新生代里的对象绝大多数是朝生夕死的,非常适合使用标记-复制算法,后面的回收算法章节会详细说。

新生代GC收集的执行顺序如下:

1、绝大多数新创建的对象会存放在伊甸园空间(Eden)。

2、在伊甸园空间执行第 1 次GC(Minor GC)之后,存活的对象被移动到其中一个幸存者空间(Survivor)。

3、此后每次 Minor GC,都会将 Eden 和 使用中的Survivor 区域中存活的对象,一次性复制到另一块空闲中的Survivor区,然后直接清理 Eden 和 使用过的那块Survivor 空间。

4、从以上空间分配我们知道,Survivor区内存占比很小,当空闲中的Survivor空间不够存放活下来的对象时,这些对象会通过分配担保机制直接进入老年代。

5、在以上步骤中重复N次(N = MaxTenuringThreshold(年龄阀值设定,默认15))依然存活的对象,就会被移动到老年代。

从上面的步骤可以发现,两个幸存者空间,必须有一个是保持空的

我们需要重点记住的是,新创建的对象,是保存在伊甸园空间的(Eden)。那些经历多次GC依然存活的对象会经由幸存者空间(Survivor)转存到老年代空间(Old generation)

也有例外出现,对于一些大的对象(指需要占用大量连续内存空间的对象)则直接进入到老年代。
Java提供了 -XX:PretenureSizeThreshold 来指定对象大于这个值,直接分配到老年代。
3.2、老年代(Old generation)

对象在新生代周期中存活了下来的,会被拷贝到这里。通常情况下这个区域分配的空间要比新生代多。正是由于对象经历的GC次数越多越难回收,加上相对大的空间,发生在老年代的GC次数要比新生代少得多。这个区域触发的垃圾回收称之为:Major GC 或者 Full GC

老年代空间的构成其实很简单,它不像新生代空间那样划分为几个区域,它只有一个区域,里面存储的对象并不像新生代空间里绝大部分都是朝闻道,夕死矣。这里的对象几乎都是从Survivor 空间中熬过来的,它们绝不会轻易狗带。因此,Major GC 或 Full GC 发生的次数不会有 Minor GC 那么频繁。

为什么老年代的回收耗时,比新生代更长呢?

有两点原因:

1、老年代内存占比更大,所以理论上回收的时间也更长
2、老年代使用的是标记-整理算法,清理完成内存后,还得把存活的对象重新排序整理成连续的空间,成本更高(算法的细节,后续章节会详细说。)
3.3、方法区(Method area)

这个区域主要回收废弃的常量和类型,例如常量池里不会再被使用的各种符号引用等等。类型信息的回收相对来说就比较严苛了,必须符合以下3个条件才会被回收:

1、所有实例被回收
2、加载该类的ClassLoader 被回收
3、Class 对象无法通过任何途径访问(包括反射)

可以使用 -Xnoclassgc 禁用方法区的回收。

3.4、跨代引用的问题

举个栗子,新生代中的对象很有可能会被老年代里的对象所引用,当新生代触发GC的时候,只搜索新生代的区域明显是不够的,还得搜索老年代的对象是否引用了新生代中非 GC Roots 引用链上的对象,来确保正确性。但这样做会带来很大的性能开销。为了解决这个问题,Java定义了一种名为记忆集的抽象的数据结构,用于记录存在跨区域引用的对象指针集合。

大多数的虚拟机,都采用一种名为**卡表(Card Table)**的方式去实现记忆集,卡表由一个数组构成,每一个元素都对应着一块特定大小的内存区域,这块内存区域被称之为卡页(Card Page),每一个卡页,可能会包含N个存在跨区域引用的对象,只要存在跨区域引用的对象,这个卡页就会被标识为1。当GC发生的时候,就不需要扫描整个区域了,只需要把这些被标识为1的卡页加入对应区域的 GC Roots 里一起扫描即可。各区域触发垃圾回收的类型与解释:

Minor GC:只回收新生代区域。
Major GC:只回收老年代区域。只有CMS实现了Major GC,所以在老年代里,触发GC,除了CMS和G1之外的其他收集器,大多数触发的其实是 Full GC
Full GC:回收整个堆区和方法区
Mixed GC:回收整个新生代和部分老年代。G1收集器实现了这个类型。

04、典型的垃圾回收算法

在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。

4.1、Mark-Sweep(标记-清除)算法

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段清除阶段。标记阶段的任务是标记出所有需要被回收的对象清除阶段就是回收被标记的对象所占用的空间

并没有规定标记阶段一定要标记**“可回收”的对象**,也可以标记**“存活”**的对象
标记“存活”的,还是标记“可回收”的,我个人认为 是标记存活的方式效率高些。
首先,Java使用的是 可达性分析算法来判断对象是否存活,上面有详细说这个算法,这里就不重复了。
我们假设要标记**“可回收”**的对象,再进行清除,那么需要三个步骤:
先通过可达性分析法,通过**根对象(GC Roots)**顺着引用链先把这些存活对象都标出来
遍历这个区域所有对象,把没标记存活的对象,打上一个**“可回收”**的标记
遍历这个区域所有对象,把标记了“可回收”的对象,释放掉。
但标记的是**“存活”**的对象,再进行清除,只需要两个步骤即可:
先通过可达性分析法,通过根对象(GC Roots)顺着引用链先把这些存活对象都标出来
遍历这个区域所有对象,把没标记存活的对象,直接清理掉即可。
所以,标记“可回收”的对象,会多了一次完全没有必要的遍历。这也是我不认同标记“可回收”的原因。

具体过程如下图所示:

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第1张图片

从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作

**4.2、**标记 - 复制算法算法

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第2张图片

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

很显然,复制算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么复制算法的效率将会大大降低。

标记-复制算法,在这个基础之上对其进行了优化,IBM曾有过一项针对新生代的研究,结论是绝大多数情况下,新生代区域里的对象有98%都熬不过第一次回收

所以不需要按照 1 : 1 的比例来实现复制算法,而是可以按照 8 : 1 : 1 的比例来分配内存空间,也就是一个80%的Eden空间两个10%的Survivor空间。

为什么要两块Survivor空间?
因为复制算法,必须要有一块空间是空闲的。想象一下,如果只有一块Eden空间 + 一块Survivor空间
当GC回收完成后,Eden中存活的对象会移动到Survivor空间。程序继续运行,新的对象又会进入Eden空间,此时就会出现 Eden 和 Survivor 空间里都有对象,复制算法也就进行不下去了。

每次分配内存,只使用Eden和其中一块Survivor空间,发生GC回收时,把Eden和其中一块Survivor空间中存活的对象,复制到另一块空闲的Survivor空间,然后直接把Eden和使用过的那块Survivor空间清理掉。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第3张图片

目前主流的使用分代回收机制的Java虚拟机,都是使用标记-复制算法来作为新生代的回收算法。它非常适合用在新生代这种回收率极高的场景,这样的场景下,复制算法浪费的空间几乎可以忽略不计。效率高,且内存不会有碎片化的问题。但对于老年代这种存活率很高的场景,就不适合了。

4.3、Mark-Compact(标记-整理)算法

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。增加了整理这个操作,去解决这些内存空间碎片化的问题。具体过程如下图所示:

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第4张图片

老年代里的对象存活率很高,不适合使用标记-复制的算法。而且老年代存储大对象的概率要比新生代大很多,这些大对象需要连续的内存空间来存储,标记-清除这个算法也不适合。所以大多数的老年代都采用标记-整理来作为这个区域的回收算法。

4、常见面试题分析

4.1、为什么老年代不使用和新生代一样的标记-复制算法呢?

新生代里绝大多数对象都是朝生夕死的,使用的 标记-复制算法,空间占比可以 8 : 1 : 1,但是 老年代里对象存活率很高,这个占比明显不合适。如果占比得设置得大 (例如50%),又会浪费很多内存空间,而且由于对象很多都是存活的,复制移动也是一笔开销。所以 标记-复制这个算法,不适合老年代这种对象存活率很高的区域。

4.2、标记和根搜索算法是什么样的关系?

Java使用的是**根搜索(可达性分析)**算法来确定对象是否存活的,而不是引用计数法这种事先在对象里记录引用数的做法。
标记其实不用遍历整个内存空间(除非内存空间里的对象全部存活),而是通过根搜索算法顺着引用链遍历标记存活的对象。所以标记的过程,就是根搜索算法查找存活对象的过程。

4.3、标记-整理算法,每次整理都消耗大量时间,能不能优化一下?

可以的,每次回收后,都进行整理,确实会消耗太多的资源。可以通过设置一个 阈值或者临界点,当 内存碎片化程度还在这个阈值范围内的时候,仅采用 标记-清除算法。只有超过这个阈值,才进行整理。

4.4、什么是内存碎片?它会带来什么问题?

从上面 标记-清除 算法,我们看到回收完成后,会产生很多不连续的内存空间,这就是 内存碎片
Java虚拟机对于 大对象(例如很长的字符串、byte数组等等),都必须存储在连续的内存空间里,当一个大对象需要进入某个内存空间时,由于内存碎片过多,虽然剩余内存是远大于这个对象所需空间的,但就是找不到一块连续的内存空间来存储它,这会导致提前触发一次 Full GC

5、常用的垃圾回收器

每一个回收器都存在Stop The World 的问题,只不过各个回收器在Stop The World 时间优化程度、算法的不同,可根据自身需求选择适合的回收器。
目前应用范围最广的,应该还是JDK8,它默认使用的是 Parallel Scavenge + Parallelo Old (PS+PO)收集器组合。
一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第5张图片
5.1、Serial(-XX:+UseSerialGC)

Serial 是Java虚拟机初代收集器,在JDK1.3之前是Java虚拟机新生代收集器的唯一选择,这是一个单线程工作的收集器。在进行垃圾回收的时候,需要暂停所有的用户线程,直到回收结束。

虽然历史久远,但它依然是HotSpot虚拟机运行在客户端模式下,或者4核4GB以下服务端的默认新生代收集器,这种核心数和内存空间较小的场景下,它单线程的优势就体现出来了,没有线程交互的开销,加上内存空间不大,单次回收耗时几十毫秒,这点停顿时间,完全是可以接受的。

Serial 负责收集新生代区域,它采用标记-复制算法。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第6张图片
5.2、Serial Old(-XX:+UseSerialOldGC)

SerialOld 是 Serial 收集器的老年代版本,和 Serial 一样,它也是单线程的收集器。目前主要应用在客户端模式(Client VM)下的HotSpot虚拟机使用。

如果在服务端模式(Server VM)下,它也有两种用途:一个是在JDK5以及之前,和Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器在出现并发模式故障(Concurrent Mode Failure) 时作为后备收集器。

SerialOld 负责收集老年代区域,它采用标记-整理算法。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第7张图片
5.3、ParNew(-XX:+UseParNewGC)

随着计算机的核心数和内存容量都在飞速发展,多核心和大内存容量的场景下,Serial 收集器单线程的性能明显比较落后了,ParNew 就是 在Serial 收集器的基础之上,实现了它的多线程版本。它可以多条线程同时进行垃圾收集,这也是它和 Serial 收集器的最大的区别,其他的功能性、配置、策略等等的和 Serial 基本一致。

ParNew有一个比较重要的知识点,在JDK9之后,Java官方取消了ParNew和除了CMS收集器之外的所有老年代收集器的搭配,而且还取消了 - XX:+UseParNewGC 这个参数。所以JDK9之后,ParNew只能和CMS搭配使用了。

ParNew 负责收集新生代区域,它采用标记-复制算法。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第8张图片

ParNew 是JDK7之前 Server VM 模式下的首选的新生代收集器。但是在单CPU的情况下,它的效率不会比 Serial收集器高的,所以要注意使用场景。

5.4、Parallel Scavenge(-XX:+UseParallelGC)

Parallel Scavenge 从外观上看,和 ParNew 很相似,都是新生代的收集器,支持多线程并行回收,也同样是使用标记-复制来作为回收算法。但 Parallel Scavenge 的关注点不一样,它的目标是实现一个可控制吞吐量的垃圾收集器。

吞吐量的计算公式:运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

假设运行用户代码时间是 99 分钟,运行垃圾收集时间是 1 分钟,结合计算公式 :吞吐量 = 99 / (99 + 1) = 0.99,也就是 99% 的吞吐量。

Parallel Scavenge 收集器提供了一些参数,给用户按自身需求控制吞吐量:

-XX:MaxGCPauseMillis
控制垃圾收集停顿的最大时间,单位是毫秒,可以设置一个大于0的数值。
不要想着把这个数值设置得很小来提升垃圾收集的速度,这里缩短的停顿时间是以牺牲新生代空间大小换来的,空间小,回收自然就快,停顿时间自然也短,但是空间小,吞吐量自然也会小。所以得综合考虑。
-XX:GCTimeRatio
设置 垃圾收集时间占比的计算因子,参数范围是0 - 100的整数。它的公式是 1 / (1+GCTimeRatio)
举个栗子:当设置成15,那就是 1 / (1+15) = 0.0625,就是允许最大垃圾收集时间占总时间的6.25%,当设置成99的时候,就是 1 / (1+99) = 0.01,也就是允许最大垃圾收集时间占总时间的1%,依次类推。
-XX:+UseAdaptiveSizePolicy
动态调整开关,这个参数和 Parallel Scavenge 收集器无关,但是搭配起来使用是一个很好的选择。
当这个参数被激活,就不需要人工指定新生代的大小、Eden和Survivor区的比例、对象直接进入老年代的大小等等细节参数了,JVM会根据当前运行的情况动态调整,给出最合适的停顿时间和吞吐量。搭配以上两个参数,和把基本的内存数据设置好即可,例如堆的最大占用空间等等。
5.5、Parallel Old(-XX:+UseParallelOldGC)

就像 Serial Old 是 Serial 的老年代版本一样,Parallel Old 是 Parallel Scavenge 的老年代版本。

Parallel Old 也支持多线程并行回收的能力,使用标记-整理来作为回收算法。这个收集器是JDK6的时候推出的,和 Parallel Scavenge 搭配,在多CPU核心和大内存的场景下,吞吐性能优秀。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第9张图片

在注重吞吐量和多CPU核心的情况下,都可以优先考虑 Parallel Scavenge + Parallelo Old 收集器,这也是JDK8默认的垃圾收集器组合

5.6、CMS (-XX:+UseConcMarkSweepGC)

CMS(Concurrent Mark Sweep) 是JDK1.4后期推出的GC收集器,它是一款并发低停顿的收集器,对于响应速度有较高要求,对停顿时间忍受度低的应用,非常适合使用CMS作为垃圾收集器。

CMS 负责收集老年代区域,它采用标记-清除算法。

它的运行过程相对于前几个来说会复杂一些,可以分为四个步骤:

1、初始标记(CMS initial mark)

这个阶段需要 Stop Tow World(暂停所有用户线程),但这个阶段的速度很快,因为只标记和根节点(GC Roots)直接关联的对象。

2、并发标记(CMS Concurrent mark)

这个阶段不需要 Stop Tow World,在初始标记完成后,并发标记从GC Roots直接关联的对象开始,遍历整个引用链,这个阶段耗时较长,但用户线程可以和GC线程一起并发执行

3、重新标记(CMS remark)

这个阶段需要 Stop Tow World,因为并发标记阶段,用户线程和标间线程同时在运行,相当于一边扫地一边丢垃圾,重新标记就是修正用户线程继续运行,导致的变动的那一部分对象。这一阶段的耗时比初始标记长一些,但远没有达到并发标记阶段那么长的时间。这个阶段可以多线程并行标记。

4、并发清理(Concurrent sweep)

这个阶段不需要 Stop Tow World,执行到这里,说明标记阶段已经完成,此时遍历整个老年代的内存空间,清理掉可回收的对象,由于不需要移动整理存活的对象,这个阶段可以允许用户线程和回收线程并发执行。在清理完成后,会重置CMS收集器的数据结构,等待下一次垃圾回收。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第10张图片

以上4个步骤可以看出,CMS之所以能实现低延迟,是因为它把垃圾搜集分成了几个明确的步骤,在一些耗时较长的阶段实现了用户线程和GC线程并发执行的能力。用两次短暂的 Stop Tow World 来代替了其他收集器一整段长时间的 Stop Tow World

CMS确实是非常优秀的垃圾收集器,但它也是有缺点的:

1、内存碎片。由于使用了 标记-清理 算法,回收结束后会产生大量不连续的内存空间,也就是内存碎片

**2、GC进行时会降低吞吐量。**由于使用了并发处理,很多情况下都是GC线程和应用线程并发执行的,GC线程肯定会占用一部分计算资源,这个期间会降低一部分吞吐量(尽管这样,也比之前几个收集器好很多)。

3、浮动垃圾。CMS有两个阶段是可以用户线程和GC线程并发执行的,用户线程的继续执行自然会伴随垃圾的不断产生,这些就是浮动垃圾。这些垃圾只能等下次触发GC的时候才能清除了,也因为这些浮动垃圾的存在,CMS收集器需要留一手,JDK5的时候,在老年代内存空间使用了68%的时候就会触发一次GC,到了JDK6,觉得JDK5的这个设置太保守了,所以调整到了92%。

可以通过-XX:CMSInitiatingOccupancyFraction 调整这个阈值
5.7、Garbage First(G1)

G1 是 Garbage First 收集器的简称,它在JDK7的时候立项,JDK8 Update 40的时候才全部完工。这个收集器在JDK9 的时候成为了服务端模式下的默认垃圾收集器。

G1 收集器的设计理念是:实现一个停顿时间可控的低延迟垃圾收集器

G1 依然遵循分代回收的设计理论,但它对堆(Java Heap)内存进行了重新布局,不再是简单的按照新生代、老年代分成两个固定大小的区域了,而是把堆区划分成很多个大小相同的区域(Region),新、老年代也不再固定在某个区域了,每一个Region都可以根据运行情况的需要,扮演Eden、Survivor、老年代区域、或者Humongous区域。

大对象会被存储到Humongous区域,G1大多数情况下会把这个区域当作老年代来看待。如果对象占用空间超过Region的容量,就会存放到N个连续的 Humongous Region 中。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第11张图片

G1 收集器的内存空间结构

收集器的运行过程可以大致分成四个步骤:

初始标记(Initial Marking)- Stop Tow World

只标记 GC Roots 能直接关联的对象,还有一些额外的细节操作例如修改TAMS指针的值,保证后续阶段用户程序并发运行的时候,新对象分配在正确的位置。这个阶段需要暂停用户线程,但耗时很短。

并发标记(Concurrent Marking)- No Stop Tow World

从根节点(GC Root)开始,顺着引用链遍历整个堆,找出存活的对象。这个步骤耗时较长,但用户线程可以和GC线程并发执行。

最终标记(Final Marking)- Stop Tow World

处理并发标记阶段,用户线程继续运行产生的引用变动,这个阶段需要暂停用户线程,支持并行处理。

筛选回收(Live Data Counting and Evacuation)- Stop Tow World

根据以上三个阶段标记完成的数据,计算出各个Region的回收价值和成本,再根据用户期望的停顿时间来决定要回收多少个Region。回收使用的是复制算法,把需要回收的这些Region里存活的对象,复制到空闲的Region中,然后清理掉旧Region全部空间。因为需要移动存活的对象,所以不可避免的要暂停用户线程,这个步骤支持多条线程并行回收。

一文读懂“Java性能调优之JVM GC(垃圾回收机制)”_第12张图片
5.8、Q& A

设置停顿时间的参数是什么?

-XX:MaxGCPauseMillis 默认值是 200 毫秒

G1收集器是怎么保证停顿时间可控的?

首先G1把内存区域分成了若干个相同大小的 Region 区,在执行回收的时候,根据标记阶段统计到的数据,计算出各个 Region 区的回收价值和成本,有了这些数据之后,就可以计算出回收哪几个Region价值最高,且符合用户预期的停顿时间。

你可能感兴趣的:(Java,jvm,java,开发语言)