写点什么

「一篇终结 JVM」:Java 面试必问十个 JVM 核心知识点梳理

  • 2022 年 8 月 03 日
  • 本文字数:10184 字

    阅读完需:约 33 分钟

「一篇终结JVM」:Java面试必问十个JVM核心知识点梳理

想要提高程序员自身的内功心法无非就是数据结构跟算法 + 操作系统 + 计网 + 底层 ,而所有的 Java 代码都是在 JVM 上运行的,了解了 JVM 好处就是:

  • 写出更好更健壮的代码。

  • 提高 Java 的性能,排除问题。

  • 面试必问 ,要对知识有一定的深度 。

1、简述 JVM 内存模型


从宏观上来说 JVM 内存区域 分为三部分 线程共享区域 、 线程私有区域 、 直接内存区域 。

1.1、线程共享区域

1.1.1、堆区

堆区 Heap 是 JVM 中最大的一块内存区域,基本上所有的对象实例都是在堆上分配空间。堆区细分为 年轻代 和 老年代 ,其中年轻代又分为 Eden、S0、S1 三个部分,他们默认的比例是 8:1:1 的大小。

1.1.1 元空间

方法区:

  1. 在 《Java 虚拟机规范》中只是规定了有 方法区 这么个 概念 跟它的 作用 。 HotSpot 在 JDK8 之前 搞了个 永久代 把这个概念实现了。用来主要存储类信息、常量池、静态变量、JIT 编译后的代码等数据。

  2. PermGen(永久代)中类的元数据信息在每次 FullGC 的时候可能会被收集,但成绩很难令人满意。而且为 PermGen 分配多大的空间因为存储上述多种数据很难确定大小。因此官方在 JDK8 提出移除永久代。

官方解释移除永久代:

  1. This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.

  2. 即:移除永久代是为融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代。

元空间:

在 Java 中用 永久代 来存储类信息,常量,静态变量等数据不是好办法,因为这样很容易造成内存溢出。同时对永久代的性能调优也很困难,因此在 JDK8 中 把 永久代 去除了,引入了元空间 metaspace ,原先的 class、field 等变量放入到 metaspace。

总结:

元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现 。不过元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中,而是使用本地内存 。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过参数来指定元空间的大小。

1.2、直接内存区域

直接内存:

一般使用 Native 函数操作 C++代码来实现直接分配堆外内存,不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。这块内存不受 Java 堆空间大小的限制,但是受本机总内存大小限制所以也会出现 OOM 异常。分配空间后 避免了在 Java 堆区跟 Native 堆中来回复制数据 ,可以有效提高读写效率, 但它的创建、销毁却比普通 Buffer 慢 。

PS: 如果使用了 NIO ,本地内存区域会被频繁地使用,此时 jvm 内存 ≈ 方法区 + 对 + 栈+ 直接内存

1.3、线程私有区域

程序计数器、虚拟机栈、本地方法栈跟线程的声明周期是一样的。

1.3.1、程序计数器

课堂上比如你正在看小说《诛仙》,看到 1412 章节时,老师喊你回答问题,这个时候你肯定要先应付老师的问题,回答完毕后继续接着看,这个时候你可以用书签也可以凭借记忆记住自己在看的位置,通过这样实现继续阅读。

落实到代码运行时候同样道理, 程序计数器 用于记录当前线程下虚拟机正在执行的字节码的指令地址。它具有如下特性:

  1. 线程私有

多线程情况下,在同一时刻所以为了让线程切换后依然能恢复到原位,每条线程都需要有各自独立的程序计数器。

  1. 没有规定 OutOfMemoryError

程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存。

  1. 执行 Native 方法时值为空

Native 方法大多是通过 C 实现并未编译成需要执行的字节码指令,也就不需要去存储字节码文件的行号了。

1.3.2、虚拟机栈

方法的出入栈:调用的方法会被打包成 栈桢 ,一个栈桢至少需要包含一个局部变量表、操作数栈、桢数据区、动态链接。


动态链接:

当栈帧内部包含一个指向运行时常量池引用前提下,类加载时候会进行符号引用到直接引用的解析跟链接替换。

局部变量表:

局部变量表是栈帧重要组中部分之一。他主要保存函数的参数以及局部的变量信息。局部变量表中的变量作用域是当前调用的函数。函数调用结束后,随着函数栈帧的销毁。局部变量表也会随之销毁,释放空间。

操作数栈:

保存着 Java 虚拟机执行过程中数据

方法返回地址:

