码迷,mamicode.com
首页 > 其他好文 > 详细

深入解析volatile关键字

时间:2020-05-17 00:45:32      阅读:69      评论:0      收藏:0      [点我收藏+]

标签:缓存   变量   第一个   指令   art   opera   vol   ++操作   incr   

知识关联:

CPU Cache模型与JMM

JMM与并发三大特性

告读者:本文中的代码均由jdk1.7运行,可能由于jdk1.8的优化,笔者用jdk1.8测试得不到预想结果。

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

1、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

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,修改和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为什么没有失效,去获取最新的值?

其实严格的说,对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。在《Java并发编程的艺术》中有这一段描述:“在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。”需要注意的是,这里的修改操作,是指的一个操作

  • 对比1.1 volatile保证可见性中的代码,while循环会不断读取flag的值来进行判断,而每一次判断都包含一个读操作。单个读取操作具有原子性,所以一个线程修改了flag时,由于可见性,另一个线程再读取到flag最新值。
  • 而本例中,p++是一个由三个原子操作组成的复合操作。在这个复合操作中,内存可见性是针对于load,即第一个操作读取的时候,在线程A执行到第三个操作写回主存操作时,不会再读取p的值。

总的来说,在自增操作中,执行load,修改和store三步,在还没有将store指令操作完成的时候,如果有其他线程介入修改共享变量并更新,此时更新是错误的。因为此时内存可见性是针对执行load操作的时候,需要刷新读取最新的值。

2、volatile原理和实现机制

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

  • 确保指令重排序不会将其后面的代码排到内存屏障之前。
  • 确保指令重排序时不会将其前面的代码排到内存屏障之后。
  • 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成。
  • 强制将线程工作内存中值的修改刷新至主内存中。
  • 如果是写操作,则会导致其他线程工作内存中的缓存数据失效。

3、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会使线程陷入阻塞状态。

深入解析volatile关键字

标签:缓存   变量   第一个   指令   art   opera   vol   ++操作   incr   

原文地址:https://www.cnblogs.com/privateNotesOfAidanChen/p/12903107.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!