并发编程之JMM&Volatile底层原理剖析

初步认识 Volatile

一段代码引发的思考,下面这段 代码演示了使用valatile和没有使用volatile关键字对于变量更新的影响

public class App {
    public volatile static boolean stop = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            int i = 0;
            while(!stop){
                i ++;
            }
        });
        t1.start();
        System.out.println("begin");
        Thread.sleep(1000);
        stop = true;
    }
}

volatile的作用

可以使得在多处理器环境下保证共享变量的可见性,什么是可见性?

在单线程的环境下,如果向一个变量先写入一个值,然后再没有写干涉的情况下读取这个变量,这个时候读取到的这个变量值应该是之前写入的值。这本来是一个很正常的事情,但是在多线程环境下,读和写发生在不同线程中的时候可能会出现:读线程不能即使读取到其他线程写入的最新值。这就是所谓的可见性。为了实现多线程写入的内存可见性,必须使用一些机制。而volatile就是这样一种机制

volatile 关键字是如何保证可见性的?

在运行main函数之前,加入虚拟机参数

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*XXX.function(xxx替换成实际运行的类,function替换方法名)

然后在输出的结果中,查找下 lock 指令, 会发现,在修改带有 volatile 修饰的成员变量时,会多一个lock 指令。 lock是一种控制指令, 在多处理器环境下, lock 汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的一个效果

为了更好的理解可见性的本质, 我们需要从硬件层面进行梳理

从硬件层面了解可见性的本质

        一台计算机最核心的组件时CPU,内存以及I/O设备不断迭代升级来提升计算机处理能力之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问。

       为了提升计算性能, CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。 为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化。

  1. CPU 增加了高速缓存
  2. 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率
  3. 编译器的指令优化,更合理的去利用好 CPU 的高速缓存

      然而每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。 为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程

CPU缓存架构

      CPU缓存即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用,减少CPU的等待时间,提高了系统的效率

       通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性

什么叫缓存一致性呢?

      首先,有了高速缓存的存在以后, 每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成。之后写入到缓存中。 在整个运算过程完成后,再把缓存中的数据同步到主内存

      由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。 同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。

为了解决缓存不一致的问题,在 CPU 层面做了很多事情,

主要提供了两种解决办法l

1. 总线锁

2. 缓存锁

JMM&volatile.jpg

    总线锁,简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据, 总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大, 这种机制显然是不合适的

如何优化呢? 最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行。 所以引入了缓存锁, 它核心机制是基于缓存一致性协议来实现的

缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI, MESI, MOSI 等。 最常见的就是 MESI 协议

MESI 表示缓存行的四种状态,分别是:

  1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致

     2.E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改

     3.S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致

     4.I(Invalid) 表示缓存已经失效

在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作

关键 对于 MESI 协议, 从 CPU 读写角度来说会遵循以下原则:CPU 读请求:缓存处于 M、 E、 S 状态都可以被读取, I 状态 CPU 只能从主存中读取数据CPU 写请求:缓存处于 M、 E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写使用总线锁和缓存锁机制之后, CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果

MESI 优化带来的可见性问题

MESI 协议虽然可以实现缓存的一致性,但是也会存在一些问题。

就是各个 CPU 缓存行的状态是通过消息传递来进行的。 如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存该数据的 CPU(Invalid)。并且要等到他们的确认回执。 CPU0 在这段时间内都会处于阻塞状态。 为了避免阻塞带来的资源浪费。 在 cpu 中引入了 Store Bufferes

       CPU0 只需要在写入共享数据时,直接把数据写入到 storebufferes 中, 同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时, 再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。

但是这种优化存在两个问题

1. 数据什么时候提交是不确定的,因为需要等待其他 cpu给回复才会进行数据同步。这里其实是一个异步操作

2. 引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,如果 storebuffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取

看个例子:

//cpu已经缓存了Flag
//M(Modify)  E(Exclusive) S(Shared)  I(Invalid) 状态
value = 3 //(S)
void cpu0(){
    value = 10; //( M) ->[ storebufferes ->通知其他cpu缓存行失效(i)] 
    Flag = true;//(E)
}
void cpu1(){
   if(Flag){//true
       assert value ==10;//flase
   }
}

        cpu0和cpu1分别在俩个独立cpu上执行,假如cpu0缓存行中缓存了isFlag这个共享变量且状态(E),而Vlaue可能是(S)状态。

          这时候,CPU0在执行的时候,会先把value=10写入到storebuffer中,并且通知其他缓存了value的cpu.。在等待其他CPU通知结果的时候,cpu0会先执行isFlag=true的指令。

          而因为当前cpu0缓存了isFlag并且是(E)状态,所以可以直接修改isFlag=true,但是value值还不等于10.

          这种情况我们可以认为是CPU的乱序执行,也可以认为是重排序,这种重排序会带来可见性问题。

         从硬件层面很难去知道软件层面上的这种前后依赖关系,没有办法通过某种手段自动去解决。所以在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flushstore bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。

   

总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就相当于实现了一种内存屏障.

这个时候问题又来了, 内存屏障、重排序这些东西好像是和平台以及硬件架构有关系的。 作为 Java 语言的特性,一次编写多处运行。 我们不应该考虑平台相关的问题,并且这些所谓的内存屏障也不应该让程序员来关心。

JMM

什么是JMM

JMM 全称是 Java Memory Model. 什么是 JMM 呢?

JMM模型跟CPU缓存模型结构类似,是基于CPU缓存模型建立起来的,JMM模型是标准化的,屏蔽掉了底层不同计算机的区别。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中。

    通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性

需要注意的是, JMM 并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在 JMM 中,也会存在缓存一致性问题和指令重排序问题。只是 JMM 把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题

java 内存模型底层实现可以简单的认为: 通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障

简单来说, JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。 这些方法大家都很熟悉:volatile、 synchronized、 final;以及HappenBefore规则

重排序

注意:X86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障。仅会对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障

      为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。 所谓的重排序其实就是指执行的指令顺序。编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能

从源代码到最终执行的指令,可能会经过三种重排序

          2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。

          编译器的重排序, JMM 提供了禁止特定类型的编译器重排序

          处理器重排序, JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序

内存屏障

      硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障:

  1.   lfence,是一种Load Barrier 读屏障
  2. sfence, 是一种Store Barrier 写屏障
  3. mfence, 是一种全能型的屏障,具备lfence和sfence的能力
  4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

    内存屏障有两个能力:

  1. 阻止屏障两边的指令重排序
  2. 刷新处理器缓存/冲刷处理器缓存

对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据

对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。

     JMM 层面的内存屏障

为了保证内存可见性, Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,在 JMM 中把内存屏障分为四类

  1. load1 loadload load2 -> load1 早于load2 读读屏障
  2. strore1 strorestore store2 -> 写写屏障
  3. load loadstore store ->读写屏障
  4. store storeload load ->全屏障

我们通过 javap -v xxx.class命令查看汇编指令会发现,假如了volatile关键字后有这么一条指令

flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE

烦是volatile关键字 最后一定会执行 stroeload();

不同平台下实现的文件

 

  如果存在重排序情况下, JMM提供了俩级别内存屏障(cpu,语言级别)

     lock汇编指令:cpu级别内存屏障,锁住缓存行

      volatile :语言级别内存屏障,禁止编译器对代码优化(重排序)

 

 

as-if-serial

     as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。  

     为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。  

a =1;
b=2;
c = a*b;

       A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序

happens-before

      在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

猜你喜欢

转载自blog.csdn.net/shuaishuaidada/article/details/103680242