Java多线程(四)——volatile关键字原理

iwehdio的博客园:https://www.cnblogs.com/iwehdio/

学习自:

1、volatile关键字

缓存一致性协议

  • 现代CPU都是多核处理器。由于cpu核心(Kernel)读取内存数据较慢,于是就有了缓存的概念。我们希望针对频繁读写的某个内存变量,提升本核心的访问速率。因此我们会给每个核心设计缓存区(Cache),缓存该变量。由于缓存硬件的读写速度比内存快,所以通过这种方式可以提升变量访问速度。

  • 缓存的结构可以如下设计:

    image-20210209153914813

    • 一个缓存区可以分为N个缓存行(Cache line),缓存行是和内存进行数据交换的最小单位。每个缓存行包含三个部分,其中valid用于标识该数据的有效性。如果有效位为false,CPU核心就从内存中读取,并将对应旧的缓存行数据覆盖,否则使用旧缓存数据;tag用于指示数据对应的内存地址;block则用以存储数据。
  • 对于Java内存模型JMM而言:

    • 所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
    • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
    • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
    • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
  • 缓存一致性问题:

    • 如果涉及到并发任务,多个核心读取同一个变量值,由于每个核心读取的是自己那一部分的缓存,每个核心的缓存数据不一致将会导致一系列问题
    • 缓存一致性的问题根源就在于,对于某个变量,好几个核心对应的缓存区都有,到底哪个是新的数据呢?
    • 所以为了保证缓存的一致性,业界有两种思路:
      1. 写失效(Write Invalidate):当一个核心修改了一份数据,其它核心如果有这份数据,就把valid标识为无效;
      2. 写更新(Write update):当一个核心修改了一份数据,其它核心如果有这份数据,就都更新为新值,并且还是标记valid有效。
    • 业界有多种实现缓存一致性的协议,诸如MSI、MESI、MOSI、Synapse、Firefly Dragon Protocol等,其中最为流行的是MESI协议。
  • MESI协议就是根据写失效的思路,设计的一种缓存一致性协议。为了实现这个协议,原先的缓存行修改如下:

    image-20210209154449841

    • 原先的valid是一个比特位,代表有效/无效两种状态。在MESI协议中,该位改成两位,不再只是有效和无效两种状态,而是有四个状态,分别为:
      1. M(Modified)修改:表示核心的数据被修改了,缓存数据属于有效状态,但是数据只处于本核心对应的缓存,还没有将这个新数据写到内存中。由于此时数据在各个核心缓存区只有唯一一份,不涉及缓存一致性问题;
      2. E(Exclusive)独占:表示数据只存在本核心对应的缓存中,别的核心缓存没这个数据,缓存数据属于有效状态,并且该缓存中的最新数据已经写到内存中了。同样由于此时数据在各个核心缓存区只有一份,也不涉及缓存一致性问题;
      3. S(Shared)共享:表示数据存于多个核心对应的缓存中,缓存数据属于有效状态,和内存一致。这种状态的值涉及缓存一致性问题;
      4. I(Invalid)失效:表示该核心对应的缓存数据无效。
    • 为了保证缓存一致性,每个核心要写新数据前,需要确保其他核心已经置同一变量数据的缓存行状态位为Invalid后,再把新数据写到自己的缓存行,并之后写到内存中。
  • MESI协议包含以下几个行为:

    • 读(Read):当某个核心需要某个变量的值,并且该核心对应的缓存没这个变量时,就会发出读命令,希望别的核心缓存或者内存能给该核心最新的数据;
    • 读命令反馈(Read Response):读命令反馈是对读命令的回应,包含了之前读命令请求的数据。举例来说,Kernel0发送读命令,请求变量a的值,Kernel1对应的缓存区包含变量a,并且该缓存的状态是M状态,所以Kernel1会给Kernel0的读命令发送读命令反馈,给出该值;
    • 无效化(Invalidate):无效化指令是一条广播指令,它告诉其他所有核心,缓存中某个变量已经无效了。如果变量是独占的,只存在某一个核心对应的缓存区中,那就不存在缓存一致性问题了,直接在自己缓存中改了就行,也不用发送无效化指令;
    • 无效化确认(Invalidate Acknowledge):该指令是对无效化指令的回复,收到无效化指令的核心,需要将自己缓存区对应的变量状态改为Invalid,并回复无效化确认,以此保证发送无效化确认的缓存已经无效了;
    • 读无效(Read Invalidate):这个命令是读命令和无效化命令的综合体。它需要接受读命令反馈和无效化确认;
    • 写回(Writeback)这个命令的意思是将核心中某个缓存行对应的变量值写回到内存中去。
  • 影响MESI协议的时间瓶颈主要有两块:

    1. 无效化指令:Kernel0需要通知所有的核心,该变量对应的缓存在其他核心中是无效的。在通知完之前,该核心不能做任何关于这个变量的操作。
    2. 确认响应:Kernel0需要收到其他核心的确认响应。在收到确认消息之前,该核心不能做任何关于这个变量的操作,需要持续等待其他核心的响应,直到所有核心响应完成,将其对应的缓存行标志位设为Invalid,才能继续其它操作。
  • 针对这两部分,我们可以进一步优化:

    1. 针对无效化指令的加速:在缓存的基础上,引入Store Buffer这个结构。Store Buffer是一个特殊的硬件存储结构。通俗的来讲,核心可以先将变量写入Store Buffer,然后再处理其他事情。如果后面的操作需要用到这个变量,就可以从Store Buffer中读取变量的值,核心读数据的順序变成Store Buffer → 缓存 → 内存。这样在任何时候核心都不用卡住,做不了关于这个变量的操作了。
    2. 针对确认响应的加速:在缓存的基础上,引入Invalidate Queue这个结构。其他核心收到Kernel0的Invalidate的命令后,立即给Kernel0回Acknowledge,并把Invalidate这个操作,先记录到Invalidate Queue里,当其他操作结束时,再从Invalidate Queue中取命令,进行Invalidate操作。所以当Kernel0收到确认响应时,其他核心对应的缓存行可能还没完全置为Invalid状态。

    image-20210209155656729

    • 但是这些优化方法可能会导致错误,主要原因是,对变量进行下一步操作时,Store Buffer和Invalidate Queue中的命令可能还未来得及完全处理。

