[Java]volatile关键字

Java内存模型(Java Memory Model)

这里写图片描述
上图大概示意了Java的内存模型,其中
  主内存(Main Memory):课件可以简单理解为计算机当中的内存,但又不完全等同。它被所有线程共享,对于一个被共享的变量(如静态变量、或是堆中的对象实例)来说,主内存当中存放了它的“本尊”。
  工作内存(Working Memory):工作内存可以简单理解为CPU高速缓存,但又不完全等同。每一个线程拥有独立的工作内存,对于一个共享变量来说,工作内存中相当于存放了它的“副本”。
  线程对共享变量的操作只能在工作内存中来实现,不能直接读写主内存中的变量。不同线程之间也不能访问彼此的工作内存,变量值的传递只能通过主内存来进行。(因为效率的原因,不能直接操作主内存)
JMM中一个简单的读写过程如下所示:
这里写图片描述

为什么需要volatile

  上面的读写过程在单线程中是没有问题的,但如果是在多线程中就可能会遇到问题。如果引入线程A、B,线程A执行上述读写过程,线程B执行System.out.println(s)操作。让线程B在线程A之后执行。那么线程B输出的内容不一定是3,也有可能是0。因为线程B可能是在线程A将变量值写入主内存之后执行,也可能是在线程A还未将变量值写入主内存时执行。
  此时的解决办法有两种,一种是使用synchronized同步锁,但是synchronized对程序性能影响比较大。可以使用volatile修饰符,volatile关键字保证了被其修饰的变量对所有线程的可见性。即当一个变量被线程修改时,变量新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候会拉取到最新的值。

为什么volatile有这样的作用

  这涉及到Java语言的先行发生原则。先行发生原则是两个事件结果之间的关系,如果一个事件发生在另外一个事件之前,结果必须反映出来,即使这些事件是乱序执行的。(通常是优化程序流程)
  这里所谓的事件,实际上就是指各种指令操作,比如读写操作、初始化操作、锁操作等等。
  先行发生原则作用于很多场景下,包括同步锁、线程启动、线程终止、volatile等等。
  对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。对应于前面的例子来说就是线程A将值写入主内存的操作会先于线程B的读操作。

volatile不能保证变量原子性

public class VolatileTest {
    private volatile static int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    for (int j = 0; j < 100; j++){
                        count++;
                    }
                }
            }).start();
        }
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

  上述代码是开启是个线程,每个线程对count进行100次自增操作。理想中最后count的值应该为1000,但事实不一定如此,有可能小于1000。
  出现这个问题的原因是,count++这一行java代码并不是原子操作,经过编译器编译为字节码之后,会变成如下指令
  
  getstatic //获取静态变量count
  iconst_1 //定义常量1
  iadd //count增加1
  putstatic //把count结果同步到主内存
  

  虽然每一次执行getstatic操作时获取到的都是主内存中最新的值,但是进行iadd操作时,因为并不是原子操作,其他线程可能已经使count自增了很多次。这样一来,本线程所计算更新的是一个陈旧的count值,因此无法做到线程安全。

什么时候使用volatile

  1. 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  2. 变量不需要与其他的状态量共同参与不变约束

第一条就是上面的例子。第二条可参考如下场景:

volatile static int start = 3;
volatile static int end = 6;

线程A执行如下代码:
while (start < end){
  //do something
}

线程B执行如下代码:
start+=3;
end+=3;

这种情况下,一旦在线程A的循环中执行了线程B,start有可能先更新为6,造成一瞬间start==end,从而跳出while的可能性。

指令重排

  指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
  指令重排的目的是为了在不改变程序执行结果的前提下,优化程序运行效率。注意,这儿说的不改变结果是指在单线程下的程序执行结果
  指令重排虽然优化了程序执行效率,但是也有可能影响多线程下的执行结果。如:

  boolean contextReady = false;
  
  在线程A中执行:
  context = loadContext();
  contextReady = true;

    在线程B中执行:
    while( ! contextReady ){ 
       sleep(200);
    }
    doAfterContextReady (context);

  上述程序看似没什么问题,线程B等待线程A中加载context,直到加载完成将contextReady赋值为true,然后在线程B中执行doAfterContextReady。
  但是,如果线程A中的代码交换了顺序,
 
  contextReady = true;
  context = loadContext();
 

  那么有可能context还没有加载完成,contextReady已经为true,线程B就直接跳出等待,开始执行doAfterContextReady。
  但是,事实上指令重排是存在于字节码层面的,这儿只是为了方便理解。

内存屏障

  内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。
  内存屏障分为以下四种类型:
  1. LoadLoad屏障:
    1. 抽象场景:Load1;LoadLoad;Load2
    2. Load1和Load2代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  2. StoreStore屏障:
    1. 抽象场景:Store1;StoreStore;Store2
    2. Store1和Store2代表两条写入指令。在Store2写入执行之前,保证Store1的写入操作对其它处理器可见。
  3.LoadStore屏障:
    1. 抽象场景:Load1;LoadStore;Store2
    2. 在Store2被写入之前,保证Load1要读取的数据被读取完毕。
  4. StoreLoad屏障:
    1. 抽象场景:Store1;StoreLoad;Load2
    2. 在Load2读取操作执行之前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。

volatile实现先行发生原则的方法

一个变量被volatile修饰符修饰之后,JVM会帮我们做两件事:
1. 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2. 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。具体来说就如前面提到的例子,
线程A:

boolean contextReady = false;

在线程A中执行:
context = loadContext();
contextReady = true;

当contextReady被volatile修饰符修饰之后,会变成:
这里写图片描述
由于加入了StoreStore内存屏障,所以context = loadContext()contextReady = true两条指令便不能进行指令重排。

结论

volatile特性之一:
保证变量在线程之间的可见性,但不能保证原子性。可见性的保证是基于CPU的内存屏障指令,被JSR-133抽象为happens-before原则。

volatile特性之二:
阻止编译时和运行时的指令重排。编译时JVM编译器遵循内存屏障的约束,运行时依靠CPU屏障指令来阻止重排。

猜你喜欢

转载自blog.csdn.net/vi_nsn/article/details/78917436