JVM(三):HotSpot的算法细节实现

回顾

前面一文了解了垃圾回收如何进行标记,并且认识了三种常用的垃圾清除算法

  • 标记清除
  • 标记复制
  • 标记整理

下面就来看下在Java虚拟机是如何来实现这些算法的

根节点枚举

根节点枚举是优化可达性分析算法中从GC Roots集合找引用链操作的,也就是优化判断是否存活的操作,根节点指的就是GC Roots集合中的GC Root

作为GC Roots的节点主要为一下两点

  • 全局性的引用,比如常量和类常量
  • 执行上下文,比如栈帧中的本地变量表

虽然GC Roots的目标很明确,但仅仅就这两点,可能GC Roots集合中就有很多个对象了,此时检查整个GC Roots就称为是在进行根节点枚举

根节点枚举有两个大问题

  • GC Roots集合很大,里面对象很多,逐个去检查引用是很耗费性能的,逐个去进行检查就是保守式枚举
  • 保证并发安全,在根节点枚举过程中,如何确保进行标记时,GC Roots下面的那些引用链不发生变化

对于第二个问题,根节点是通过暂停用户线程来保证的,但对于第一个问题没有解决,暂停用户线程来保证了并发安全,但由于GC Roots集合很大,检查引用需要耗费很高的时间成本,此时就发生了Stop the world现象,与标记整理方法类似(移去一端的过程中也会发生Stop The World)

目前Java虚拟机使用的都是准确式垃圾回收,这个方法的优点是,当用户线程停顿下来之后,其实并不需要对整个GC Roots集合进行检查,即不会对所有的全局引用位置和执行上下文进行检查,虚拟机有办法可以直接得到哪些地方存放着对象引用的

准确式垃圾回收

上面提到了,对于根节点枚举的性能优化,Java采用的是准确式垃圾回收来解决的

HotSpot使用一组称为OopMap的数据结构来达到这个目的,一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据给计算出来,在即时编译的过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用,也就是说OopMap这个数据结构会记录下拥有引用链的GC Root节点,这样收集器在扫描时就可以通过OopMap去直接得知这些信息了,并不需要对整个GC Roots来进行查找,避免了全局扫描

拓展:oop是面向对象的意思

安全点(SafePoint)

现在已经使用oop来提高了根节点枚举的性能,但同时也带来了消耗,每一次垃圾回收了之后,都要去维护oopMap,或者对象的引用关系变换了,也要去维护oopMap

如果每一个指令都去生成新的oopMap,那么这将会耗费大量的额外空间,这是不可取的

所以,Java虚拟机只有在安全点上会去生成新的oopMap

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始进行垃圾收集,而是强制要求必须执行到安全点后才能够暂停

安全点的数量选定也要合理,不能太少,太少会导致垃圾回收器等待的时间过长;也不能太多,太多会导致垃圾回收器太过频繁,要知道垃圾回收进行的时候是会停止线程的,所以垃圾回收器执行的太过频繁是会降低服务效率并且会过分地增大运行时的内存负荷(垃圾回收期也要消耗CPU)

而安全点的位置是根据是否具有让程序长时间执行的特征为标准来进行选定的,为什么要根据这个去进行判断呢?

这是因为HotSpot在进行GC的时候,垃圾收集器必须要等待所有的线程都进入到安全点才可以进行GC,所以必须在需要耗费长时间执行的程序里添加安全点,否则垃圾收集器需要等待很长的时间

所以JVM一般都会在指令复用的地方添加安全点,此时safePoint位置就保证了oopMap一定是准确的

  • 方法调用:方法返回之前,调用方法之后
  • 循环跳转:循环的末尾
  • 异常跳转:抛异常的地方,因为抛异常要交给后续处理的,所以也要等待长时间

缩减垃圾收集器等待时间

上面提到过,JVM必须要等待所有的线程都进入到安全点,才可以进行GC,那么JVM除了在需要耗费长时间执行的地方选定为安全点(在指令序列复用的地方选定为安全点)之外,还有没有其他措施来缩减垃圾收集器等待时间?即如何让所有的线程(不包括JNI调用的线程)都跑到最近的安全点,然后停顿下来

