JUC之Java并发基础篇——搞懂volatile

作为 Java 的关键字,volatile 虽然没有 synchronized 出现的频率高,但是在 Java 源码中还是会经常出现的,尤其是 JUC 当中,比如 AbstractQueuedSynchronizer 。那么,volatile 到底意味着什么,作用是什么?简而言之,有两点,其一是保证了内存可见性,其二是禁止指令重排序。

内存可见性

缓存问题

Java内存模型规定了所有的变量都存储在主内存中,同时每个线程还有自己的工作内存。线程的工作内存中保存了该线程使用到的从主内存拷贝的副本变量,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

这样就导致了一个问题:线程1修改了共享变量的值,没来得急写入主存,或者写入主存了线程2并未从主存刷新数据,这样线程2拿到的数据就是过期数据,即内存可见性问题

线程、主内存和工作内存的交互关系如下图所示:

线程缓存

举例

虽说对于可见性问题说的头头是道,但是全是理论。那么怎么证明这个现象的存在呢?看下面的例子。

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
public class DemoRunnable implements Runnable {

private boolean flag = false;
public DemoRunnable() {
}
public void setFlag(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
int i = 0;
System.out.println("====== start ===========");
while (!flag) {
i++;
}
System.out.println("====== end ===== , i == " + i);
}

public static void main(String[] args) throws InterruptedException {
DemoRunnable demo = new DemoRunnable();
new Thread(demo).start();
System.out.println("sleep to let demo thread run first");
TimeUnit.MILLISECONDS.sleep(10);
demo.setFlag(true);
}
}

对于上面的代码,如果主线程修改了变量的值,demo 线程可以立刻发现的话,程序会正常结束,产生如下的输出:

====== start ===========

sleep done let demo thread run first

====== end ===== , i == xxxxxxx

实际上并非如此,程序进入了死循环,无法退出。这就说明了一个线程修改了共享变量,另一个线程可能不会立即看到。极端情况下,数据可能一直都不会被看到。

禁止指令重排序

指令重排序,即在执行程序时,为了提高性能,编译器和处理器会对指令做一些优化。而 volatile 则可以禁止某些指令重排序。

对于重排序,举个例子,比如做饭。有一种流程是,洗菜 -> 炒菜 -> 淘米 -> 煮饭。但是为了提高效率,我们可以这么做:淘米 -> 煮饭 -> 趁煮饭的时间洗菜炒菜。这样一来,就可以省了不少时间。在这里面,有依赖关系的不能重排序,比如煮饭依赖于已经淘米了。这些步骤就是一些指令,我们自己就是处理器。

更多关于指令重排序与 happens-before 请参考之前的博客: JUC之Java并发基础篇——指令重排与happens-before

由于 volatile 可以禁止指令重排序,所以,对于文中的例子,如果不想出现结果 0, 0 ,只需要将变量 int a, b 使用 volatile 修饰即可。

原子性

