JVM笔记-HotSpot的算法细节实现

1. 根节点枚举

1.1 暂停用户线程

迄今为止,全部收集器在根节点枚举这一步骤都是必须暂停用户线程的。即使是号称停顿时间可控、或者(几乎)不会发生停顿的 CMS、G一、ZGC 等收集器,枚举根节点时也必需要停顿。算法

这也是致使垃圾收集过程必须停顿全部用户线程的一个重要缘由。数组

1.2 如何高效查找引用链

目前主流 JVM 使用的都是准确式垃圾收集,所以虚拟机有办法直接知道哪些地方存放着对象的引用。而 HotSpot 是使用一组称为 OopMap 的数据结构来实现的。缓存

一旦类加载动做完成,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来(在即时编译过程当中,也会在「特定的位置」记录下栈和寄存器中哪些位置是引用),这样收集器在扫描时就能直接得知这些信息,没必要挨个从方法区等 GC Roots 开始查找了。从而能够提升查找效率。安全

普通对象指针:Ordinary Object Pointer, OOPbash

2. 安全点

2.1 概述

前面「特定的位置」记录了引用信息,这些位置被称为安全点(Safepoint)。数据结构

用户程序执行时,并不是在代码指令流的任意位置都能停下来开始垃圾收集,而是强制要求必须执行到安全点后才能暂停。多线程

能够用高速公路上行驶的汽车作类比:高速公路上行驶的汽车(用户线程)不是在任何地方都能停下来的,只有到了服务区(安全点)才能停下。并发

2.2 安全点选取

安全点的选取既不能太多,也不能太少:高并发

  • 若太少,收集器会等待过长时间;性能

  • 若太多,则会过度增长运行时的内存负荷。

安全点位置的选取标准:是否具备让程序长时间执行的特征。什么样的程序会长时间执行呢?

最明显的特征就是指令序列的复用,如方法调用、循环跳转、异常跳转等,只有具有这些功能的指令才会产生安全点。

2.3 如何让线程跑到最近的安全点

垃圾收集发生时,如何让全部线程(不包括 JNI 调用的线程)都跑到最近的安全点、而后停顿下来呢?有以下两种方案能够采用。

2.3.1 抢先式中断
  • 思想:无需用户线程代码配合,垃圾收集时,系统首先把全部用户线程所有中断;此时如有用户线程不在安全点,则恢复执行,直至它到达安全点再中断。

  • 这种方案如今几乎不用了。

2.3.2 主动式中断
  • 思想:垃圾收集须要中断线程时,不直接操做线程,只是设置一个标志位,各个线程执行过程当中不停地主动轮询该标志位,若标志位为真,则在本身最近的安全点主动中断挂起。

  • 轮询标志的地方和安全点是重合的。

如何高效轮询呢?HotSpot 使用内存保护陷阱的方式,经过一条汇编指令来完成安全点轮询和触发线程中断。

2.4 优缺点

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。

可是,没法解决程序“不执行”的状况(好比用户线程处于 Sleep 或者 Blocked 状态),因为此时线程没法响应虚拟机的中断请求,没法再走到安全点挂起本身。

3. 安全区域

3.1 概述

为了解决安全点机制中程序“不执行”的状况,从而引入了安全区域(Safe Region)。

安全区域是指可以确保在某一段代码片断中,引用关系不会发生变化,所以在这个区域中任意地方开始垃圾收集都是安全的。能够理解扩展拉伸的安全点。

3.2 实现思路

当用户线程执行到安全区域里的代码时,会标识本身已经进入了安全区域。

虚拟机发起垃圾收集时,没必要理会已声明在安全区域的线程;而当线程离开安全区域时,会检查虚拟机是否已经完成根节点枚举(或者其余暂停用户线程的阶段):

  • 若完成,则继续执行;

  • 不然就必须等待,直至收到能够离开安全区域的信号。

4. 记忆集与卡表

4.1 跨代引用问题

分代收集理论中,为了解决对象跨代引用所带来的问题,垃圾收集器在新生代创建了名为记忆集(Remembered Set)的数据结构,以免把整个老年代加入 GC Roots 的扫描范围。

