简析 Volatile 关键字

Volatile 简介

volatile作为java关键字之一,其主要作用为在并发场景下,当某个线程更新了使用volatile修饰的变量后,会立即将修改后的值写入主存,其他线程读取的时候可以保证读到的值是最新的,而不是缓存。非volatile修饰的变量在线程并发的情况下不具备这种特性。

Volatile 特性

1、可见性
这里的可见性是指,当某个线程修改了这个变量的值后,其他线程立即可见。需要注意的一点是,java运算并非原子操作,所以无法保证原子性。

举个经典例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Main {
private static volatile int count = 0;
private static final int times = 10000;
private static void add() {
for (int i = 0; i < times; ++i) {
count++;
}
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; ++i) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
add();
System.out.println(count);
}
});
}
for (Thread r : threads) {
r.start();
}
}
}

最后输出:

1
2
3
4
5
6
7
8
9
10
18522
21471
21644
16865
20807
20421
35868
38790
40240
42493

可以发现,最后一个线程执行结束的时候值并非预想的10000。原因是因为 count++ 表达式是非原子操作,运行时会做拆解:

  1. 获取count的值,复制到寄存器
  2. 把前面获取到的count的值+1然后写到内存中

把上面的例子做个改造,也能印证问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Main {
private static AtomicInteger count = new AtomicInteger(0);
private static final int times = 10000;
private static void add() {
for (int i = 0; i < times; ++i) {
count.addAndGet(1);
}
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; ++i) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
add();
System.out.println(count);
}
});
}
for (Thread r : threads) {
r.start();
}
}
}

输出:

1
2
3
4
5
6
7
8
9
10
45060
50000
47612
47814
45960
67128
79234
87439
90350
100000

无论执行多少次,最后一个线程结束时,count的值总是100000。

2、有序性

有序性是指禁止指令重排优化,即程序执行的顺序按照代码的先后顺序执行。指令重排序是编译器和处理器为了高效对程序进行优化的手段,在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。重排序在多线程环境下出现的概率还是挺高的,在关键字上有volatile和synchronized会禁用重排序。

在JVM层,volatile 是采用“内存屏障”来实现的,那么指令重排序时不能把后面的指令重排序到内存屏障之前的位置,所以在执行到内存屏障这句指令时,在它前面的操作已经全部完成。同时,内存屏障会强制将缓存的修改操作立即写入主存,以确保其他线程立即可见。

Volatile 适用场景

  • 适用于对变量的写操作不依赖于当前值,对变量的读取操作不依赖于非volatile变量。
  • 适用于读多写少的场景。

应用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}
}

为什么要使用volatile 修饰instance??

主要在于 instance = new Singleton() 这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

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

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

~沙漠尽头必是绿洲~