深入理解volatile 关键字

一个可见性问题引发的思考

我们下来看一段代码:

public static boolean flg =false;
    public static void main(String[] args) throws InterruptedException {
     
        Thread thread=new Thread(()->{
     
            int i=0;
            while (!flg){
     
                i++;
                //1. System.out.println("i:="+i);

                // 2.Thread.sleep(1000);
                /*try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }*/
            }
        });
        thread.start();
        Thread.sleep(1000);
        flg=true;
    }

运行结果:
深入理解volatile 关键字_第1张图片
如果放开第一段注释代码或者第二段注释代码,就会发现程序正常结束,这是为什么呢?我们来分析一下

print导致循环结束

  • 因为print底层运用了synchronized关键字,说明println有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存中。

    public void println(String x) {
           
            synchronized (this) {
           
                print(x);
                newLine();
            }
        }
    
  • 从IO角度来说,print本质上是一个IO操作,而磁盘的IO效率一定要比CPU的计算效率慢得多,所以IO可以使得CPU有时间去做内存刷新的事情,从而导致这个现象。我们可以通过去定义一个new File()去验证

Thread.sleep(long)

  • Thread.sleep(long)会导致线程切换,从而导致缓存失效,进而从此读取到最新的值

volatile

我们知道,之所以会出现文章一开头锁提到的问题,主要是因为可见性。java为了保证线程的可见性,从而提出了volatile关键字。

什么是可见性?

在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性。

为什么会出现可见性问题?

1. 高速缓存

现代处理器(CPU)的处理能力要远胜于主内存(DRAM)的访问速率,主内存执行一次读、写操作的时间足够处理器执行上百条的指令。为了弥补处理器与主内存之间的鸿沟,硬件设计者在主内存和处理器之间引入了高速缓存(cache),如图:
深入理解volatile 关键字_第2张图片
高速缓存(cache) 是一种存取速率远大于主内存而容量远比主内存小的存储部件,每个处理器都有其高速缓存。引入高速缓存后,处理器在执行读、写操作的时候并不直接去主内存打交道,而是通过高速缓存进行。
现代处理器一般具有多个层次的高速缓存,如上图所示。就有一级缓存(L1 Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache)。它们的访问顺序:L1>L2>L3。
当多个线程在访问同一个共享变量的时候,每个线程的处理器的高速缓存上都会各自保留一份该共享变量的副本,这样就带来一个问题:当一个处理器对副本数据更新之后,其他处理器如何知道并做出适当反应呢?这个就涉及到可见性问题。同时也被称为缓存一致性问题

缓存一致性问题(MESI)

MESI(Modified-Exclusive-Shared-Invalid)协议是一种广为使用的缓存一致性协议,x86处理器所使用的一致性协议就是基于MESI协议的。
为了保障数据一致性,MESI将缓存条目状态划分为四种:Modified、Exclusive、Shared、Invalid,并定义了一组消息(Message)用于协调各个处理器之间的读写操作。

  • Invalid(无效的,记为I),表示相应缓存行中的不包含任何内存地址对应的有效副本。该状态是缓存条目的初始状态
  • Shared(共享的,记为S),表示相应缓存行中包含相应内存地址对应的副本数据,且其他处理器上的高速缓存同样包含相同内存地址对应的副本数据,如图:
    深入理解volatile 关键字_第3张图片
  • Exclusive(独占,记为E),表示相应缓存行中包含相应内存地址对应的副本数据,且其他所有处理器上的高速缓存都不保留该副本数据,如图:
    深入理解volatile 关键字_第4张图片
  • Modified(更改过的,记为M),表示相应缓存行中包含相应内存地址所做的更新结果数据。在MESI协议中,任意一个时刻只能够有一个处理器对同一个内存地址对应的数据进行更新操作。 如图:
    深入理解volatile 关键字_第5张图片
    MESI协议定义了一组消息(Message)用于协调各个处理器的读、写内存操作,如图:
    深入理解volatile 关键字_第6张图片
    接下来,我们通过流程图来简单了解一下MESI协议的工作过程:
    深入理解volatile 关键字_第7张图片
    从上面这张图我么可以看到MESI协议的一个性能弱点:处理器执行完内存操作之后,必须等待其他所有处理器将其高速缓存中相应的副本数据并接收到这些处理器所回复的
    Invalidate Acknowledge/Read Response消息之后才能将数据写入高速缓存中。
    为了规避和减少这种等待所造成的写操作的延迟,硬件设计者又引入了写缓存和无效化队列
写缓冲器(Store Buffer)和无效化队列(Invalidate Queue)

