深入解析volatile关键字

知识关联:

CPU Cache模型与JMM

JMM与并发三大特性

(示例使用jdk1.7)

volatile关键字是基于MESI缓存一致性协议的,协议的主要内容是多个CPU从主存读取数据到缓存,当其中某个CPU修改了缓存中数据,该数据会立刻同步回主存,其他CPU通过总线嗅探机制可以感知到数据的变化,从而将自己缓存中的数据失效,重新从主存中获取。

一、volatile语义

volatile修饰的实例变量或类变量具备两层语义:

  • 保证了不同线程之间对共享变量操作时的可见性。即当一个线程修改了volatile修饰的变量,另外一个线程会立即看到最新的值。
  • 禁止对指令进行重排序。

1.1 volatile保证可见性

volatile修饰的变量,当一个线程在自己工作内存中执行修改操作,会立即将修改后的值同步回主存(不会等到程序执行结束或者其他时间),其他线程工作内存的的值会立即失效再从主存中获取。

  1 public class FlagTest {
  2     private volatile static boolean flag = false;
  3 
  4     public static void main(String[] args) throws InterruptedException {
  5         new Thread(new Runnable() {
  6             @Override
  7             public void run() {
  8                 while (!flag){
  9 
 10                 }
 11             }
 12         },"minder").start();
 13 
 14 
 15         TimeUnit.SECONDS.sleep(2);
 16 
 17         new Thread(new Runnable() {
 18             @Override
 19             public void run() {
 20                 System.out.println("the work is done");
 21                 flag = true;
 22             }
 23         },"worker").start();
 24     }
 25 }
 26 
VisionTest

上例,若flag不用volatile修饰,在worker将flag值为true后,程序仍一直处于运行状态。volatile保证了worker线程对flag的每次操作minder线程都能看到(对应于happens-before原则第三条,volatile原则:对一个变量的写操作要早于对这个变量的读操作),具体步骤如下:

1)minder线程从主存中获取flag的值,缓存到工作内存

2)woker线程将工作内存中flag值为true,立即刷新回主存

3)minder线程工作内存中的flag失效,重新到主存中获取flag

不过有趣的是,不用volatile修饰flag时,当你在minder循环中打印一句话,最后也会退出循环。其原因是,minder线程把flag读到工作内存,因为flag未被volatile修饰,因此在worker修改flag的值后,minder未去读最新值,但打印"print"操作是一个同步操作,执行它时会刷新工作内存,因此会读到flag的最新值。

1.2 volatile保证顺序性

(1) 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

(2) 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

volatile保证顺序性的方式,是直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后无依赖关系到的指令则可以随便怎么排序。

例1:

  1 int x = 0;
  2 int y = 0;
  3 volatile int z = 10;
  4 x++;
  5 y++
example

在语句z=20之前,先执行x还是y的定义赋值没什么关系,只要能保证执行到z=20的时候x=0,y=1,就可以,同理关于x的自增和y的自减操作都必须在z=20以后发生。

例2:

  1 ublic class Load {
  2     private volatile boolean flag = false;
  3     private static Socket socket;
  4 
  5     public Load(){
  6         socket = new Socket();
  7         flag = true;
  8     }
  9 
 10     public static void main(String[] args) {
 11         final Load[] load = new Load[1];
 12         new Thread(){
 13             @Override
 14             public void run() {
 15                 load[0] = new Load();
 16             }
 17         }.start();
 18 
 19         new Thread(){
 20             @Override
 21             public void run() {
 22                 while (true){
 23                     if (load[0].flag){
 24                         //operate files and methhods in Load
 25                     }
 26                 }
 27             }
 28         }.start();
 29     }
 30 }
 31 
Load

上例,flag被volatile修饰,意味着在执行到flag=true时,一定再执行且完成了对socket的初始化。因此它能避免多线程情况下,如上,若flag不是volatile,则重排序可能出现两个情况:

1)socket在flag=true后执行,一个线程初始化Load,则到flag=true时,socket还未初始化。另一个线程使用的是一个未初始化完成的Load对象。

2)socket在flag=true之后执行,socket初始化可能还为初始化完成时,flag已经被赋为true。另一个线程使用的是一个未初始化完成的对象。

volatile修饰能保证,socket初始化在flag=true之前进行初始化操作,且在socket初始化完成后再执行flag=true。

1.3 volatile不保证原子性

  1 public class IncreaseTest {
  2     private static int p = 0;
  3 
  4     public static void main(String[] args) throws InterruptedException {
  5         for (int i = 0;i < 3;i++)
  6         new Thread(){
  7             @Override
  8             public void run() {
  9                 for (int i = 0;i < 1000;i++){
 10                     p++;
 11                     System.out.println(Thread.currentThread().getName()+"="+p);
 12                 }
 13             }
 14         }.start();
 15     }
 16 }
 17 
IncreaseTest

上述代码多次运行结果一定是小于等于3000,导致结果的主要原因是p++操作是非原子操作,是由三步组成:

1)从主存中获取p,缓存至当前线程工作内存;

2)在工作内存中进行p+1操作;

3)将最新的值刷新回主存。

