[关闭]
@c102zkl 2018-07-27T04:11:11.000000Z 字数 7551 阅读 555

volatile关键字?

Java基础 多线程


 volatile关键字虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的,因此在讲述volatile关键之前,我们先来了解一下与内存模型相关的概念和知识,然后分析了volatile关键字的实现原理,最后给出了几个使用volatile关键字的场景。

链接
Java 内存模型

并发编程中的三个概念

 在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:
 
 

1. 原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  一个很经典的例子就是银行账户转账问题:

  比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

  试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

  所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

  同样地反映到并发编程中会出现什么结果呢?

  举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

  1. 1
  2. i = 9;

  假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

  那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

2. 可见性
可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  举个简单的例子,看下面这段代码

  1. //线程1执行的代码
  2. int i = 0;
  3. i = 10;
  4. //线程2执行的代码
  5. j = i;

  假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

  此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

  这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
  
3. 有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

  1. int i = 0;
  2. boolean flag = false;
  3. i = 1; //语句1
  4. flag = true; //语句2

  上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)

  下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

  比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

  但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

  1. int a = 10; //语句1
  2. int r = 2; //语句2
  3. a = a + 3; //语句3
  4. r = a*a; //语句4

那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3

  不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

  虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

  1. //线程1:
  2. context = loadContext(); //语句1
  3. inited = true; //语句2
  4. //线程2:
  5. while(!inited ){
  6. sleep()
  7. }
  8. doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

  从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

  也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
 


指令重排序

指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序

指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果

然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。我们来看看下面的例子:

  1. boolean contextReady = false;
  2. 在线程A中执行:
  3. context = loadContext();
  4. contextReady = true;
  5. --------
  6. 在线程B中执行:
  7. while( ! contextReady ){
  8. sleep(200);
  9. }
  10. doAfterContextReady (context);

以上程序看似没有问题。线程B循环等待上下文context的加载,一旦context加载完成,contextReady == true的时候,才执行doAfterContextReady 方法。

但是,如果线程A执行的代码发生了指令重排,初始化和contextReady的赋值交换了顺序:

  1. boolean contextReady = false;
  2. 在线程A中执行:
  3. contextReady = true; // 注意这里 交换了顺序
  4. context = loadContext();
  5. 在线程B中执行:
  6. while( ! contextReady ){
  7. sleep(200);
  8. }
  9. doAfterContextReady (context);

这个时候,很可能context对象还没有加载完成,变量contextReady 已经为true,线程B直接跳出了循环等待,开始执行doAfterContextReady 方法,结果自然会出现错误。

这里java代码的重排只是为了简单示意,真正的指令重排是在字节码指令的层面。


内存屏障

内存屏障(Memory Barrier)是一种CPU指令,维基百科给出了如下定义:
内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。

内存屏障共分为四种类型


LoadLoad屏障:
抽象场景:Load1; LoadLoad; Load2
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:
抽象场景:Store1; StoreStore; Store2
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见

LoadStore屏障:
抽象场景:Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:
抽象场景:Store1; StoreLoad; Load2
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。


Volatile

在前面讲述了很多东西,其实都是为讲述volatile关键字作铺垫,那么接下来我们就进入主题。

1.volatile关键字的两层语义

  一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:


  1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  2)禁止进行指令重排序。


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

  1. //线程1
  2. boolean stop = false;
  3. while(!stop){
  4. doSomething();
  5. }
  6. //线程2
  7. stop = true;

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

  下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

  那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

  但是用volatile修饰之后就变得不一样了:


  第一:使用volatile关键字会强制将修改的值立即写入主存;

  第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

  第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。


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

  那么线程1读取到的就是最新的正确的值。


那么禁止指令重排序是怎么做到的呢?
在一个变量被volatile修饰后,JVM会为我们做两件事:


1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。


我们看一看上面指令重排序所用到的例子:

  1. boolean contextReady = false;
  2. 在线程A中执行:
  3. context = loadContext();
  4. contextReady = true;

我们给contextReady 增加volatile修饰符,会带来什么效果呢?

  1. volatile boolean contextReady = false;
  2. 在线程A中执行:
  3. context = loadContext();
  4. StoreStore 屏障
  5. contextReady = true;
  6. StoreLoad 屏障

