java并发中volatile的使用

2017-07-25  本文已影响87人  HelloArmin

java中volatile声明变量,有两个作用

保证可见性

多线程访问共享变量时,声明volatile可以保证了共享变量可见性。可见性,即当一个线程修改共享变量时,另一个线程能读到变量修改后的值。

没有了解过计算机系统低层实现的同学,可能会好奇,为什么会发生一个线程更新一个变量,另一线程读到的是是更新前的值的情况?

计算机系统,为了提高处理速度,CPU其实并不直接与内存进行通信,而是将内存中的数据读取到CPU高速缓存(L1,L2,L3)中,操作完后也并不总是会马上写回内存。只有当缓存被置换出去的时候,才会去写内存。

多处理器环境下,保证缓存一致性,有两种方式:总线锁和MESI缓存一致性协议。

总线锁(bus locking):
当一个CPU对缓存中的数据进行操作的时候,会往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。

总线锁带来的性能成本高昂,更多的计算机采用的是MESI缓存一致性协议。

MESI协议, 会在缓存中存储一个标志位。这个标志位有以下四种状态:

每个CPU都会监听其他CPU对缓存的读写操作。

使用volatile声明的变量,JVM会发送lock指令,将缓存写回内存。由于缓存一致性协议,写回缓存的时候,其他处理器的缓存将失效。从而保证了该变量值得一致性。

禁止指令重排

编译器和处理器为了提升程序性能,会按照一定的规则进行对指令进行重排顺序。在单线程下,这样的重排并不会带来问题。因为重排会遵守as-if-serial语义.

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


double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

C 依赖于A 和B,因此重排时C不会被重排到A和B之前。而A和B没有依赖,重排也复合as-if-serial语义,编译器和处理器可以对其进行重排。

经常提到的使用双检查锁来创建singleton对象的问题,正是由于指令重排导致的。

public class Singleton {

  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {
     if(instance == null) {
        synchronzied(Singleton.class) {
           if(instance == null) {
               instance = new Singleton();  
           }
        }
     }
     return instance;
   }
}

new Singleton()并非是一个原子操作,它有多条指令组成:

memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址

但是经过重排序后:

memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化
ctorInstance(memory); //2:初始化对象

指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面,在线程A初始化完成这段内存之前,线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得instance对象进行使用就可能发生错误。

使用volatile会插入内存屏障指令,防止编译和运行时指令被重排。

上一篇下一篇

猜你喜欢

热点阅读