DAY8:你必须知道的java虚拟机之GC篇——HotSpot算法实现

647 阅读15分钟

上节回顾

1.引用计数算法和可达性算法实现思路是怎么样的?

2.方法区的回收效果怎么样?导致的原因能简单说一下吗?

3.简单举例4个能成为GC root的条件

本章提要

本章主要内容是垃圾收集算法和hotspot的算法实现细节,算法大家应该以及很清楚了,本节的主要重心还是放在hotspot的算法实现细节。

一、3种常见的垃圾回收算法

从表现上来看,这三种常见的垃圾回收算法其实很好理解,我个人是通过这三张图片来记住的,多余的大段大段文字其实你理解了的话,看多来反而会被绕晕。借鉴下《深入离家java虚拟机》里的图片

1.标记-清除

特点:实现简单,效率不稳定(取决于需要标记的对象的多少),容易造成空间碎片化,导致大对象无法分配空间。

2.标记-复制

特点:内存对半分,移动存活对象,回收垃圾对象。明显的问题就是空间浪费比较严重,但是实现简单,运行效率较高(前提是大部分对象都需要回收)。

3.标记-整理

特点:相对于标记-清除算法,标记整理多了一步移动存活对象,这样虽然可以避免空间碎片化导致的空间浪费,但是同时也增加了虚拟机的负重,增加“Stop The World”的时间

二、HotSpot算法的实现细节

这是我们本节需要细讲的地方,看懂这一块对理解垃圾回收的整个过程都会有一种豁然开朗的感觉。话不多说直接进入主题吧~

1.根结点枚举

前面一章我们讲过,hotspot的垃圾收集算法用的是可达性分析的方式,也就是查看引用到根结点的一个引用是否可达来判断的,相信同学们应该没有忘记。虽然思路是没错,但是我们的gc内存这么大,包含了数不清的对象和引用,之间的关系也错综复杂,如果每次都去全盘扫描的话效率一定是差强人意的。

目前主流的java虚拟机使用的都是准确式垃圾收集,与准确相对的就是保守型半保守型

保守型

JVM不会记录内存位置上一个数据的类型,我们无法判断我们所要操作gc的是引用还是别的类型,这种情况下所实现的GC方式就是保守型。微软的JScript和早期版VBScript也是用保守式GC的;微软的JVM也是。

保守型的优点是实现简单,缺点也很明显,无法准确的回收已死亡的对象,只要有指针指向死亡对象,这个对象就能逃过本次GC,并且由于可能存在指针指向对象,导致对象移动的时候我同时还得去修改指针的指向,这一点是无法实现的。所以对象也就无法移动了。

半保守型

半保守型通过在中间添加一层“句柄”来解决对象移动的问题。

准确式垃圾收集

就是JVM记录来内存任意位置上的数据的类型,能准确判断这块数据是否是指针。

实现准确式垃圾收集的三种方式

1.让数据自身带上标记(tag),通过获取数据中的标记内容来判断

2.让编译器为每个方法生成特别的扫描代码。

3.通过维护一个映射表来维护对象和类型之间的记录。

我们的hotspot就是通过第三种方式来实现的,对应的映射表在hotspot里面我们叫OopMap。

使用这样的映射表一般有两种方式

1、每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”,这也是hotspot虚拟机使用的方式。

2、为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

代码中每一个变量,每一个对象都有自己的类型,代码会根据safepoint也就是后面要讲的安全点将把代码的指令集分为好几段,每一段指令集就会记录一个OopMap。

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置主要在:

1、循环的末尾

2、方法临返回前/调用方法的call指令后

3、可能抛异常的位置

2.安全点

前面我们也讲过,代码的指令集会被安全点分割为好几段,所以安全点是什么呢?

安全点的选择

安全点的选取是按照“是否具有让程序长时间执行的特征”为标准,也就是说有一些指令能让程序长时间的执行,可能导致无法立马暂停,而别的线程已经暂停了,这时候就会产生新的等待问题。

所以说安全点的选择大体上是在指令序列的复用上面,也就是说在方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

到达安全点的方式

安全点的选取已经没问题了之后,接下来的问题就是,我要发生GC了,现在我如何保证所有的线程能同时到达安全点并且停顿下来,保证我的根结点的选取的顺利完成,毕竟根结点选取的时候会发生”Stop The World“的。一共是两种方案:

1.抢先式中断

抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

2.主动式中断

主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

但是这种频繁的访问就类似于写了一个while(!sign)的无限循环,直到我们的标志为真的时候才会跳出循环,这是一种很消耗性能的一种方式,所以为了保证高效性,HotSpot使用内存保护陷阱的方式, 把轮询操作精简至只有一条汇编指令的程度。也就是说,在JVM内部这个实现其实就是一条指令。

3.安全区域