由于加入了StoreStore屏障,屏障上方的普通写入语句 context = loadContext() 和屏障下方的volatile写入语句 contextReady = true 无法交换顺序,从而成功阻止了指令重排序。
image_1cjctk4hr1fdphook41drba10m.png-65.4kB

2.volatile不保证原子性

下面看一个例子:

  1. public class VolatileTest {
  2. public static volatile int count = 0;
  3. public static void increase(){
  4. count++;
  5. }
  6. private static final int THREAD_COUNT = 20;
  7. public static void main(String[] args) {
  8. Thread[] threads = new Thread[THREAD_COUNT];
  9. for (int i = 0; i < THREAD_COUNT; i++) {
  10. threads[i] = new Thread(new Runnable() {
  11. @Override
  12. public void run() {
  13. for (int j = 0; j < 10000; j++) {
  14. increase();
  15. }
  16. }
  17. });
  18. threads[i].start();
  19. }
  20. while(Thread.activeCount() > 1){ //保证前面的线程都执行完
  21. System.out.println(Thread.activeCount());
  22. Thread.yield();
  23. }
  24. System.out.println(count);
  25. }
  26. }

这段代码是什么意思呢?很简单,开启20个线程,每个线程当中让静态变量count自增10000次。执行之后会发现,最终count的结果值未必是200000,有可能小于200000。

使用volatile修饰的变量,为什么并发自增的时候会出现这样的问题呢?这是因为count++这一行代码本身并不是原子性操作,在字节码层面可以拆分成如下指令:

  1. getstatic //读取静态变量(count)
  2. iconst_1 //定义常量1
  3. iadd //count增加1
  4. putstatic //把count结果同步到主内存

虽然每一次执行 getstatic 的时候,获取到的都是主内存的最新变量值,但是进行iadd的时候,由于并不是原子性操作,其他线程在这过程中很可能让count自增了很多次。这样一来本线程所计算更新的是一个陈旧的count值,自然无法做到线程安全:
image_1cjcqvok51qgouvf1moo76l91r9.png-302.7kB

  采用synchronized:

  1. public int count = 0;
  2. public synchronized void increase() {
  3. count++;
  4. }

  采用Lock:

  1. public int count = 0;
  2. Lock lock = new ReentrantLock();
  3. public void increase() {
  4. lock.lock();
  5. try {
  6. count++;
  7. } finally{
  8. lock.unlock();
  9. }
  10. }

  采用AtomicInteger:

  1. public AtomicInteger count = new AtomicInteger();
  2. public void increase() {
  3. count.getAndIncrement();
  4. }

3.volatile能在一定程度上保证有序性

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

  volatile关键字禁止指令重排序有两层意思:

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

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


使用volatile关键字的场景 

 synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件(摘取自《深入理解Java虚拟机》):

  1)运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

  2)变量不需要与其他的状态变量共同参与不变约束。

上面两个说的不太好理解,通俗来讲就是以下两点:

  1)对变量的写操作不依赖于当前值

  2)该变量没有包含在具有其他变量的不变式中

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

  事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

  下面列举几个Java中使用volatile的几个场景。
  

状态标记量

  1.  volatile boolean flag = false;
  2. while(!flag){
  3. doSomething();
  4. }
  5. public void setFlag() {
  6. flag = true;
  7. }
  1. volatile boolean inited = false;
  2. //线程1:
  3. context = loadContext();
  4. inited = true;
  5. //线程2:
  6. while(!inited ){
  7. sleep()
  8. }
  9. doSomethingwithconfig(context)

单例模式

  1. //单例模式
  2. private static volatile Singleton instance = null; // 单例对象
  3. private Singleton(){} // 私有构造函数
  4. //静态工厂方法
  5. public static Singleton getInstance(){
  6. if (instance == null) { //双重检测机制
  7. synchronized (Singleton.class) { //同步锁
  8. if (instance == null) { //双重检测机制
  9. instance = new Singleton();
  10. }
  11. }
  12. }
  13. return instance;
  14. }

这里给出先关的单例模式链接
单例模式链接


总结

并发编程
原子性:要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
可序性:程序执行的顺序按照代码的先后顺序执行。

指令重排序

JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。

volatile

volatile特性之一:

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

volatile特性之二:

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

volatile,能保证可见性,不能保证原子性,在一定程度上能保证有序性。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注