上面三个操作查看汇编指令,分别对应于load,increase和store。三个操作单独都是原子性操作,但组合起来就不是,在执行过程中,可能发生:

1)线程A从主存读取到p=10,由于CPU时间片调度关系,执行权换到线程B;

2)因为线程A未修改p,线程B从主存中仍获取到p=10;

3)线程B在工作内存中执行p+1操作,但还未刷新到主存,CPU又将执行权给线程A;

4)线程B未刷新回主存,线程A看不到修改,线程A直接将工作内存中的p执行+1操作;

5)线程A将p=11写入主存;

6)线程B将p=11写入主存。

这样两次操作实际p只进行了一次变化。

问题详解:

第五步,线程A将p=11写入主存后,线程B再次执行,P为什么没有失效,去获取最新的值?

happens-before原则第三条,volatile变量规则:对一个变量的写操作要早于对这个变量的读操作。所以读一个volatile变量时,总会返回某一线程写入的最新值。但p++是一个复合操作,第五步时,线程B执行到store操作,不会再到内存中读取p的值。而p++操作中,内存可见性是针对于load操作,load操作期间若发生另一线程修改的p的情况,才会重新读取最新值。用一个例子做类比:

 1 public class LoopTest {
 2     static volatile boolean flag = false;
 3 
 4     public static void main(String[] args) throws InterruptedException {
 5         new Thread(new Runnable() {
 6             @Override
 7             public void run() {
 8                 while (true)
 9                     while (flag){
10                         System.out.println(Thread.currentThread().getName()+" start to work");
11                         try {
12                             TimeUnit.SECONDS.sleep(20);
13                         } catch (InterruptedException e) {
14                             e.printStackTrace();
15                         }
16                         System.out.println(Thread.currentThread().getName()+" is done");
17                     }
18             }
19         },"worker").start();
20         
21         TimeUnit.SECONDS.sleep(2);
22         
23         new Thread(new Runnable() {
24             @Override
25             public void run() {
26                 flag = true;
27                 System.out.println("flag changed to true");
28                 try {
29                     TimeUnit.SECONDS.sleep(3);
30                 } catch (InterruptedException e) {
31                     e.printStackTrace();
32                 }
33                 flag = false;
34                 System.out.println("flag changed to false");
35             }
36         }).start();
37     }
38 }
LoopTest

上述代码,当flag第一次被改为true时,worker线程进入while (flag) 循环,但flag再次被修改为false时,worker线程仍在循环体内处于睡眠状态。原因是worker执行的循环操作不是原子性操作,flag被修改时,worker线程没有在执行监听,load操作状态,而处于其他操作中。

因此在《并发编程实战》中,作者给出使用volatile前要满足的三条标准:

  • 写入变量时并不依赖变量的当前值;或者能够确保只有单一的线程修改变量的值;
  • 变量不需要与其他的状态变量共同参与不变约束;
  • 并且访问变量时,没有其他的原因需要加锁。

二、volatile原理和实现机制

volatile保证可见性和顺序性,是通过“lock;”实现的(汇编可见)。“lock;”前缀相当于一个内存屏障,该屏障会为指令的执行提供如下几个保障。

  • 确保指令重排序不会将其后面的代码排到内存屏障之前。
  • 确保指令重排序时不会将其前面的代码排到内存屏障之后。
  • 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成。
  • 强制将线程工作内存中值的修改刷新至主内存中,这个刷新过程是由lock保证原子性的,即缓存一致性协议会阻止同时修改由两个以上处理器缓存的内存区域数据,也就是在写入过程中对(store-write)两个操作进行加锁,保证只有一次只有一个线程将工作内存中数据写回主存。
  • 如果是写操作,则会导致其他线程工作内存中的缓存数据失效。

三、volatile与synchornized区别

(1) 使用上的区别

  • volatile只能用于修饰实例变量或者类变量,不能修饰方法及方法参数、局部变量和常量等。
  • synchornized关键字不能用于对变量的修饰,只能用于修饰方法或者语句块。
  • volatile修饰的变量可以为null,synchornized同步语句块的monitor对象不能为null。

(2) 对原子性的保证

  • volatile无法保证原子性。
  • 由于synchornized使用一种排他机制,因此synchornized修饰的同步代码是无法被中途打断的,能够保证代码原子性。

(3) 对可见性的保证

  • 两者均可以保证共享资源在多线程间的可见性,但是实现机制完全不同。
  • synchornized借助于JVM指令monitor enter和monitor exit对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都将被刷新到主内存中。
  • volatile使用机器指令“lock;”的方式迫使其他线程工作内存中的数据失效,只能再次从主存中获取。

(4) 对有序性的保证

  • volatile禁止JVM编译器以及处理器对其进行重排序,所以它能够保证有序性。
  • synchornized所修饰的同步方法也能保证顺序性,但是这种顺序性是以程序的串行化执行换来的,在所修饰的代码块中代码指令也会发生指令重排序的情况发生。

(5) 其他

  • volatile不会使线程陷入阻塞。
  • synchornized会使线程陷入阻塞状态。
posted @ 2020-05-17 00:25  Aidan_Chen  阅读(225)  评论(0编辑  收藏  举报