hotspot提供了两种解决方案

  • 抢先式中断
  • 主动式中断

抢先式中断是指,不需要线程执行代码主动去配合,在垃圾收集发生的时候,JVM首先会把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就会去恢复这条线程执行,让他到安全点上再进行中断,这里的中断不是设置标志位,而是强行中断

主动式中断是指:需要线程去进行配合,每个线程都有一个标志位,当垃圾收集发生的时候,JVM会将每个线程的该标志位进行设置,各个线程会对这个标志位进行轮询操作,一旦发生这个标志位被设置了,该线程就会主动在自己最近的安全点上进行挂起

那主动式中断什么时候对这个标志位进行轮询操作呢?

标志位轮询的地方与安全点是重合的,也就是说,只要线程附近有安全点出现,马上进行轮询,判断需不需要停在该安全点上,而且不单单是安全点上,而且在所有创建对象和需要在Java堆上分配内存的地方,也要进行标志位轮询,这是因为创建对象或者在Java堆上分配内存意味着要对OopMap进行维护了,进行GC时是不能对OopMap进行修改的,所以此时一定要关注是否需要进行GC

保证轮询操作的原子性

因为轮询操作是频繁中出现的并且还很重要,为了提高轮询操作的效率,也就是说提高轮询操作完成的优先级,就要保证轮询操作的原子性

HotSpot采用内存保护陷阱的方式,来将轮询操作变成一个原子性的操作

拓展:关于这个内存保护陷阱的方式,好像是采用自陷异常来实现的,也就是线程发起自陷异常信号来打断当前执行的程序,从而获得CPU使用权

整个过程大致如下

  • 得到轮询指令
  • 执行轮询指令,发起自陷异常的信号
  • 抢占CPU来执行轮询判断操作
    • 如果需要进行等待,将内存页设置为不可读,然后交由预先注册的异常处理器挂起线程实现等待
    • 如果不需要进行等待,不做任何操作

对于HotSpot来说,采用的是主动式中断

安全区域(Safe Region)

有了安全点之后,程序在执行的时候就可以进行准确式垃圾回收了,并且减少了垃圾回收器等待的时间,解决了如何在GC时第一时间让线程进行停顿

但是,安全点只是针对正在执行程序的线程,那对于不执行程序的线程呢?

不执行程序的线程是指线程被阻塞了、线程正在睡眠,这种时候的线程是无法响应虚拟机的中断请求的,不能主动让自己走到安全点上去中断挂起自己,那这时候怎么办呢?让虚拟机一直等待线程恢复,然后线程轮询发现标志位,再走到安全点吗?显然这是不可能的

这种时候就必须引入安全区域来解决了

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,与安全点不同,安全区域的位置要比安全点大得多,并且位于该片段就是安全了

本质上可以把安全区域理解成是扩展拉伸了的安全点

当线程执行到安全区域里面代码的时候,就会标识自己已经进入了安全区域,当这段时间里虚拟机要发起垃圾收集时就不会去管这些已声明自己在安全区域内的线程了;当线程离开安全区域时,会去检查JVM是否已经完成了根节点枚举(注意此时线程没有被挂起,仍然可以继续运行的),如果完成了,线程就会当没事发生一样,会继续运行;如果未完成,线程就会在这里进行等待,直到收到可以离开安全区域的信号为止

记忆集与卡表

有了准确式垃圾回收机制,我们就减少了对于GC Roots的查询,但还没有解决跨代引用的问题,当老年代去引用新生代的时候,新生代去进行GC回收的时候,还要去检查老年代的情况,前面提到过,JVM使用记忆集来让新生代去存储那些引用的老年代,这样就不用检查所有的老年代了

垃圾收集器在新生代中创建了名为记忆集的数据结构(Remembered Set),是为了解决对象跨代引用的问题,为了避免新生代要对整个老年代进行检查

记忆集是一种用于记录从非收集区域指向收集区域的指针集合,也就是存储了老年代对新生代的引用,非收集区域就是指老年代,而收集区域为新生代,具体来说存储的就是下面这个指针

