对于JAVA并发编程而言,我们始终要遵守这三种特性的规则。才能使我们的代码不出问题。

在上一篇我们通过字节码层面和汇编的层面对volatile进行了一个简单的介绍。那么这一期我们将深入的去给大家介绍一下volatile。

代码执行过程

首先我们先来了解一下代码的执行过程。我们首先通过JVM的类加载器将类加载到元空间里面,元空间会通过元数据对象在堆内生成一个Class对象。那么CPU是如何执行到我们的代码的呢?
首先我们的栈和线程是1:1对应的关系。我们首先会将字节码文件通过字节码执行引擎和JIT将这个字节码转换成汇编代码,但是此时汇编代码并不是CPU马上就能执行的。因为我们的CPU它只懂二进制,我们通过0和1来控制它的高低电频。所以我们的硬件又会将汇编代码转换成01形式的二进制代码。注意,这时候CPU还是不会马上执行。
你可以理解为我们的代码实际上就是水,而我们的水需要载体比如水杯。那我们的线程就充当了这么一个作用。只有当我们的CPU调度到这个线程的时候才会执行相应的代码。

CPU线程模型

CPU要想去知道我何时调度该线程,那么其实是通过两种线程模型来知道的。

  1. 内核线程模型
  2. 用户线程模型

而我们的JAVA则使用的是内核线程模型。那么这两种模型是什么意思呢?你可以理解为内核线程模型就是把我们的线程交给操作系统去管理,而用户线程模型就是我们的线程是由我们的用户程序去管理。那么这里就引入出来两个概念:用户态和内核态

用户态和内核态

在了解用户态和内核态之前,我们要先了解CPU的安全级别。对于我们CPU来说它也有一套安全级别:

  1. ring0
  2. ring1
  3. ring2
  4. ring3

以上四个级别是这样的,首先ring0是安全级别当中最高的,比如我们的OS,它就是运行在ring0级别下的。而我们的JVM就是运行在ring3级别下。我们的windows和linux目前只分了两个等级ring0和ring3。而ring0就是我们常说的内核态,ring3就是我们常说的用户态。而与之相对应的就是我们的内存当中的用户空间和内核空间。下面我用一张图来表示一下
手撕面试题之volatile详解(三)
我们以一个内存大小为4G的计算机来说。它首先分配了1G左右的空间给了我们的内核空间,也就是操作系统。剩下的3G都分配给了用户空间,例如JVM,QQ,微信,快播(QVOD)等使用的空间。
那对于我们的线程来说,实际上我们创建一个线程是需要依赖底层的操作系统的,而我们的JVM想要创建一个线程怎么办?是不是需要通过操作系统才可以创建一个线程。而我们通过操作系统的时候我们需要从用户态陷入到内核态,去调用比如PThread库来创建一个线程,当我这个线程执行完毕了之后,我在由内核态切换回用户态。虽然过程很轻松,可是从用户态陷入到内核态花费的代价是很大的。因为它在陷入到内核态的时候它的用户空间里的数据全部都不能使用了,因为内核空间和用户空间是两个单独存在的,他们之间互不干扰,我一旦陷入到内核态吗,是不是用户空间的东西我都用不了了?所以这里引入一个概念,我们的Synchronized之所以在以前的版本一直饱受病垢也是因为这个原因。

CPU缓存一致性协议

在上一篇我们有提到LOCK前缀指令会触发我们的缓存一致性协议。那么缓存一致性协议到底是个啥玩楞?
在我们很久以前,那个时候各方面技术还不发达,我们实际上是通过总线锁的方式去处理的。但是总线锁并不能发挥我们的多核CPU优势,因为对于同一个数据来说,当你加了总线锁后,只有一个线程能对他进行操作所以这时候我们出来了缓存一致性协议。
缓存一致性协议的实现有很多,我们目前最常用的就是MESI协议,也是我接下来要去讲的。当然也有其他例如MSI等其他的实现方式。在遇到Lock指令的时候我们的CPU会优先触发缓存一致性协议,只有在没办法的时候它才会选择去使用总线锁的方式。

MESI一致性协议

