写点什么

JVM(三),java 多线程编程核心技术 pdf

作者:MySQL神话
  • 2021 年 11 月 27 日
  • 本文字数:6272 字

    阅读完需:约 21 分钟

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


《一线大厂 Java 面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》

【docs.qq.com/doc/DSmxTbFJ1cmN1R2dB】 完整内容开源分享


)来实现的**


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


首先我们要认识 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 堆的变大,对象之间的关系也会越来越复杂,也就导致当进行可达性分析时,用户线程的停止时间会越来越长


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


下面举个栗子来分析一下


首先给对象定义状态


  • 白色:表示对象还没有被垃圾收集器访问过,在刚开始可达性分析的时候,所有的对象都是白色,若分析结束了,如果还是白色,那就代表这个对象是不可达的,需要进行垃圾回收

  • 黑色:表示对象已经被垃圾收集器访问过,并且确定了可达,而且引用该对象的所有对象已经被扫描过了

  • 灰色:表示对象已经被垃圾收集器访问过,并且确定了可达,但引用该对象的所有对象并没有被全部扫描过


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


一开始的引用关系如下所示,此时全部为白色,代表垃圾收集器还没访问



后面垃圾收集器进行访问,到了下面这个状态(我这里增多了一个对象,是因为想演示一下灰色的情况),可以看到其中有一个灰色节点,这是因为存在一个引用对象没有被访问



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



那么如果不停止线程,会出现什么情况呢?


总共会有两种特殊情况


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

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

总结

面试前的“练手”还是很重要的,所以开始面试之前一定要准备好啊,不然也是耽搁面试官和自己的时间。


我自己是刷了不少面试题的,所以在面试过程中才能够做到心中有数,基本上会清楚面试过程中会问到哪些知识点,高频题又有哪些,所以刷题是面试前期准备过程中非常重要的一点。

面试题及解析总结

大厂面试场景

知识点总结


本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

用户头像

MySQL神话

关注

还未添加个人签名 2021.11.12 加入

还未添加个人简介

评论

发布
暂无评论
JVM(三),java多线程编程核心技术pdf