实际上,全部涉及部分区域收集(Partial GC)行为的垃圾收集器(例如 G一、ZGC、Shenandoah 等)都会面临一样的问题。

4.2 记忆集

「记忆集」是一种抽象的数据结构,用于记录从「非收集区域」指向「收集区域」的指针集合。

垃圾收集场景中,收集器只需经过记忆集判断出某一块非收集区域是否存在指向收集区域的指针便可,无需了解跨代引用指针的所有细节。

所以,在实现记忆集时,能够采用不一样的记录粒度,以节省记忆集的存储和维护成本,几种精度举例以下:

  • 字长精度:每一个记录精确到一个机器字长(处理器的寻址位数,如常见的 32 位或 64 位),该字包含跨代指针

  • 对象精度:每一个记录精确到一个对象,该对象中有字段包含跨代指针

  • 卡精度:每一个记录精确到一块内存区域,该区域中有对象包含跨代指针

4.3 卡表

4.3.1 记忆集&卡表

其中,上述第三种“卡精度”指的是用一种“卡表(Card Table)”的方式来实现记忆集,也是目前最经常使用的实现方式。

记忆集与卡表的关系:可类比 Java 语言中接口与实现类的关系(好比 Map 与 HashMap)。

4.3.2 卡表&卡页

卡表最简单的形式能够是一个字节数组,数组中的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,该内存块称为“卡页(Card Page)”,它们的关系如图所示:

一个卡页的内存中一般包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在跨代指针,就将对应卡表的数组元素的值标识为 1,称为该元素变脏(Dirty),若无则标识为 0.

4.4 卡表的维护

卡表何时变脏?谁来把它变脏呢?

  • 什么时候:当有其余分代区域中的对象引用了本区域对象时,其对应的卡表元素就应该变脏。

  • 如何变脏:HotSpot 虚拟机是经过写屏障实现的。

下面介绍什么是写屏障。

5. 写屏障

5.1 简述

写屏障(Write Barrier)能够看作在虚拟机层面对“引用类型字段赋值”动做的 AOP 切面,赋值前的写屏障称为“写前屏障(Pre-Write Barrier)”,赋值后的写屏障称为“写后屏障(Post-Write Barrier)”。

应用写屏障后,虚拟机会为全部赋值操做生成相应的指令,一旦收集器在写屏障中增长了更新卡表操做,不管更新的是否是老年代对新生代的引用,每次只要对引用进行更新,就会产生额外的开销。

5.1 伪共享问题

5.1.1 伪共享

除了写屏障的开销,高并发场景下还存在“伪共享(False Sharing)”问题:即,多线程修改互相独立的变量时,若是这些变量刚好共享一个缓存行,会彼此影响而致使性能下降。

该问题是处理底层细节时常常须要考虑的。

5.1.2 如何避免

如何避免“伪共享”问题:不采用无条件写屏障,而是先检查卡表标记,仅当该卡表元素未被标记过期才将其标记为变脏。

若以 SQL 的更新操做(UPDATE)进行类比,则:

  • 无条件写屏障:每次 UPDATE 不加判断,直接更新;

  • 开启判断条件后:每次 UPDATE 前,先执行 SELECT,查询结果知足必定条件时再执行 UPDATE。

若开启该判断条件,能避免伪共享问题;但同时也会增长一次额外判断的开销。所以须要根据实际状况来权衡。

JDK 7 以后,HotSpot 虚拟机增长了以下参数来决定是否开启卡表更新的判断条件:

# 是否开启卡表更新的判断条件
-XX:+UseCondCardMark复制代码

6. 并发的可达性分析

6.1 并发标记问题

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能进行分析,这意味着必须全程冻结用户线程(Stop The World)。

为何必须在一个能保证一致性的快照上才能进行对象图的遍历呢?

  1. 若是用户线程是冻结的,没问题。

  2. 若用户线程没冻结,也就是用户线程与收集器并发工做呢?收集器在对象图标记,同时用户线程在修改引用关系(修改对象图的结构),这样可能出现两种后果:

    1. 把本来消亡的对象错误标记为存活,这种状况虽很差(产生了浮动垃圾),但还能够容忍。

    2. 把本来存活的对象标记为消亡,这就很严重了,程序确定会所以报错。