volatile 是否能保证原子性,一般有两种说法。一个是说能保证原子性,只要修饰的变量在赋值时和本身无关。一种说法是不能保证原子性。本文认为 volatile 并不能保证被修饰变量的赋值操作原子性。可看以下代码:

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
public class VolatileDemo {
private volatile int count;
public static void main(String[] args) throws InterruptedException {
int num = 1000;
int count = 0;
VolatileDemo demo = new VolatileDemo();
do {
count++;
demo.count = 0;
Thread t1 = new Thread(() -> {
for (int i = 0; i < num; i++) {
demo.count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < num; i++) {
demo.count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
} while (demo.count == 2 * num);
System.out.println("第" + count + "次,跳出循环,demo.count = " + demo.count);
}
}

如果可以保证原子性的话,可以预见,上面的程序会是一个死循环,无法跳出。但是实际结果呢,出现了以下情况:

第22次,跳出循环,demo.count = 1622

count++ 实际上等同于 count = count + 1 ,这不是一个步骤,是分三步的:取原值、计算、赋值。volatile 保证了内存可见性,但是是保证了在取变量值的时候,取的是最新的值。在计算及赋值时,对应的值是否还是最新的,这点是不保证的。

volatile 的解决问题之道

内存屏障

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

参考: 维基百科-内存屏障

下载列出了内存屏障的四种类型。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障,是目前大多数处理器所支持的,但是相对其他屏障,该屏障的开销相对昂贵。

volatile 内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中,并通知其它线程,使其它线程的变量副本无效。

当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,从主内存中读取共享变量。

volatile 实现

在重排序一文中,我们有提到,重排序分为编译器重排序和处理器重排序。

对于编译器

为了实现 volatile 的内存语义,JMM 会限制 volatile 的重排序,如下表。

能否重排序 第二个操作
第一个操作 普通读/写 volatile 读 volatile 写
普通读/写 no
volatile 读 no no no
volatile 写 no no
  • 当第一个操作是 volatile 读时,不论第二个操作是什么,都不能重排序
  • 当第二个操作是 volatile 写时,不论第一个操作是什么,都不能重排序
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不允许重排序

对于处理器

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障。这样,处理器在执行指令时就不会进行优化处理。

  • 在每个 volatile 写操作前面插入一个 StoreStore 屏障。禁止了 volatile 写与前一个有可能的写重排序,同时保证了内存可见性。
  • 在每个 volatile 写操作后面插入一个 StoreLoad 屏障。禁止了 volatile 写与后一个有可能的读重排序,同时保证了内存可见性。
  • 在每个 volatile 读操作后面插入一个 LoadLoad 屏障。禁止了 volatile 读与后续有可能的读重排序。
  • 在每个 volatile 读操作后面插入一个 LoadStore 屏障。禁止了 volatile 读与后续有可能的写重排序。

注:参考 《Java并发编程的艺术》(方腾飞)

volatile 应用

状态标记

由于 volatile 保证了内存可见性,所以可用于修饰共享变量。但是,由于其不具备原子性,为了保证多线程情况下不出问题,最好来修饰赋值可以一步完成的变量。比如,状态标记。

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
public class DemoRunnable implements Runnable {

private volatile boolean flag = false;
public DemoRunnable() {
}
public void setFlag(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
int i = 0;
System.out.println("====== start ===========");
while (!flag) {
i++;
}
System.out.println("====== end ===== , i == " + i);
}

public static void main(String[] args) throws InterruptedException {
DemoRunnable demo = new DemoRunnable();
new Thread(demo).start();
System.out.println("sleep to let demo thread run first");
TimeUnit.MILLISECONDS.sleep(10);
demo.setFlag(true);
}
}

单例模式双重锁

单例模式的懒汉模式,如果不小心的话是会出错的。以下代码是一份不会出问题的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SingletonDemo {
private static volatile SingletonDemo INSTANCE;//标记3
private SingletonDemo() {}//标记1
public static SingletonDemo getINSTANCE() {
if (INSTANCE == null) {
synchronized (SingletonDemo.class) {
if (INSTANCE == null) {//标记2
INSTANCE = new SingletonDemo();
}
}
}
return INSTANCE;
}
}

注1:构造方法必须私有化,避免外部再 new 出新对象。

注2:著名的 double-check ,如果不进行二次判断的话,很可能多个线程同时得出第一个 INSTANCE == NULL 的结论,然后依次进入同步代码块,这样就会导致 INSTANCE 会被重新 new。

注3:INSTANCE 必须使用 volatile 进行修饰,因为 new 一个对象不是一步完成的,指令重排可能会使某线程拿到的是半个对象。

new 一个对象,可能出现下面两种步骤:

顺序1 顺序2
1. 申请内存 1. 申请内存
2. 初始化属性 2. 指向对象
3. 指向对象 3. 初始化属性

也就是说,指令重排,可能会出现以下这种情况

线程1(顺序2初始化) 线程2
1. 执行 INSTANCE = new SingletonDemo()
2. 申请内存
3. 指向对象
4. 第一个 INSTANCE == null 判断
5. INSTANCE != null
6. 拿到一个假对象,所以是半个
7. 完成对象属性初始化

可以看出,在指令重排时,是有可能出现并发问题的。所以,INSTANCE 要用 volatile 修饰,在禁止重排序后,使用顺序1就不会出现这个问题。

总结

  • volatile 的作用主要有两点,一个是保证了内存可见性,一个是禁止指令重排序
  • volatile 并不能保证原子性,使用 volatile 修改的变量,在赋值时一定不要和本身原来的值相关
  • volatile 的内存语义是通过插入内存屏障来实现的
  • volatile 可用于修饰状态标记变量
  • 单例模式的懒汉模式,变量要用 volatile 来修饰,这样来禁止指令重排序