volatile关键字浅析

一、内存模型

      程序运行过程中的临时数据存放在主内存(物理内存)当中的。而从内存中读写的速度跟CPU执行指令的速度比起来要慢的多,如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令的执行速度。因此CPU里面存在高速缓存。
      在程序运行的过程中,CPU会将运算需要的数据从主存复制一份到其高速缓存中。CPU在进行计算时,直接从其高速缓存读写数据。当运算结束之后,再将高速缓存的数据刷新到主存当中。
      当一个变量在多个CPU都存在缓存时,就有可能存在缓存不一致的问题。解决办法:

1)、通过在总线加LOCK#锁

      因为CPU和其他部件进行通讯时都是通过总线进行的,如果对总线加LOCK#锁,阻塞了其他CPU对其他部件的访问(如主存)。从而使只有一个CPU能使用这个变量的内存。
      但在锁住总线期间,其他CPU无法访问内存,导致效率低下。

2)、缓存一致性协议

      Intel的MESI协议:当CPU写数据时,如果发现操作的数据时共享变量,即在其他CPU中也存在该变量的副本。则会发送信号通知其他CPU将该变量的缓存置为无效状态。因此其他CPU需要读取该变量时,发现自身缓存中该变量的缓存是无效的,则会从主存中重新获取。

二、并发中的三个概念

      1、原子性:即一个或多个操作,要么全部执行完毕,要么都不执行。
      2、可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
      3、有序性:程序执行的顺序按照代码的先后顺序执行。(处理器会考虑指令间的数据依赖关系来进行重排序)
       如以下例子,代码1和代码2进行重排序对结果并无影响,但代码3必须在代码1,2之后。即可能出现的排序顺序是 1,2,3 或者 2,1,3

int a = 10;
int b = 17;
a = a + b;
复制代码

三、Java内存模型

      Java虚拟机中定义一种Java内存模型以屏蔽各个硬件平台和操作系统的内存访问差异。
      Java内存模型没有限制执行引擎使用处理器的寄存器或高速缓存来提升执行指令,也没有限制编译器对指令进行重排序。即Java内存模型也会出现缓存一致性问题和指令重排序的问题。
      Java内存模型规定所有的变量都是存在主存中(类似物理内存),每个线程都有自己的工作内存(类似CPU的高速缓存)。线程对变量的所有操作必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

1、原子性

x = 10 //语句1
y = x  //语句2
x++    //语句3
x = x + 1  //语句4
复制代码

      只有语句1是原子性操作,其他三个语句都不是原子性操作。
      语句1直接将10赋值给x,也就是说线程执行这个语句时会直接将数值10写入到工作内存中。而其他语句实际上包含2个操作,先去读取x的值,再将x的值写入工作内存。

      总结:Java内存模型只保证简单的读取和赋值(变量之间相互赋值不是原子操作)才是原子操作。想要保证大范围操作的原子性,需要通过synchronized和Lock实现,确保任意时刻只有一个线程执行该代码块。

2、可见性

      Java提供volatile关键字保证可见性。
      当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存中,当有其他线程需要读取时,它会去主存读取新值。
      而普通的共享变量不能保证可见性,因为普通共享变量被修改后,什么时候写入主存是不确定的。当其他线程去读取,此时内存可能还是原来的旧值。因此无法保证可见性。
      通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁,然后执行同步代码,并且释放锁之前会将变量的修改刷新到主存当中,因此可以确保可见性。

3、有序性

      在Java内存模型中,允许编译器对指令进行重排序,但是重排序不会影响到单线程的执行,却影响到多线程并发执行的正确性。

比如new对象时,会进行三件事件:
      (1)、给实例分配内存;
      (2)、调用构造方法,初始化成员变量。
      (3)、将对象指向分配的内存空间。
而在JVM中(2)和(3)的顺序是无法被保证的,只能通过volalite保证其有序性。

四、深入剖析volatile关键字

1)保证不同线程对变量进行操作时的可见性(即一个线程修改某个变量的值,这新值对其他线程来说是立即可见的)

2)禁止进行指令重排序。

volatile的原理和实现机制:
      “观察加入volalite关键字和没有加入volalite关键字锁生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
      lock前准指令实际上相当于一个内存屏障,内存屏障会提供3个功能:
      1、确保指令重排序时,不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。
      2、强制对缓存的修改操作立即写入内存。
      3、如果是写操作,会导致其他CPU中对应的缓存无效。

五、使用volatile关键字的场景

      synchronized关键字是防止多个线程同时执行一段代码,但会影响执行效率。而volatile关键字在某些情况下性能优于synchronized,但不能替代synchronized,因为volatile不能提供原子性。
      1)对变量的写操作不依赖于当前值
      2)该变量没有包含在具有其他变量的不变式中

个人总结:

      配合计算机的内存模型,可以很好的理解Java的内存模型。以及了解到,可见性在高并发时的重要性。
      在确保某个变量(比如某个flag值)在单线程进行修改操作时,可以使用volatile确保该变量的可见性。相比synchronized效率有所提升。
      在单例DCL中,synchronized时确保了变量的有序性、可见性。但并没有确保有序性,这时就需要将变量修饰成volatile来确保有序性

public class Singleton{
    private volatile static Singleton mInstance;
    private Singleton() {}
    
    public static Singleton getInstance(){
        if( mInstance == null){// 语句A
            synchronized(Singleton.class){ 
                if( mInstance == null){ // 语句B
                    mInstance = new Singleton();
                }
            }
        }
        return mInstance;
    }
}
复制代码

因为当指令进行重排序后会出现以下情况:
(1)、给Singleton的实例分配内存;
(3)、将mInstance对象指向分配的内存空间。
(2)、调用Singleton()的构造方法,初始化成员变量。
      当多线程并发时,线程A先运行到语句A中,mInstance是null,线程A进行单例对象的初始化。但因为指令重排序,出现了(1)(3)(2)的情况,当线程A还没执行完(2),也就是还没初始化完单例对象时。线程B运行到语句A。此时单例对象已不为null,自然语句A为false,线程B会返回一个还没初始化完毕的mInstance对象。

参考文章:

1、www.cnblogs.com/dolphin0520…
2、《Android源码设计模式》——《单例设计模式》

猜你喜欢

转载自juejin.im/post/5cba8794f265da038e54a661