在这里插入图片描述
要知道,在垃圾收集的场景中,收集器只需要通过记忆集来判断出非收集的区域是否存在有指向了收集区域的指针就可以了,说白了点就是判断是否有收集区域被非收集区域引用了,根本不需要了解这些跨代指针的全部细节,所以在实现Remembered Set的时候,是可以考虑一些更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面是常用的一些精度

  • 字长精度:精确到一个机器字长,一个机器字长是指处理器的寻址位数,即访问物理内存的指针长度,这一个机器字长代表着跨代指针
  • 对象精度:精确到一个对象,该对象里存在字段为跨代指针,即对象里面含有跨代指针
  • 卡精度:精确到一块内存区域,该区域内有对象含有跨代指针

可以见这三种精度的粗犷程度是逐级递增的,并且是一层包含一层的关系

目前最常用的就是卡精度,卡精度是使用卡表的方式来实现的,卡精度是抽象的记忆集的一种抽象实现,而卡表是卡精度的具体实现,也可以说是记忆集的一种具体实现

卡表定义了记忆集的记录精度与堆内存的映射关系

在HotSpot中也是采用卡表的方式的,也就是选用最粗狂的粒度,且卡表的底层是一个字节数组,也就是说,在HotSpot中,记忆集的底层是一个字节数组

拓展:那么为什么会采用字节数组而不是比特数组呢?为何是Byte而不是Bit?

这个主要是因为采用字节数组会比位数组快,在现代计算机硬件中都是最小按字节寻址的,没有直接存储bit的指令,所以使用bit还需要额外去使用其他指令

下面的代码是HotSpot默认的卡表标记逻辑

CARD_TABLE [this address >> 9] = 0

字节数组CARD_TABLE中的每一个索引都对应着其标识的内存区域中一块特定大小的内存块(以上面的引用关系为例就是指新生代),这个内存块被称为是卡页

形成的卡表与卡页的关系如下
在这里插入图片描述
一个卡页中的内存中通常包含不止一个对象,只要卡页内存在一个或者多个对象的字段里面有跨代指针,就会将卡表里的数组元素的值标识为1,称为该元素,即该内存变脏了,没有则标识为0

那么,在垃圾收集时,只需要筛选出卡表变脏的元素,从元素里面找到包含跨代指针的对象,将这些对象也加入GC Roots中一起扫描

也就是说卡表的结构是,索引代表着卡页的地址,而索引对应的值就代表着对应的卡页是否出现了引用

写屏障

现在我们已经知道卡表的工作方式了,那要解决的下一个问题就是,如何去维护卡表呢?怎样知道哪些区域变脏了?并且交给谁去维护卡表呢?

卡表元素变脏的时刻是很明显的,当其他分代区域中对象引入了本区域的对象时(卡表是存放在本区域的),变脏时间点原则上应该发生在引用类型字段赋值的那一刻,问题是如何在这个时间点上进行更新维护

首先我们这里要先了解一个概念,语言写的程序如何运行的?分为两种

  • 解释执行:解释执行其实就是将每一行代码进行解释,翻译成目标代码之后就去执行,跟同声翻译差不多,说一句就翻译一句,你也就听懂了一句
    • 优点在于不需要进行等待,但缺点在于实际运行效率比较低,因为计算机去执行这种语言的程序时,相当于是一条条代码去做的,而不是一段程序去做的
  • 编译执行:编译执行是将整个程序以此向转化成目标程序,跟翻译差不多,把整段文字进行翻译,你就看懂了整段文字
    • 优点在于实际运行效率会比较高,但缺点在于需要等待整个程序转化

而Java就比较特殊,它是解释和编译混合的,因为Java程序执行相当于涉及到两台计算机,一台JVM,一台服务器,要知道Java文件执行的时候,要先翻译成class文件,然后class文件交给虚拟机去执行

  • 翻译成class文件采用的是编译执行:Java交给JVM去将Java文件编译成class文件
  • 虚拟机执行class文件采用的是解释执行:解释器去解释class文件然后翻译成一条条指令交给计算机执行

这也是为什么Java的理念是**“一次编译,到处运行”**,Java虚拟机进行编译,通过JIT解释器可以像JavaScript,Python一样到处去运行

现在回到如何进行卡页的更新维护

