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

根节点枚举


根节点枚举是优化可达性分析算法中从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内存也被称为高速缓存,它的出现是为了解决运算速度与内存读写速度不匹配的矛盾

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后,附一张自己面试前准备的脑图:

image

面试前一定少不了刷题,为了方便大家复习,我分享一波个人整理的面试大全宝典

  • Java核心知识整理

image

  • Spring全家桶(实战系列)

image.png

Step3:刷题

既然是要面试,那么就少不了刷题,实际上春节回家后,哪儿也去不了,我自己是刷了不少面试题的,所以在面试过程中才能够做到心中有数,基本上会清楚面试过程中会问到哪些知识点,高频题又有哪些,所以刷题是面试前期准备过程中非常重要的一点。

以下是我私藏的面试题库:

image

很多人感叹“学习无用”,实际上之所以产生无用论,是因为自己想要的与自己所学的匹配不上,这也就意味着自己学得远远不够。无论是学习还是工作,都应该有主动性,所以如果拥有大厂梦,那么就要自己努力去实现它。

最后祝愿各位身体健康,顺利拿到心仪的offer!
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门即可获取!
6871)]

Step3:刷题

既然是要面试,那么就少不了刷题,实际上春节回家后,哪儿也去不了,我自己是刷了不少面试题的,所以在面试过程中才能够做到心中有数,基本上会清楚面试过程中会问到哪些知识点,高频题又有哪些,所以刷题是面试前期准备过程中非常重要的一点。

以下是我私藏的面试题库:

[外链图片转存中…(img-0UyEBU19-1712038676871)]

很多人感叹“学习无用”,实际上之所以产生无用论,是因为自己想要的与自己所学的匹配不上,这也就意味着自己学得远远不够。无论是学习还是工作,都应该有主动性,所以如果拥有大厂梦,那么就要自己努力去实现它。

最后祝愿各位身体健康,顺利拿到心仪的offer!
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门即可获取!

  • 22
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值