volatile关键字详解

前言

提到JAVA的并发编程,就不得不提volatile关键字,不管是在面试还是实际开发中,volatile关键字的使用都是一个应该掌握的技能。它之所以重要,是因为它和JAVA并发编程中会遇到三种重要问题中的两种密切相关。

在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。而volatile关键字之所以神奇,在于它可以解决可见性问题有序性问题

1 可见性

1.1 可见性问题

如果我们学过java内存模型的话,对下面这张图想必不陌生:

每一个线程都有一份自己的本地内存,所有线程共用一份主内存。如果一个线程A对主内存中的某个数据V进行了修改,而此时另外一个线程B不知道该数据V已经发生了修改,它会从本地内存中去读取这个数据V,显然数据V已经过时了。这就是说,本次线程A修改后的数据V,对线程B来说,此时是不可见的

1.2 volatile保证内存可见性

可见性问题在并发场景中是十分常见,那么volatile关键字如何保证内存可见性呢?

volatile关键字的作用很简单,就是一个线程在对主内存的某一份数据进行更改时,改完之后会立刻刷新到主内存。并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。这样一来就保证了可见性。

其底层原理如下:

JMM把主内存和线程的本地内存之间的交互分为8个原子操作,他们分别是:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

JMM要求,如果要把一个变量从主内存复制到工作内存,那么应该顺序的执行read和load操作。反之,应该顺序的执行store和write操作。JMM只要求上述两个指令是顺序执行,不要求必须要连续执行,也就说是,可以在read和load操作之间插入其他指令。这基本构成了JMM的主内存和工作内存之间的交互逻辑。

而volatile如何实现内存可见性呢?其实很简单,下面我截取一段被volatile关键字修饰的变量的赋值逻辑的汇编指令:

1
2
3
4
5
···
0x0000000002931351: lock add dword ptr [rsp],0h ;
*putstatic instance;
- org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
···

你不用去关心这段字节码什么意思,你知道知道它的语意是对一个volatile修饰的变量进行赋值操作。和没有volatile修饰的变量的赋值操作字节码相比,volatile修饰的变量的赋值操作仅仅是多了一个lock指令前缀

lock指令前缀有什么作用呢?lock后的写操作会强制回写已修改的数据到主内存,相当于连续执行了store和write操作

看到这里有同学不禁要问了,lock操作只是强制回写数据到主内存,但没有强制其他线程去刷新他们工作内存中的值啊。

这要结合缓存一致性协议——MESI协议来看了,不懂的同学可回顾本博客另外一篇文章《JAVA内存模型》的MESI协议一章。

假如线程A操作赋值逻辑,使用lock操作强制回写数据V到主内存,根据MESI协议,线程A会将主内存中该数据V的状态改为M,其他线程一直在监听主内存,发现该数据V的状态为M后,会将他们的工作内存中的数据V的状态改为I——即失效状态,最终迫使其他线程在使用V之前,必须去主内存读取新值。

这就是volatile保证内存可见性的原理,其实只是一个lock指令前缀而已。

2 有序性

2.1 有序性问题

并发场景中,有序性问题,许多是由JVM的指令重排优化引起的。

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

实质上指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理

我们来看一个因为指令重排而引起的并发问题,懒加载的双重检查模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}

这个模型我们并不陌生,在 《Effecitve Java》一书中作者层提到了双重检查模式,并指出这种模式在Java中通常并不适用。并不适用的原因,就是因为指令重排。

上面这段代码,初看没问题,但是在并发模型下,可能会出错。那是因为instance = new Singleton();并非一个原子操作,编译器会将其编译为三行字节码,也就是三个步骤:

  1. memory=allocate();// 分配内存

  2. ctorInstanc(memory) //初始化对象

  3. instance=memory //设置instance指向刚分配的地址

在编译器运行时,因为步骤三和步骤二无依赖关系,故而JVM会对其进行指令重排优化,从1-2-3顺序优化为1-3-2顺序。

可以看到指令重排之后,操作3排在了操作2之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——也就是说引用instance指向了一个”被部分初始化的对象”。

此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if (instance == null) 条件判为false,方法返回instance引用,那么用户则得到了没有完成初始化的“半个”单例。从而发生问题。

2.2 volatile防止指令重排

解决这个该问题,只需要将instance声明为volatile变量:private static volatile Singleton instance;

volatile关键字能够禁止JVM对修饰的变量的读写做指令重排,从而保证了instance = new Singleton();在底层能够按照顺序执行。

其底层原理其实和volatile保证可见性的原理是一样的,也就是在汇编指令层面加入一个lock前缀

这里的lock前缀指令相当于一个内存屏障(但实际上不是内存屏障),它保证了:当程序执行到volatile变量的读或写时,在lock指令前面的操作必须全部执行完毕,且结果必须已经对后面的操作可见(也就是上面说的lock会强制刷新新值到主存);

指令重排的原则是无依赖关系间的优化排序,而volatile字段带来的lock前缀,则会使instance = new Singleton();的三个字节码相当于变成这样(lock是汇编指令,所以说只是相当于):

  1. memory=allocate();// 分配内存

  2. ctorInstanc(memory) //初始化对象

  3. lock instance=memory //设置instance指向刚分配的地址

这使得原本没有依赖关系的2和3操作,因为lock前缀的语意强制,被强加上了一个2必须在3之前完成的“依赖”,形成了虽然不是内存屏障,但却达到了内存屏障功能的效果,给人一种指令重排无法越过volatile读写操作两边的观感。

3 lock指令前缀作用总结

Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

  • 确保指令重排序时不会把lock指令后面的指令排到lock之前的位置,也不会把前面的指令排到lock的后面;即在执行到lock这句指令时,在它前面的操作已经全部完成;

  • 强制将对缓存的修改操作立即写入主存,同时利用缓存一致性机制,让其他工作线程从主存重新读值;

4 volatile不能保证原子性

从上文我们知道volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性吗?

下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
//有10个线程分别进行了1000次操作inc的自增操作
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但可见性只能保证每次读取的是最新的值,volatile没办法保证对变量的操作的原子性。

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  1. 假如某个时刻变量inc的值为10,
  2. 线程1对变量进行自增操作,线程1先读取了变量inc的原始值10。
  3. 线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,还没有没有对变量进行修改操作,线程2读到的10也是10;
  4. 这时候线程1对inc进行自增,并且通过可见性,将结果11写回主存,并将线程2的工作内存中inc的状态改为无效。
  5. 但是此时线程2对inc的读操作已经结束了,已经在进行+1操作了,inc就算在线程2中被置为无效,线程2也过了能感知到的时间点了,导致线程2也是对10+1,得到11再写回主存。

以此类推,导致最后的结果必定小于10000;这说明了volatile无法保证原子性,它本身也不适用类似场景。

volatile比较适合用来修饰一个会被单线程更改,但又需要立刻让其他线程感知到值变化的值,比如代码逻辑里面的业务开关等

0%