Java volatile

volatile 关键字保证了可见性,但是不保证原子性,采用内存屏障来实现的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

synchronized 和 Lock 既能够保证原子性也能够保证可见性。

CPU 内存模型

CPU 内存模型

  1. 程序执行时,把需要用到的数据,从主内存拷贝一份到高速缓存。
  2. CPU 处理器计算时,从它的高速缓存中读取,把计算完的数据写入高速缓存。
  3. 当程序运算结束,把高速缓存的数据刷新会主内存。

Java 内存模型(JMM)

Java 内存模型

并发编程的 3 个特性

原子性

原子性是指操作是不可中断的,要么执行完成要么不执行,基本数据类型的访问和读写都是具有原子性,当然(long 和 double 的非原子性协定除外)。

1
2
3
4
x = 10;        //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4

语句 1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句时会直接将数值 10 写入到工作内存中。
语句 2 实际上包含 2 个操作,它先要去读取 x 的值,再将 x 的值写入工作内存,虽然读取 x 的值和将 x 的值写入工作内存的操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,语句 3 和语句 4 包括 3 个操作:读取 x 的值,进行加 1 操作,写入新的值。

可见性

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

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

1
2
3
4
5
int i = 0;
boolean flag = false;

i = 1; //语句1
flag = true; //语句2

以上代码可能会发生指令重排序(Instruction Reorder)。

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

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

单线程情况下不可能出现 “语句 2 语句 1 语句 4 语句 3”。
因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令 Instruction 2 必须用到 Instruction 1 的结果,那么处理器会保证 Instruction 1 会在 Instruction 2 之前执行。

1
2
3
4
5
6
7
8
9
//线程1:
context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

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

指令重排序不会影响单线程的执行,但是会影响到线程并发执行的正确性。

volatile 禁止指令重排序的含义:

  1. 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对 volatile 变量的读操作或者写操作的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
1
2
3
4
5
6
7
8
// x、y为非volatile变量
// flag为volatile变量

x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5

由于 flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,不会将语句 3 放到语句 1、语句 2 前面,也不会讲语句 3 放到语句 4、语句 5 后面。但是要注意语句 1 和语句 2 的顺序、语句 4 和语句 5 的顺序是不作任何保证的。
并且 volatile 关键字能保证执行到语句 3 时,语句 1 和语句 2 必定是执行完毕了的,且语句 1 和语句 2 的执行结果对语句 3、语句 4、语句 5 是可见的。

实现原理

1
public static volatile int counter = 1;

编译后的字节码如下:

1
2
3
public static volatile int counter;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE

内存屏障也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。
写操作时 JVM 会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。 这一步确保了如果有其他线程对声明了 volatile 变量进行修改,则立即更新主内存中数据。

但这时候其他处理器的缓存还是旧的,所以在多处理器环境下为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查自己的缓存是否过期, 当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了 volatile 变量都是从主内存中获取最新的。

Lock 指令实际上相当于一个内存屏障,它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面,即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

内存屏障保证前面的指令先执行,所以这就保证禁止了指令重排,同时保证缓存写入主内存和其他处理器缓存失效,这也就保证了可见性。

应用场景

volatile 关键字在某些情况下性能要优于 synchronized,但是无法替代 synchronized。

1. 状态标记:

1
2
3
4
5
6
7
8
9
10
volatile boolean flag = false;
//线程1
while(!flag){
doSomething();
}

//线程2
public void setFlag() {
flag = true;
}

2. 单例模式中的 DCL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private volatile static Singleton instance = null;

private Singleton() {
}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

instance = new Singleton() 这句,这并非是一个原子操作,分三个步骤:

  1. 给 instance 分配内存;
  2. 调用 Singleton 的构造函数来初始化成员变量;
  3. 将 instance 对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕但是 2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

参考

[1] volatile 底层原理详解