写缓冲器(Store Buffer,也被称为Write Buffer)是处理器内部的一个容量比高速缓存还小的私有高速缓存部件。引入写缓冲器后,处理器在执行操作时会这样处理:如果相应的缓存条目为S,那么处理器会先将写操作的相关数据(包括数据和带操作的内存地址)存入写缓冲器的条目之中,并异步发送Invalidate消息,即内存写操作的执行处理器在将写操作的相关数据放入写缓冲器之后便认为写操作已经完成,不去等待其他处理器返回Invalidate Acknowledge/Read Response消息而是继续执行其他指令,从而减少了写操作的延迟
无效化队列(Invalidate Queue),处理器在接收到Invalidate消息后并不删除消息中指定的内存地址对应的副本数据,而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息,从而减少了写操作执行器所需等待的时间。
过程如下:
深入理解volatile 关键字_第8张图片
但是,写缓冲器和无效化队列又会带来一些新的问题:指令重排序

2. 指令重排序

我们通过一个例子来详细说明一下指令重排序的问题:

int data =0;
boolean ready=false;
void threadDemo1(){
     
    data=1; //S1
    ready=true; //S2

}
void threadDemo2(){
     
   while (!ready){
      //S3
       System.out.println(data);  //S4
   }
}

假设CPU0的高速缓存只有ready的副本,CPU1的高速缓存只有data的副本
执行过程如下图:
深入理解volatile 关键字_第9张图片
从CPU1的角度来看,这样就造成了一种现象:S2先于S1执行

内存屏障

处理器支持哪种内存重排序,就回提供能够禁止相应重排序的指令,这些指令就被称为内存屏障
内存屏障可以统一用XY来表示,其中X和Y分表表示Load(读)和Store(写)。内存屏障的作用是禁止该指令左侧的任何X操作与该指令右侧的任何Y操作之间进行重排序,从而确保该指令左侧的所有X操作先于指令右侧的Y操作被提交、如下图:
深入理解volatile 关键字_第10张图片

原理

volatile的原理其实就是利用了底层的内存屏障来实现的,我么可以看一下加了volatile 关键字之后的伪代码:

	volatile int data =0;
    boolean ready=false;
    void threadDemo1(){
     
        data=1;
        //StoreStore 确保前后的写操作已经写入到高速缓存中
        ready=true;

    }
    void threadDemo2(){
     
        while (!ready){
     
            //LoadLoad 确保ready的读操作在data的读操作之前
            System.out.println(data);
        }
    }

总结一下: volatile 读 / 写插入内存屏障规则:

  • 在每个 volatile 读操作的后面插入 LoadLoad 屏障和 LoadStore 屏障
  • 在每个 volatile 写操作的前后分别插入一个 StoreStore 屏障和一个 StoreLoad 屏障

happens-before模型

Java内存模型(Java Memory Model,JMM)定义了volatile、final和synchronized关键字的行为并确保了正确同步的Java程序能够正确的运行在不同架构的处理器上。
在原子性方面,JMM规定对long/double之外的基本数据类型以及引用类型的共享变量进行读写操作都具有原子性。另外,JMM还特别规定对volatile修饰的long/double共享变量进行读写操作也具有原子性。
对于可见性和有序性问题,JMM则使用了happens-before模型来解答
happens-before 规则如下:

  • 程序顺序规则:即貌似串行语义(As-if-serial)。一个线程内任何一个动作的结果对程序顺序上该动作之后的其他动作都是可见的,并且这些动作在该线程自身看来就像是完全依照程序顺序执行提交的。
  • 监视器锁规则:监视器锁的释放happens-before后续每一个对该锁的申请。
    注意:“释放”与“申请”必须是针对同一类型锁实例,也就是说一个锁的释放与另一个锁的申请并无happens-before关系
  • volatile变量规则:一个volatile变量的写操作happens-before后续每一个针对该变量的读操作。
    注意:必须是针对同一个volatile变量,其次针对同一个volatile变量的读写操作必须具有时间上的先后关系。
  • 线程启动规则:调用线程的start()方法happens-before被启动的这个线程中的任何一个动作。
  • 线程终止规则:一个线程中任何一个动作都happens-before该线程的join方法的执行线程在join方法返回之后所执行的任意动作
  • 传递性规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

总结

  • volatile通过缓存一致性实现了可见性
  • volatile通过内存屏障禁止了指令的重排序,从而保证了有序性
  • JMM通过happens-before模型更简洁的描述了可见性和有序性问题。

你可能感兴趣的:(并发编程)