方法被调用的位置,当方法退出时候实际上等同于当前栈帧出栈。

比如执行简单加减法:

public class ShowByteCode {    private String xx;    private static final int TEST = 1;    public ShowByteCode() {    }    public int calc() {        int a = 100;        int b = 200;        int c = 300;        return (a + b) * c;    }}复制代码
复制代码

执行 javap -c *.class :


1.3.3、本地方法栈

跟虚拟机栈类似,只是为使用到的 Native 方法服务而已。

2、判断对象是否存活

JVM 空间不够就需要 Garbage Collection 了,一般共享区的都要被回收比如堆区以及方法区。在进行内存回收之前要做的事情就是 判断那些对象是死的,哪些是活的 。常用方法有两种 引用计数法 跟 可达性分析 。

2.1、引用计数法

思路是给 Java 对象添加一个引用计数器,每当有一个地方引用它时,计数器 +1;引用失效则 -1,当计数器不为 0 时,判断该对象存活;否则判断为死亡(计数器 = 0)。

优点:

实现简单,判断高效。

缺点:

无法解决 对象间 相互循环引用 的问题

class GcObject {    public Object instance = null;}public class GcDemo {    public static void main(String[] args) {        GcObject object1 = new GcObject(); // step 1         GcObject object2 = new GcObject(); // step 2                object1.instance = object2 ;//step 3        object2.instance = object1; //step 4                object1 = null; //step 5        object2 = null; // step 6    }}复制代码
复制代码
  • step1: GcObject 实例 1 的引用计数+1,实例 1 引用数 = 1

  • step2: GcObject 实例 2 的引用计数+1,实例 2 引用数 = 1

  • step3: GcObject 实例 2 的引用计数+1,实例 2 引用数 = 2

  • step4: GcObject 实例 1 的引用计数+1,实例 1 引用数 = 2

  • step5: GcObject 实例 1 的引用计数-1,结果为 1

