0%

JMM (二):浅谈 volatile 关键字

很多编程语言中都有 volatile 关键字,但是不同语言下其语义并不完全相同。本文将就其在 Java 中的语义作具体说明、解释

Visibility 可见性

很多人对 volatile 关键字的基本理解是每次读取该关键字所修饰的变量时,可以保证能够获取到其在内存中最新的值。首先说明这个理解并没有错,其说明了 volatile 具有可见性。下面我们通过一个例子来验证 volatile 的可见性。下面的代码中,我们定义了一个普通的共享变量 isLoop 用于控制线程 A 的 while 循环。我们期望当其被线程 B 修改为 false 时,线程 A 跳出 while 循环

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
30
31
public class VolatileTest {

// 共享变量,判定是否继续循环
public static boolean isLoop = true;

/**
* volatile的可见性测试
* @throws InterruptedException
*/
public static void testVisibility() throws InterruptedException {

Thread threadA = new Thread( ()->{
System.out.println("Thread A Start ...");
while (isLoop) {

}
System.out.println("Thread A End");
} );

Thread threadB = new Thread( ()->{
System.out.println("Thread B Start ...");
isLoop = false;
System.out.println("Update [isLoop] to " + isLoop);
System.out.println("Thread B End");
} );

threadA.start();
Thread.sleep(500); // 确保线程A已经进入while循环
threadB.start();
}
}

现在,我们来实际验证下这个代码的效果。可以看到线程 B 虽然将 isLoop 修改为 false 了,但是从下图黄框我们可以发现,线程 A 依然处于 While 循环中没有结束。显然这里与我们的预期期望并不相符。其实原因也很简单,我们知道 JMM 要求,线程不可以直接操作主内存中的共享变量,其需要拷贝共享变量的副本至线程的工作内存中才能使用。在这里的示例代码中,虽然线程 B 将其工作内存中的共享变量 isLoop 副本的值修改为 false 了,但是并没有立即同步到主内存中。换句话说主内存中共享变量 isLoop 的值依然是 true

如果要求测试结果符合我们的预期,也很简单,只需使用 volatile 关键字修饰 isLoop 变量

1
2
// 共享变量,判定是否继续循环
public static volatile boolean isLoop = true;

从修改后程序的测试结果,我们可以得出一个结论,当线程 B 将共享变量修改为 false 后,线程 A 能够立刻感知到其他线程对该变量所做的修改。即 volatile 具备可见性

前面我们是通过程序的现象了解了 volatile 关键字的可见性,现在我们来具体看看 Java 底层是如何实现的,这里我们借助 HSDIS 进行反汇编并通过 JITWatch 工具进行可视化分析

可以看到线程 B 在修改了共享变量后,在汇编指令中插入了一条 lock 前缀指令。根据 Intel 手册对 lock 指令的解释,其作用如下:

  1. 会将当前处理器 Cache 中的数据立即回写到 Ram 中。在此过程中,CPU 会适当地选取总线加锁或缓存锁等形式来保证该过程不会被打断
  2. 该数据的回写操作同时会引起其他处理器 Cache 中相应数据的缓存无效 (MESI 协议)
  3. 强制要求 lock 指令之前的操作都必须在此之前执行完成、lock 指令之后的操作都必须在此之后执行。因此该指令虽然不是内存屏障,但其却具有该语义,相当于是一个内存屏障

现在我们就可以很好的解释 volatile 关键字的可见性了。当线程 B 工作内存中的共享变量 isLoop 副本被修改后会立即刷新回主内存,即主内存中共享变量 isLoop 的值被更新为 false。与此同时对于线程 A 而言,虽然其工作内存中已经有了共享变量 isLoop 的副本,但是由于 volatile 关键字的作用,会使得其每次进行 while 循环判定时,都会从主内存中重新拷贝共享变量 isLoop 的副本,而不会使用工作内存中之前的旧副本 (旧副本数据已经失效,不可用)。所以说 volatile 关键字的所修饰共享变量,一方面可以保证共享变量的更新、修改能够立即被同步回主内存中,另一方面可以保证每次都是从主内存中读取该共享变量的最新值,而不是使用其工作内存中已有的副本数据

Atomicity 原子性

既然 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
28
29
public class VolatileTest {

public static volatile int count = 0;

/**
* volatile 的原子性测试
*/
public static void testAtomicity() throws InterruptedException {

List<Thread> threadList = new LinkedList<>();

for(int i=0; i<20; i++) {
Thread thread = new Thread( ()->{
for(int j=0; j<10000; j++) {
count++;
}
} );
threadList.add(thread);
thread.start();;
}

for( Thread thread : threadList ) {
thread.join();
}

System.out.println("[Main Thread] count: " + count );
}

}