实际上MESI对应的是4个英文单词和四种不同的状态

  • M:即Modify表示这个数据已经被修改过
  • E: 即Exclusive表示这个数据处于独占状态中
  • S: 即Shared表示这个数据正在处于共享状态
  • I: 即Invalidate, 表示数据目前已经失效

那么他们之间的状态是怎么切换的呢?老规矩我们上图!
手撕面试题之volatile详解(三)
首先我有一个加了lock指令的数据在主内存当中对吧,假如现在我第一个线程要去取数据。在我们上一篇说道,它会先读到总线上,那么这时候它发现我们的数据被加了Lock前缀。于是触发我们的MESI协议,将我们的变量设置一个状态叫E(独占)
假设说,现在我们有第二个线程要去读取他,那么他会把这个变量设置为S状态,代表共享状态。因为此时在其他线程的工作内存中已经存在了该变量,那当我第二个线程想要获取的时候肯定这个变量就会变成一个共享的状态了对吧

手撕面试题之volatile详解(三)
好,这个时候我们的线程1开始处理数据,假如我们要执行一个x=x+1这个操作,那么它会先丢给执行引擎,然后因为我们的每个CPU内部都有一个寄存器,我们的寄存器去进行一个+1的一个计算。当我们计算完毕后,此时会发出一个信号,告诉我们其他线程的工作内存中,我这个值已经被修改啦,你们都给我把自己工作内存中的这个x=0设置为失效。

手撕面试题之volatile详解(三)

缓存行加锁

那么问题来了,假如说我两个CPU一起修改了这个这个数据怎么办?那这个时候我们两到底要听谁的?谁要将自己的数据设置为失效状态?那么这时候就有一个机制,就是我们各自对自己的缓存行加锁(缓存行详情见上一篇),如果加锁成功了,就拥有了数据的修改权。这时候也会发出一个总线写的信号,告诉另一个CPU你别再加锁了,也别改了,我已经拿到锁了你快给我设置为失效状态!
于是乎另一个CPU的就知道了,把自己的数据设置为失效状态。

总线裁决

那问题来了,如果两个人同时加锁并且同时经过总线想对方发出信号怎么办?那么实际上这时候我们的总线就充当了一个类似于裁判一样的性质,去做一个总线裁决,它判定哪边赢,哪边就拥有修改权限。由于我们这里不是讲硬件的课,总线裁决不会很深入,大家只需要知道我们总线裁决有两种方式。第一种是集中式,第二种是分布式就够了。

总线锁

刚刚我们说了,我们目前的计算机基本都采用的是总线一致性协议的方式。那么到底什么情况下会使用到总线锁呢?
当我们有多个缓存行被读取的时候,就会使用总线锁。因为我们刚刚讲了,我们对缓存行加锁实际上只是对单个缓存行加锁,因为你对多个缓存行加锁实际上是没有办法保证原子性的,所以当我们读取多个缓存行的时候会直接使用总线锁的方式去对数据加锁。

Store Buffers与失效队列

我们缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到 消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的 阻塞都会导致各种各样的性能问题和稳定性问题。为了提高我们的性能,我们引入了两个东西,第一个叫Store Buffers,第二个叫失效队列也叫Invalidate Queue

Store Buffers:

为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓 存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。但是这么做,会有两个风险问题存在
1.处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案 称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回
2.什么时候会完成,这个并没有任何保证。
试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取 finished的值为true,而value的值不等于10。

失效队列:

刚刚我们说我们有一个存储缓冲Store Buffers,那么对应我们失效的值也不会被立马丢弃,而是放到一个队列里面,只有当我CPU空闲的时候才会去从队列里面擦除数据。

好啦~文章到这里就结束啦,喜欢的话记得点赞收藏转发哦!

相关文章:

  • 2021-12-28
  • 2021-06-12
  • 2021-08-01
  • 2021-09-25
  • 2022-01-03
猜你喜欢
  • 2021-10-04
  • 2022-12-23
  • 2021-10-29
  • 2021-11-16
  • 2021-08-23
  • 2022-01-23
相关资源
相似解决方案