  • step6: GcObject 实例 2 的引用计数-1,结果为 1

如上分析发现实例 1 跟实例 2 的引用数都不为 0 而又相互引用,这两个实例所占有的内存则无法释放。

2.2、可达性分析

很多主流商用语言(如 Java、C#)都采用 引用链法 判断对象是否存活,大致的思路就是将一系列的 GC Roots 对象作为起点,从这些起点开始向下搜索。在 Java 语言中,可作为 GC Roots 的对象包含以下几种:

  1. 第一种是 虚拟机栈中的引用的对象 ,在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的。

  2. 第二种是我们 在类中定义了全局的静态的对象 ,也就是使用了 static 关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为 GC Roots 是必须的。

  3. 第三种便是 常量引用 ,就是使用了 static final 关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为 GC Roots。

  4. 第四种是在使用 JNI 技术时,有时候单纯的 Java 代码并不能满足我们的需求,我们可能需要在 Java 中调用 C 或 C++的代码,因此会使用 Native 方法 ,JVM 内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为 GC Roots。

GC Root 步骤主要包含如下三步:

2.1.1 可达性分析


当一个对象到 GC Roots 没有任何引用链相连时 ,则判断该对象不可达。

注意: 可达性分析仅仅只是判断对象是否可达,但还不足以判断对象是否存活 / 死亡。

2.1.2 第一次标记 & 筛选

筛选的条件对象 如果没有重写 finalize 或者调用过 finalize 则将该对象加入到 F-Queue 中

2.1.3 第二次标记 & 筛选

当对象经过了第一次的标记 & 筛选,会被进行第二次标记 & 准备被进行筛选。 经过 F-Queue 筛选后如果对象还没有跟 GC Root 建立引用关系则被回收 ,属于给个二次机会。


2.3、四大引用类型

2.3.1 强引用

强引用(StrongReference)是使用最普遍的引用。垃圾回收器绝对不会回收它,内存不足时宁愿抛出 OOM 导致程序异常,平常的 new 对象就是。

2.3.2 软引用

垃圾回收器在内存充足时不会回收软引用(SoftReference)对象,不足时会回收它,特别适合用于创建缓存。

2.3.3 弱引用

弱引用(WeakReference)是在扫描到该对象时无论内存是否充足都会回收该对象。 ThreadLocal 的 Key 就是弱引用。

2.3.4 虚引用

如果一个对象只具有虚引用(PhantomReference)那么跟没有任何引用一样,任何适合都可以被回收。主要用跟踪对象跟垃圾回收器回收的活动。

3、垃圾回收算法

为了挥手回收垃圾操作系统一般会使用 标记清除 、 复制算法 、 标记整理 三种算法,这三种各有优劣,简单介绍下:

3.1、标记清除


原理:

算法分为 标记 和 清除 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

缺点:

标记清除之后会产生大量不连续的内存碎片,导致触发 GC。

3.2、标记复制


原理:

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

缺点:

这种算法的代价是将内存缩小为了原来的一半,还要来回移动数据。

3.3、标记整理


原理:

首先标记出所有需要回收的对象,在标记完成后,后续步骤是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

缺点:

涉及到移动大量对象,效率不高。

总结:

3.4 、三色标记跟读写屏障

前面说的三种回收算法都说到了先 标记 ,问题是如何标记的呢? 说话说一半,小心没老伴 

接下来的知识点个人感觉面试应该问不到那么深了,但是为了 必须 Mark 下 ! CMS 、 G1 标记时候一般用的是 三色标记法 ,根据可达性分析从 GC Roots 开始进行遍历访问,可达的则为存活对象,而最终不可达说明就是需要被 GC 对象。大致流程是把遍历对象图过程中遇到的对象,按 是否访问过 这个条件标记成以下三种颜色:

  • 白色:尚未访问过。

  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。

  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完 。全部访问后会转换为黑色。


假设现在有白、灰、黑三个集合(表示当前对象的颜色),遍历访问过程:

  • 1、初始时所有对象都在白色集合中。

  • 2、将 GC Roots 直接引用到的对象挪到灰色集合中。

  • 3、从灰色集合中获取对象:第一步将本对象 引用到的 其他对象 全部挪到灰色集合中,第二步将本对象 挪到黑色集合里面。

  • 4、重复步骤 3,直至灰色集合为空时结束。

  • 5、结束后仍在白色集合的对象即为 GC Roots 不可达 ,可以尝试进行回收。

当 STW 时对象间的引用是不会发生变化的,可以轻松完成标记。当支持并发标记时,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

3.4 .1、浮动垃圾

状况:GC 线程遍历到 E(E 是灰色),一个业务线程执行了 D.E = null,此时 E 应该被回收的。但是 GC 线程已经认为 E 是灰色了会继续遍历,导致 E 没有被回收。


3.4 .2、漏标


GC 线程遍历到 E(灰色了)。业务线程执行了 E–>G 断开,D–>G 链接的操作。GC 线程发现 E 无法到达 G,因为是黑色不会再遍历标记了。最终导致漏标 G。

漏标的必备两个条件: 灰到白断开 , 黑到白建立 。

Object G = E.G;    // 第一步 :读Object E.G = null; // 第二步:写Object D.G = G;   // 第三步:写复制代码
复制代码

漏标解决方法:

将对象 G 存储到特定集合中,等并发标记遍历完毕后再对集合中对象进行 重新标记 。

3.4.2.1、CMS 方案

这里比如开始 B 指向 C,但是后来 B 不指向 C,A 指向 D,最简单的方法是 将 A 变成灰色 ,等待下次进行再次遍历。


CMS 中可能引发 ABA 问题:

1、回收线程 m1 正在标记 A,属性 A.1 标记完毕,正在标记属性 A.2。

2、业务线程 m2 把属性 1 指向了 C,由于 CMS 方案此时回收线程 m3 把 A 标记位灰色。

3、回收线程 m1 认为所有属性标记完毕,将 A 设置为黑色,结果 C 漏标。所以 CMS 阶段需要重新标记。


3.4.2.2、读写屏障

漏标的实现是有三步的,JVM 加入了读写屏障,其中读屏障则是拦截第一步,写屏障用于拦截第二和第三步。

写屏障 + SATB(原始快照) 来破坏 灰到白断开。

写屏障 + 增量更新 来破坏 黑到白建立。

读屏障 一种保守方式来破坏灰到白断开后白的存储,此时用读屏障 OK 的。

现代使用可达性分析的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同。对于读写屏障,以 Java HotSpot VM 为例,其 并发标记时对漏标 的处理方案如下:

CMS : 写屏障 + 增量更新

G1 : 写屏障 + SATB

ZGC : 读屏障

CMS 中使用的增量更新,在重新标记阶段除了需要遍历 写屏障的记录,还 需要重新扫描遍历 GC Roots(标记过的不用再标记),这是由于 CMS 对于 astore_x 等指令不添加写屏障的原因。

4、GC 流程

核心思想就是 根据各个年代的特点不同选用不同到垃圾收集算法 。

  1. 年轻代 :使用 复制算法

  2. 老年代 : 使用 标记整理 或者 标记清除 算法。

为什么要有年轻代:

分代的好处就是 优化 GC 性能 ,如果没有分代每次扫描所有区域能累死 GC。因为很多对象几乎就是 朝生夕死 的,如果分代的话,我们把新创建的对象放到某一地方,当 GC 的时候先把这块存 朝生夕死 (80%以上)对象的区域进行回收,这样就会腾出很大的空间出来。

4.1、 年轻代

HotSpot JVM 把年轻代分为了三部分:1 个 Eden 区和 2 个 Survivor 区(分别叫 from 和 to )。默认比例为 8:1:1 。一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC 年龄就会增加 1 岁,当它的年龄增加到一定次数(默认 15 次)时,就会被移动到年老代中。年轻代的垃圾回收算法使用的是 复制算法 。


年轻代 GC 过程:

GC 开始前,年轻代对象只会存在于 Eden 区和名为 From 的 Survivor 区,名为 To 的 Survivor 区永远是空的。如果新分配对象在 Eden 申请空间发现不足就会导致 GC。

yang GC : Eden 区中所有存活的对象都会被复制到 To ,而在 From 区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值可以通过 -XX:MaxTenuringThreshold 来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到 To 区域。经过这次 GC 后, Eden 区和 From 区已经被清空。这个时候, From 和 To 会交换他们的角色,也就是新的 To 就是上次 GC 前的 From ,新的 From 就是上次 GC 前的 To 。不管怎样 都会保证名为 To 的 Survivor 区域是空的 。 Minor GC 会一直重复这样的过程,直到 To 区被填满, To 区被填满之后,会将所有对象移动到年老代中。这里注意如果 yang GC 后空间还是不够用则会 空间担保 机制将数据送到 Old 区

卡表 Card Table:

  1. 为了支持高频率的新生代回收 ,虚拟机使用一种叫做 卡表 (Card Table)的数据结构,卡表作为一个比特位的集合, 每一个比特位可以用来表示年老代的某一区域中的所有对象是否持有新生代对象的引用 。

  2. 新生代 GC 时不用花大量的时间扫描所有年老代对象,来确定每一个对象的引用关系, 先扫描卡表 ,只有卡表的标记位为 1 时,才需要扫描给定区域的年老代对象。而卡表位为 0 的所在区域的年老代对象,一定不包含有对新生代的引用。

4.2、 老年代

老年代 GC 过程:

老年代中存放的对象是存活了很久的,年龄大于 15 的对象 或者 触发了老年代的 分配担保 机制存储的大对象。在老年代触发的 gc 叫 major gc 也叫 full gc 。 full gc 会包含年轻代的 gc 。 full gc 采用的是 标记-清除 或 标记整理 。在执行 full gc 的情况下,会阻塞程序的正常运行。老年代的 gc 比年轻代的 gc 效率上 慢 10 倍以上 。对效率有很大的影响。所以 一定要尽量避免老年代 GC !

4.3、 元空间

永久代的回收会随着 full gc 进行移动,消耗性能。每种类型的垃圾回收都需要特殊处理 元数据。将元数据剥离出来,简化了垃圾收集,提高了效率。

-XX:MetaspaceSize 初始空间的大小。达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:

如果释放了大量的空间,就适当降低该值;

如果释放了很少的空间,那么在不超过 MaxMetaspaceSize 时,适当提高该值。

-XX:MaxMetaspaceSize:

最大空间,默认是没有限制的。

4.4 、垃圾回收流程总结


大致的 GC 回收流程 如 上图 ,还有一种设置就是 大对象直接进入老年代 :

  1. 如果在新生代分配失败且对象是一个不含任何对象引用的大数组,可被直接分配到老年代。通过在老年代的分配避免新生代的一次垃圾回收。

  2. 设置了-XX:PretenureSizeThreshold 值,任何比这个值大的对象都不会尝试在新生代分配,将在老年代分配内存。

内存回收跟分配策略

  1. 优先在 Eden 上分配对象,此区域垃圾回收频繁速度还快。

  2. 大对象直接进入老生代。

  3. 年长者(长期存活对象默认 15 次)跟 进入老生代。

  4. 在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象会群体进入老生代。

  5. 空间分配担保(担保 minorGC),如果 Minor GC 后 Survivor 区放不下新生代仍存活的对象,把 Suvivor 无法容纳的对象直接进入老年代。

5、垃圾收集器

5.1、 垃圾收集器

堆 heap 是垃圾回收机制的重点区域。我们知道垃圾回收机制有三种 minor gc 、 major gc 和 full g c。针对于堆的就是前两种。年轻代的叫 minor gc ,老年代的叫 major gc 。

  1. JDK7、JDK8 默认垃圾收集器 Parallel Scavenge(新生代)+ Parallel Old(老年代)

  2. JDK9 默认垃圾收集器 G1 ,服务端开发常见组合就是 ParNew + CMS


工程化使用的时候使用指定的垃圾收集器组合使用,讲解垃圾收集器前先普及几个重要知识点:

STW

java 中 Stop-The-World 机制简称 STW,是指执行垃圾收集算法时 Java 应用程序的 其他所有线程都被挂起 (除了垃圾收集帮助器之外)。是 Java 中一种全局暂停现象,全局停顿,所有 Java 代码停止,native 代码虽然可以执行但不能与 JVM 交互,如果发生了 STW 现象多半是由于 gc 引起 。

吞吐量

吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99%

垃圾收集时间

垃圾回收频率 * 单次垃圾回收时间

并行收集

指多条垃圾收集线程并行工作,但此时用户线程仍 处于等待状态 。

并发收集

指 用户线程与垃圾收集线程同时工作 (不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上。

5.2、 新生代

新生代有 Serial 、 ParNew 、 Parallel Scavenge 三种垃圾收集器。

5.3、 老年代

老年代有 Serial Old 、 Parallel Old 、 CMS 三种垃圾收集器。

5.3.1、CMS

CMS (Concurrent Mark Sweep)比较重要这里 重点说一下 。

CMS 的初衷和目的:

为了消除 Throught 收集器和 Serial 收集器在 Full GC 周期中的长时间停顿。是一种 以获取最短回收停顿时间为目标 的收集器,具有自适应调整策略,适合互联网站 跟 B/S 服务应用。

CMS 的适用场景:

如果你的应用需要 更快的响应 ,不希望有长时间的停顿,同时你的 CPU 资源也比较丰富 ,就适合使用 CMS 收集器。比如常见的 Server 端任务。

优点:

并发收集、低停顿。

缺点:

  1. CMS 收集器对 CPU 资源非常敏感 :在并发阶段,虽然不会导致用户线程停顿,但是会占用 CPU 资源而导致引用程序变慢,总吞吐量下降。

  2. 无法处理浮动垃圾 :由于 CMS 并发清理阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在本次收集中处理它们,只好留待下一次 GC 时将其清理掉。这一部分垃圾称为 浮动垃圾 。 如果内存放不下浮动垃圾这时 JVM 启动 Serial Old 替代 CMS 。

  3. 空间碎片 :CMS 是基于 标记-清除 算法实现的收集器,使用 标记-清除 算法收集后,会产生 大量碎片 。

CMS 回收流程:

  1. 初始标记 : 引发 STW , 仅仅只是标记出 GC ROOTS 能直接关联到的对象,速度很快。

  2. 并发标记 : 不引发 STW ,正常运行 所有 Old 对象是否可链到 GC Roots

  3. 重新标记 : 引发 STW ,为了修正并发标记期间,因用户程序继续运作而导致标记产生改变的标记。这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。

  4. 并发清除 : 不引发 STW ,正常运行,标记清除算法来清理删除掉标记阶段判断的已经死亡的对象。

总结:

  1. 并发标记 和 并发清除 的耗时最长但是不需要停止用户线程。 初始标记 和 重新标记 的耗时较短,但是需要停止用户线程,所以整个 GC 过程造成的停顿时间较短,大部分时候是可以和用户线程一起工作的。

之前的 GC 收集器对 Heap 的划分:


以前垃圾回收器是 新生代 + 老年代 ,用了 CMS 效果也不是很好,为了减少 STW 对系统的影响引入了 G1(Garbage-First Garbage Collector), G1 是一款面向服务端应用的垃圾收集器,具有如下特点:

1、 并行与并发 :G1 能充分利用多 CPU、多核环境下的硬件优势,可以通过并发的方式让 Java 程序继续执行。

2、 分代收集 :分代概念在 G1 中依然得以保留,它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象来获得更好的收集效果。

3、 空间整合 :G1 从整体上看是基于 标记-整理 算法实现的,从局部(两个 Region 之间)上看是基于 复制算法 实现的,G1 运行期间不会产生内存空间碎片。

4、 可预测停顿 :G1 比 CMS 牛在能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 作为 JDK9 之后的服务端默认收集器,不再区分年轻代和老年代进行垃圾回收,G1 默认把堆内存分为 N 个分区,每个 1~32M(总是 2 的幂次方)。并且提供了四种不同 Region 标签 Eden 、 Survivor 、 Old 、 Humongous 。H 区可以认为是 Old 区中一种特别专门用来存储大数据的,关于 H 区数据存储类型一般符合下面条件:

当 0.5 Region <= 当对象大小 <= 1 Region 时候将数据存储到 H 区

当对象大小 > 1 Region 存储到连续的 H 区。


同时 G1 中引入了 RememberSets 、 CollectionSets 帮助更好的执行 GC 。

1、 RememberSets : RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象)

2、 CollectionSets : Csets 是一次 GC 中需要被清理的 regions 集合,注意 G1 每次 GC 不是全部 region 都参与的,可能只清理少数几个,这几个就被叫做 Csets。在 GC 的时候,对于 old -> young 和 old -> old 的跨代对象引用,只要扫描对应的 CSet 中的 RSet 即可。

G1 进行 GC 的时候一般分为 Yang GC 跟 Mixed GC 。

Young GC : CSet 就是所有年轻代里面的 Region

Mixed GC : CSet 是所有年轻代里的 Region 加上在全局并发标记阶段标记出来的收益高的 Region

5.4.1、Yang GC


标准的年轻代 GC 算法,整体思路跟 CMS 中类似。

5.4.2、Mixed GC

G1 中是  有 Old GC 的,有一个把老年代跟新生代同时 GC 的 Mixed GC,它的 回收流程 :

1、 初始标记 : 是 STW 事件 ,其完成工作是标记 GC ROOTS 直接可达的对象。标记位 RootRegion。

2、 根区域扫描 : 不是 STW 事件 ,拿来 RootRegion,扫描整个 Old 区所有 Region,看每个 Region 的 Rset 中是否有 RootRegion。有则标识出来。

3、 并发标记 : 同 CMS 并发标记 不需要 STW ,遍历范围减少,在此只需要遍历 第二步 被标记到引用老年代的对象 RSet。

4、 最终标记 : 同 CMS 重新标记 会 STW ,用的 SATB 操作,速度更快。

5、 清除 : STW 操作 ,用 复制清理算法 ,清点出有存活对象的 Region 和没有存活对象的 Region(Empty Region),更新 Rset。把 Empty Region 收集起来到可分配 Region 队列。

回收总结:

1、经过 global concurrent marking,collector 就知道哪些 Region 有存活的对象。并将那些完全可回收的 Region(没有存活对象)收集起来加入到可分配 Region 队列,实现对该部分内存的回收。对于有存活对象的 Region,G1 会根据统计模型找出收益最高、开销不超过用户指定的上限的若干 Region 进行对象回收。这些选中被回收的 Region 组成的集合就叫做 collection set 简称 Cset!

2、在 MIX GC 中的 Cset = 所有年轻代里的 region + 根据 global concurrent marking 统计得出收集收益高的若干 old region 。

3、在 YGC 中的 Cset = 所有年轻代里的 region + 通过控制年轻代的 region 个数来控制 young GC 的开销 。

4、YGC 与 MIXGC 都是采用多线程复制清理,整个过程会 STW。 G1 的 低延迟原理 在于其回收的区域变得精确并且范围变小了。

G1 提速点:

重新标记 使 X 区域直接删除。

Rset 降低了扫描的范围,上题中两点。

3 重新标记阶段使用 SATB 速度比 CMS 快。

4 清理过程为选取部分存活率低的 Region 进行清理,不是全部,提高了清理的效率。

总结:

就像你妈让你把自己卧室打扫干净,你可能只把显眼而比较大的垃圾打扫了,犄角旮旯的你没打扫。关于 G1 还有很多细节其实没看到也。一句话总结 G1 思维: 每次选择性的清理大部分垃圾来保证时效性跟系统的正常运行 。

由于篇幅的限制,还有 5 个知识点没有罗列出来,希望可以对大家学习 JVM 有帮助,喜欢的小伙伴可以帮忙转发+关注,感谢大家!

原文链接:https://www.tuicool.com/articles/RVj2qiz

用户头像

需要资料添加小助理vx:bjmsb1226 2021.10.15 加入

爱生活爱编程

评论

发布
暂无评论
「一篇终结JVM」:Java面试必问十个JVM核心知识点梳理_Java_Java全栈架构师_InfoQ写作社区