其意图很简单,启动 20 个线程,每个线程分别对共享变量 count 进行 1 万次自增操作。我们期望这 20 个线程完成自增任务后,共享变量的值最终应该是 20 万 (20*10000=200000)。现在让我们来看下实际的测试结果,发现 count 值远小于 20 万。实际上该程序不论测试执行多少次基本都会小于 20 万

咦,之前明明不是说 volatile 具备可见性么?线程每次进行 count++ 自增时,应该都是获取到主内存中共享变量 count 的最新值进行操作的啊,为什么实际测试结果总是比预期小呢?这里我们依然通过反汇编来进行分析、解释

可以看到虽然在 Java 代码中 count++ 只是一行代码,但是实际上转化为字节码、汇编后却是多条指令。当线程 1 先从主内存中拷贝共享变量 count 最新值 (假设此时为 10) 到其工作内存后。正当 CPU 准备执行自增操作时。可能另外一个线程 (记为线程 2) 已经完成了共享变量的自增操作,并将更新后的值 11 写入到主内存中去了。此时线程 1 中 count 值却依然还是 10 (因为线程 1 已经完成了共享变量 count 的加载拷贝操作,故并不会重新从主内存中拷贝共享变量 count 最新值 11),当其执行完自增操作变为 11 后并回写到主内存。可以看到,虽然线程 1、2 分别完成了一次自增操作,但 count 却并没有加 2 只加了 1。其原因就在于 volatile 不具备原子性,当线程 1 从主内存加载完 count 变量值后,本该立即执行的修改、赋值操作却被其他线程所打断,导致接下来修改、赋值操作所使用的数据是旧数据

由于 volatile 不具备原子性,所以我们使用时需要特别小心。一般地,如果 运算结果不依赖变量当前的值确保只有一个线程修改变量 的场景下,才会使用 volatile 关键字

Ordering 有序性

而在 JMM 中,volatile 同样具备有序性。具体地,其是通过插入内存屏障指令实现,其具体规则如下所示

从上表我们可以看出:

  1. 当第一个操作是 volatile 变量读时,无论第二个操作是什么,均不允许重排序。即其保证了 volatile 变量读之后的操作不会被重排序到 volatile 变量读之前
  2. 当第二个操作是 volatile 变量写时,无论第一个操作是什么,均不允许重排序。即其保证了 volatile 变量写之前的操作不会被重排序到 volatile 变量写之后
  3. 当第一个操作是 volatile 变量写、第二个操作是 volatile 变量读时,不允许重排序

在 JDK1.5 之前的 Java 内存模型中,虽然不允许 volatile 变量之间进行重排序,但却允许普通变量与 volatile 变量之间的重排序。所以在 JSR 133 中对 volatile 变量的内存语义进一步增强,即限制了普通变量与 volatile 变量之间是否可以重排序的具体场景。这也是为什么在 JDK 1.5 之前无法通过 DCL (Double-checked locking, 双锁检查锁) 实现一个线程安全的单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Singleton {
private static volatile Singleton instance;

/**
* 基于DCL的单例模式
* @return
*/
public static Singleton getInstance() {
if( instance==null ) { // 第一次check
synchronized (Singleton.class) {
if( instance == null ) { // 第二次check
instance = new Singleton();
}
}
}
return instance;
}

public static void testSingleton() {
Singleton.getInstance();
}

}

在 instance = new Singleton () 过程中,其大致可分为三步:

  1. 分配内存空间
  2. 通过构造器进行对象初始化
  3. 将 volatile 变量 instance 指向相应的内存空间

按理说正常的执行顺序应该是 1->2->3,但是在 JDK 1.5 之前,2 和 3 可能会发生重排序,即可能出现 1->3->2 这样的执行顺序。当线程 A 按此顺序 (1->3->2) 执行完成 3 (此时 instance 不为 null 了) 正准备执行 2 时,恰好此时另外一个线程 B 调用了 Singleton.getInstance () 方法,由于此时 instance 已经不为 null 了,线程 B 就会拿到一个未进行初始化的对象,从而引发了错误,即所谓的安全发布失败。而在 JSR 133 中,Java 内存模型对 volatile 变量与普通变量之间重排序的规则进行了完善,故可以保证在 JDK 1.5 及其之后的版本中,2 和 3 不会发生重排序。即保证了基于 DCL 的单例模式是线程安全的

参考文献

  1. Java 并发编程之美 翟陆续、薛宾田著
  2. 深入理解 Java 虚拟机・第 2 版 周志明著
  3. JSR-133: Java Memory Model and Thread Specification
请我喝杯咖啡捏~

欢迎关注我的微信公众号:青灯抽丝

Powered By Valine
v1.5.2