Java并发:volatile内存可见性和指令重排

版权声明:转载请标明出处 https://blog.csdn.net/weixin_36759405/article/details/82856542

1. 正确认识 volatile

volatile变量具有synchronized的可见性特性,但是不具备原子特性。volatile变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用volatile还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。

出于简易性或可伸缩性的考虑,我们更倾向于使用volatile变量而不是锁。此外,volatile变量不会像锁那样造成线程阻塞。在某些情况下,如果读操作远远大于写操作,volatile变量还可以提供优于锁的性能优势。

2. 何时使用 volatile

我们只能在某些特定情形下使用volatile变量替代锁,要使volatile变量提供理想的线程安全,必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入volatile变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

第一个条件的限制使volatile变量不能用作线程安全计数器。虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而volatile不能提供必须的原子特性。实现正确的操作需要使 x 的值在操作期间保持不变,而volatile变量无法实现这点。(然而,如果将值调整为只从单个线程写入,那么可以忽略第一个条件。)

大多数编程情形都会与这两个条件的其中之一冲突,使得volatile变量不能像synchronized那样普遍适用于实现线程安全。

3. 内存可见性

Java内存模型(关于内存模型可参照 https://www.cnblogs.com/dolphin0520/p/3920373.html ,Java内存模型定义了8种原子操作)规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),线程只能访问自己的工作内存,不可以访问其它线程的工作内存。

先看一段代码,假如线程1先执行,线程2后执行:

// 线程1
boolean stop = false;
while(!stop){
    doSomething();
}
 
// 线程2
stop = true;

很多人在中断线程时可能都会采用这种标记办法。但是事实上并不一定能够中断线程,为什么呢?在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

所以也许在大多数时候,这个代码能够把线程中断,但是一旦出现上面的情况那么将不仅仅是无法中断线程,还可能发生死循环。

被 volatile 修饰的变量则不同:

  1. 使用volatile关键字会强制将修改的值立即写入主存。
  2. 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效)。
  3. 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存对应的主存地址被更新之后,然后去对应的主存读取最新的值。

4. 指令重排序

什么是指令重排序?
指令重排序是JVM为优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。

看以下语句:

1 double r = 2.1;
2 double pi = 3.14;
3 double area = pi*r*r;

计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。

语句重排会出现什么问题呢?先看下面的代码:

// 线程A中
{
   context = loadContext();
   inited = true;
}
// 线程B中
{
   if (inited) 
     fun(context);
}

如果线程A中的指令发生重排序,那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。

再看另一个例子:指令重排导致单例模式失效,我们都知道一个经典的懒加载方式的双重判断单例模式:

public class Singleton {
  private static Singleton instance = null;
  private Singleton() { }
  public static Singleton getInstance() {
     if(instance == null) {
        synchronzied(Singleton.class) {
           if(instance == null) {
               <strong>instance = new Singleton();  //非原子操作
           }
        }
     }
     return instance;
   }
}

看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

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

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

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

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。

在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

volatile如何禁止指令重排序?
volatile关键字通过提供“内存屏障”的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

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

下面这段话摘自《深入理解Java虚拟机》:
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
  2. 它会强制将对缓存的修改操作立即写入主存。
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

总结:在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在JDK 1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

猜你喜欢

转载自blog.csdn.net/weixin_36759405/article/details/82856542