作用和安全点类似,可以看作安全点的延伸,点到线的第一个过程,相信同学们都可以理解这个意思。

主要解决的就是程序处于Sleep状态或者Blocked状态的时候,无法接受到虚拟机中断请求的时候,这些程序所处的这段时间都是安全区域时间。在安全区域中随时都能进行GC,但是在GC过程中,这些线程是不允许走出安全区域,也就是说GC过程中Sleep的线程无法激活,Blocked的线程无法重新唤醒。

4.记忆集与卡表

记忆集

为了解决跨代引用的问题,引用了记忆集的概念,通俗的来讲,我们需要把不涉及GC区域的指向需要GC区域的一些指针记录下来,在发生部分区域收集的时候通过扫描这个记忆集的表就能够轻易地解决跨代引用的问题。

记忆集提供的只是一种思想,可以理解为是一个接口,卡表就是记忆集的一种具体实现方式。

卡表

卡表记录的精度是卡精度,也就是说每一个记录都是精确到一块内存区域,并且该内存区域的对象存在跨代指针,这一块内存区域可以看作是一张卡页

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑。

CARD_TABLE [this address >> 9] = 0;

CARD_TABLE就是我们的卡表数组,里面的元素就是每一块特定大小的内存区域所确定的卡页

卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节也就是说假如卡表的内存起始地址是0x0000,那么每一块卡页对应的内存地址分别就是0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF、、、、

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

5.写屏障

接着上面的逻辑,如果存在跨代引用,那么我就用卡表来维护一个一个的卡页来记录每一块内存中是否存在指向其他区域的指针就可以来。但是这个卡页要这么来维护呢???

此时同学们一定感觉被无限套娃,俄罗斯无限套娃儿~~~

为什么每次都是告诉我:你要这样去做,懂了吧,很简单的嘛,今天晚上应该能改完了吧。但是每次到我上手的时候才发现这个坑到底有多deep~~~可是为什么我每次都信了

痛苦面具一戴,从此谁都不爱

好了啊,戏精细胞收一收,收一收~


这里的写屏障大家需要和我们之前讲过的volitile的内存屏障来保证有序性进行区分,两个不是一个东西,两个不是一个东西!!!

这里的写屏障分为两种:

1.在赋值前的部分的写屏障叫作写前屏障

2.在赋值后的则叫作写后屏障

在G1收集器出现之前,其他收集器都是用的写后屏障

写后屏障的主要逻辑是这样的

void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

大家可以思考一下为什么每次都要更新?不每次更新需要怎么实现?

写屏障除了增加额外的开销外,为了让写屏障的效率高一些,这里在高并发场景下还面临着伪共享的问题。

什么叫伪共享 其实是应为增加cpu的读写效率,而设计的一个缓存行导致的。

在计算机原理中,一个缓存行可能存在多个卡表,每个卡表有多个卡页,所以现在有CPU1,CPU2,分别读取同一个缓存行的卡表A和卡表B,CPU1把卡表A的值修改了,并同步到缓存行,这时候虽然CPU2也读取到了卡表A和卡表B的内容,但是由于卡表A的值已经失效了,所以需要重新去主存中去读取卡表A和卡表B,但是其实我CPU2只关注卡表B,由于卡表A和卡表B处在同一个缓存行的缘故,导致了多读了一次主存,导致的性能问题。

但是为了解决这个问题呢JDK7用了一种以毒攻毒的方法,增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。虽然避免了伪共享的问题,但是同样也会额外增加一次额外判断的开销,所以“罪魁祸首”就是缓存行的设计。

当然凡是设计没有十全十美的,必有取舍,既然缓存行这个概念还存在着,那么其优点一定大于其缺点。

6.黑灰白三色标记

在可达性分析的算法中,我们知道根结点选举的过程中,所有线程会到达安全点,并暂停等到根结点的选举,发生所谓的“Stop The World”,但是根结点结束之后呢?

很多情况下minor GC是无感知的,所以说根结点之后的引用判断我们可以大胆猜测,线程绝对不是处于暂停状态的。这一点不知道同学们认不认同。

对象消失

所以多线程并发的情况下,不同对象之间的引用判断一旦判断失误就会出现两种情况:

1.把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。

2.把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

最可怕的是第二点,大概的结果就是我报警我杀了我自己

我们用《深入离家java虚拟机》中的黑灰白三色球来描述一下这一过程

黑球:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。

灰球:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

白球:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

对象消失的必要条件

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题:

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

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

所以想要解决对象消失问题只要破坏其中任意一个条件就可了,这样就产生了两种方案:

1.增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。

2.原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来 进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新 来做并发标记的,G1、Shenandoah则是用原始快照来实现。


能看到这句话的同学绝非常人~厉害厉害,这一章的内容相对来说比较干涩,希望同学们能反复阅读加深印象。最后别忘了点赞👍+关注哦~~