下面用三色标记(Tri-color Marking)演示这种状况是如何产生的。

6.2 垃圾收集器标记过程

垃圾收集器从 GC Roots 开始标记的过程示意图以下:

上图三色含义:

  • 白色:对象还没有被垃圾收集器访问过(若在分析结束后,对象仍为白色,则表示不可达)

  • 黑色:对象已被垃圾收集器访问过,且该对象全部引用都已被扫描(安全存活的)

  • 灰色:对象已被垃圾收集器访问过,但未扫描完全部引用(即该对象正在被扫描,可理解为中间态)

注意引用是有方向的。

可是,若是在标记过程当中,用户线程对引用关系作了修改,以下:

在上图的(4)中:

  1. 原先对象 A 未引用 C,对象 B 引用了 C;

  2. 但标记到 B 时,用户线程断开了 B 到 C 的引用,而使 A 引用了 C;

  3. 则垃圾收集器标记完成后,C 依然是白色(即会被回收掉);

  4. 对象 DEFG 同理。

这样致使的后果就是:正在被对象 A 和 D 引用的对象 C 和 G,在垃圾收集器标记的过程当中,因为用户线程的运行,致使本应存活的对象被垃圾收集器标记为消亡、并回收了。程序会所以报错,这是个严重的问题。

6.3 如何解决对象消失

如何解决上述“对象消失”的问题呢?理论证实,当且仅当如下两个条件同时知足时,才会产生“对象消失”的问题:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用;

  2. 赋值器删除了所有从灰色对象到该白色对象的直接或间接引用。

针对这两个条件,在上图中,以对象 A、B、C 为例解释以下:

  • 若只增长了 A 对 C 的引用,则 C 在垃圾回收后依然是存活的,不会出错。

  • 若只有 B 断开了对 A 的引用,则 C 在垃圾回收后是消亡的,但并无 A 对 C 的引用,所以也不会出错。

所以,要解决并发扫描时的对象消失问题,只需破坏其中一个便可。由此产生了两种解决方案:增量更新(Increment Update)和原始快照(Snapshot At The Begining, SATB)。

6.3.1 增量更新
  • 思路:破坏第一个条件。

  • 作法:黑色对象(A)插入新的指向白色对象(C)的引用关系(A→C)时,就将这个新插入的引用记录下来,待并发扫描结束以后,再以这些记录过的引用关系中的黑色为根,从新扫描一次。

  • 简化理解:黑色对象一旦新插入了指向白色对象的引用,它就变为灰色(需从新扫描)了。

6.3.2 原始快照
  • 思路:破坏第二个条件。

  • 作法:当灰色对象(B)要删除指向白色对象(C)的引用关系(B→C)时,就将这个要删除的引用记录下来,并发扫描结束后,再以这些记录过的引用关系中的灰色对象为根,从新扫描一次。

  • 简化理解:不管引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照来进行搜索。

这两种方案都有在用:在 HotSpot 虚拟机中,CMS 是基于增量更新来作并发标记的,G一、Shenandoah 则是用原始快照实现的。

6.3.3 举例

以上图为例:在并发扫描时,增长了 A→C 引用,而且删除了 B→C 引用,若不采起任何措施,则扫描结束后对象 C 会消失。

两种解决方案的作法分别以下:

  • 增量更新:将已标记为黑色的对象 A 置为灰色,待并发扫描结束后,从新扫描对象 A。此时能够扫描到 A→C 引用,对象 C 不会消失。

  • 原始快照:若要删除 B→C 引用,则将原始的 B→C 引用记录下来(原始的快照),待并发扫描结束后,从新扫描对象 B,因为记录的是原始信息,其中包含 B→C 引用。这样,即使未扫描到 A→C 引用,对象 C 也不会消失。

此外,不管引用关系记录的插入仍是删除,虚拟机都是经过写屏障实现的。

相关文章
相关标签/搜索