现在已经知道Java分为两部分去执行程序了,所以我们介入的地方也有两处

  • 假如在编译java成class时候去进行干涉,那么是比较难干涉的,因为编译执行是整个程序以此去转化成目标程序,对于整个程序流,没有什么介入空间,所以,如果在这里进行干涉,就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中
  • 假如在将class解释成机器指令去进行干涉,这是比较容易干涉的,因为解释也是交由JVM去做的(更详细来说是JIT),对于一条条的机器指令,有很充分的空间

而在Hot Spot中虚拟机中是通过写屏障技术来维护卡表状态的(注意这里的写屏障并不是volatile里面的内存屏障),写屏障可以看作虚拟机在面对“对引用类型的字段进行赋值”这个动作的一个AOP切面,并且还是一个Around类型的AOP,也就是这整个动作都被这个AOP环绕了,既可以在之前做(写前屏障),也可以在之后做(写后屏障),更加可以在中间过程去做,这要看使用的是哪种垃圾收集器,使用屏障去进行维护卡表

应用写屏障后,虚拟机就会为所有的赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论是不是老年代引用新生代的情况,只要出现了引用添加或更新,都会产生相应额外的开销(完成写屏障的操作需要的开销),不过这个开销相比于对老年代进行全部扫描还是可以接受的

伪共享

除了写屏障的开销之外,卡表在高并发场景下还面临着伪共享问题

伪共享是处理并发底层细节时一种经常要考虑的问题,那么伪共享究竟是什么呢?

要知道,现在处理器对于数据的缓存都是采用缓存行(Cache Line)来实现的

下面来介绍一下缓存行这个概念

首先我们要认识CPU缓存,CPU缓存其实是CPU与内存之间的临时存储器,它的容量比内存小得多,但是交换速度却快得多,CPU内存也被称为高速缓存,它的出现是为了解决运算速度与内存读写速度不匹配的矛盾

CPU缓存分为好几层,一般分为三层

  • 一级缓存
  • 二级缓存
  • 三级缓存

每一级缓存存储的东西都是下一级缓存的一部分,并且越上级的缓存距离CPU就越近,CPU读取的速度就越快

而缓存行,就是CPU缓存中缓存的东西,CPU缓存不是缓存一个个对象的,而是以缓存行为单位的,缓存行里面一般会存在多个对象

缓存行通常是64字节,比如一个long类型的数据是8个字节,那么缓存行里面能储存8个long类型的数据,假如访问的是一个Long数组,当数组中的一个值被加载到缓存中,那么为了充分利用缓存行,会把数组中后面的7个long类型的值也会自动填充进来,享受了一次免费的加载

为了保证一致性,比如一个核心线程A对一个数组进行更改,首先会将数组加载到缓存行中,然后进行更改,但如果此时有另外一个核心线程B对这个数组进行访问,此时是不可以访问的,因为缓存行被更改了,核心线程A更改完会标记这个缓存行是无效的,让其他线程从主存去获取

这个操作有关联的数据来说是正常的,这样对于该数据来说就是起到了一个共享的状态,一个元素发生改变了,就要重新从主存里去拿,但如果对于不相干的数据来说则是会严重降低效率的,因为缓存行中存在不相干的数据,只要里面一个数据发生改变了,其他数据就失效了,不相关的数据之间会相互影响其有效性,这就是伪共享的问题

如何解决伪共享的问题呢?

一种比较简单的解决思路就是不采用无条件的写屏障,前面使用的写屏障都是没有判断条件直接进行操作的,对于卡表来说,创建、更改对象之后会对卡表进行更新,只有当卡表元素没被标记时,才进行更新,也就是说,只有没有引用关系的卡表元素才会进行更新

举个栗子

比如创建了对象A,存储该对象A的内存位于addressA,那么卡表就会去根据这个addressA找到对应的元素,看这块区域有没有被标识,如果已经被标识了,证明对应的卡页已经存在引用关系了,就算再添加引用关系,还是只算有引用关系,所以就不需要更改了

当然,增加了这个操作就意味着要给额外的判断去消耗额外的性能,所以这也是需要权衡的

JDK7之后,可以通过-XX:+UseCondCardMark来决定是否开启卡表的更新的条件判断