内存屏障

  • 指令重排序:

    • 为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

      image-20210209163615412

    • 一般重排序可以分为如下三种:

      • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
      • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
      • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
    • 什么是as-if-serial?

      • 不管怎么重排序,单线程下的执行结果不能被改变。
  • 内存屏障简单来讲就是一行命令,规定了某个针对缓存的操作。

  • 写屏障:

    • 针对Store Buffer:核心在后续变量的新值写入之前,把Store Buffer的所有值刷新到缓存;核心要么就等待刷新完成后写入,要么就把后续的后续变量的新值放到Store Buffer中,直到Store Buffer的数据按顺序刷入缓存。
  • 读屏障:

    • 针对Invalidate Queue:执行后需等待Invalidate Queue完全应用到缓存后,后续的读操作才能继续执行,保证执行前后的读操作对其他CPU而言是顺序执行的。
  • 对于JVM的内存屏障实现中,也采取了内存屏障。JVM的内存屏障有四种,这四种实际上也是上述的读屏障和写屏障的组合。

    • LoadLoad屏障作用:在第二大段读数据指令被访问前,保证第一大段读数据指令执行完毕

      第一大段读数据指令; 
      LoadLoad; 
      第二大段读数据指令;
      
    • StoreStore指令作用:在第二大段写数据指令被访问前,保证第一大段写数据指令执行完毕。

      第一大段写数据指令; 
      StoreStore; 
      第二大段写数据指令;
      
    • LoadStore指令作用:在第二大段写数据指令被访问前,保证第一大段读数据指令执行完毕。

      第一大段读数据指令; 
      LoadStore; 
      第二大段写数据指令;
      
    • StoreLoad指令作用:在第二大段读数据指令被访问前,保证第一大段写数据指令执行完毕。

      第一大段写数据指令; 
      StoreLoad; 
      第二大段读数据指令;
      
  • volatile做了什么?

    • volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
    • volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
  • 针对volatile变量,JVM采用的内存屏障是:

    1. 针对volatile修饰变量的写操作:在写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;

      image-20210209165235692

    2. 针对volatile修饰变量的读操作:在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

    • 通过这种方式,就可以保证被volatile修饰的变量具有线程间的可见性和禁止指令重排序的功能了。
    • 由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。
  • synchronized加锁可以解决可见性吗?

    • 可以。因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。
    • 而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。
  • volatile的应用:

    • volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步。
    • volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
    • volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
    • volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取。
    • volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
    • volatile可以使得long和double的赋值是原子的。
    • volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。

iwehdio的博客园:https://www.cnblogs.com/iwehdio/
来源与结束于否定之否定。
原文地址:https://www.cnblogs.com/iwehdio/p/14395059.html