现在我们对整个流程都有确切的认识了

  • 从根节点枚举来引出两个问题
    • 根节点多,难以维护引用链的变化
      • 根节点多,采用oop来记录拥有引用链的GC Root,避免GC Roots的全覆盖查询
    • 需要线程停止,如何让线程快速停止
      • 安全点、安全区域
  • 解决了这两个问题之后,还要考虑引用关系
    • 使用记忆集来解决引用关系

并发情况下的可达性分析

前面已经提到过,JVM大多使用可达性分析算法来实现垃圾回收,而可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着需要全程去冻结用户线程的运行(前面提到过这里是使用安全点、安全区域来保证的快照),不让线程对堆进行改变了

但即使有OOP的支持,让需要进行查找的GC Root维持在一定数量,但随着Java堆的变大,对象之间的关系也会越来越复杂,也就导致当进行可达性分析时,用户线程的停止时间会越来越长

为了解决这个问题,我们就得思考一下,为什么进行可达性分析的时候一定要停止用户线程呢?

下面举个栗子来分析一下

首先给对象定义状态

  • 白色:表示对象还没有被垃圾收集器访问过,在刚开始可达性分析的时候,所有的对象都是白色,若分析结束了,如果还是白色,那就代表这个对象是不可达的,需要进行垃圾回收
  • 黑色:表示对象已经被垃圾收集器访问过,并且确定了可达,而且引用该对象的所有对象已经被扫描过了
  • 灰色:表示对象已经被垃圾收集器访问过,并且确定了可达,但引用该对象的所有对象并没有被全部扫描过

说起来可能比较抽象,所以用图来说明一下

一开始的引用关系如下所示,此时全部为白色,代表垃圾收集器还没访问
在这里插入图片描述
后面垃圾收集器进行访问,到了下面这个状态(我这里增多了一个对象,是因为想演示一下灰色的情况),可以看到其中有一个灰色节点,这是因为存在一个引用对象没有被访问

在这里插入图片描述
最后面形成的图是这样的,可以看到最上面两个白色的,这两个对象就是不可达对象,需要进行垃圾清除

在这里插入图片描述
那么如果不停止线程,会出现什么情况呢?

总共会有两种特殊情况

  • 让一些原本在引用链上的对象不位于引用链上了(无影响)
  • 让一些原本不在引用链上的对象又位于引用链上了(严重影响)

比如说,有线程让一些不位于引用链上的对象不在引用链上了

在这里插入图片描述
这种情况是允许的,这顶多就是让本该回收的对象逃离了这次GC回收而已,对整体的业务不会有影响

再比如说,有线程让一些本来不位于引用链上的对象突然出现在引用链上了

在这里插入图片描述
这样问题就大着了,你把要引用的对象给垃圾清除掉了,因为其原本为白色的,被垃圾收集器清除,但后面将它变成黑色了,垃圾收集器是不知道的,会让业务出现问题

对于第一种情况我们可以不进行处理,但对于第二种情况就需要进行处理

那么我们如何来解决第二种情况呢?首先我们要分析为什么会出现第二种情况

当同时满足下面的情况时就可能会出现这个问题

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用,代表有白色对象变为黑色对象
  • 在第一个情况的基础上,赋值器删除了全部从灰色对象到新增的白色对象的直接或间接引用,或者没有灰色对象引用新增的白色对象,代表白色对象已经不能变成黑色对象了

那么我们只要破坏掉其中一个条件就好了,由此也产生了两种解决方案

  • 增量更新
  • 原始快照

增量更新

增量更新破快了第一个条件,当出现新的黑色对象到白色对象的新引用时,就会将这个引用关系记录下来,等并发扫描结束了之后,等并发扫描结束了之后,再将这些记录过的引用关系中的黑色对象为根,再进行扫描一次,也就是说,相当于增量更新让原本的黑色对象变成了灰色而已,需要再进行一次扫描

原始快照

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

但对于本来就没有灰色对象对其进行引用时,原始快照解决不了问题,只能使用增量更新来解决

JVM的垃圾收集器对于这两种方式都有去实现的,比如CML是基于增量更新的来做并发标记的,而GI、Shenandoah则是用原始快照来实现的

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值