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
27
28
public class VisableTest {
private int weight = 200;

public static void main(String[] args) {
new VisableTest().test();
}

public void test() {
Thread A = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
weight = 1;
});
Thread B = new Thread(() -> {
while (true) {
if (weight != 200) {
System.out.println("visable");
break;
}
}
});
A.start();
B.start()
}
}

结果输出什么呢?答案是什么也不输出。

这涉及到内存可见性的问题。每个线程启动的时候回分配一个”工作空间”,”工作空间”包含一些数据的副本,包括类中的变量等,线程在修改这些数据的时候,修改的是自己”工作空间”中的数据,这些数据什么时候被写回主存取决于JVM,例如上面的例子,在我的本地测试环境,线程A修改的weight = 1,永远不会被写入主存中,所以线程B什么也不会打印。

volatile关键字就是干这个活的:

1
private volatile int weight = 200;

在weight前面加上volatile关键字后线程A修改weight的时候,会强制写回主存中,B读取weight的时候也会强制从主存中读取,这样就保证了A修改的weight和B读取的weight是用一份值。

二、 volatile隐患

很多专家不建议使用volatile关键字。

初期synchronized性能较差,对于上面代码中的这种情形下,使用synchronized加锁保证可见性会带来很大的性能问题,而且非常大材小用,并且上述代码中没有原子性问题(原子性问题在synchronized和volatile两个关键字中很容易造成疑惑,后面会解释),于是针对这种情况的优化目的,加入了volatile关键字,使得被volatile关键字修饰的变量可以保证可见性。

在现在的JDK版本下(1.6版本synchronized关键字被大幅优化后,以及JUC包的推出),不再推荐使用volatile关键字,因为volatile关键字确实在某种程度上给人造成疑惑。

我认为造成疑惑的罪魁祸首是volatile只提供可见性但是不提供原子性,例如weight++操作会在并发场景下失败,即使weight使用volatile修饰,这是一个经典的例子,weight++实际上是三个操作:取出weight、weight递增、写回weight。这三个操作如果不能够保证原子性,某些线程的操作可能会被覆盖掉,因为某些线程可能读出了正处于修改过程中的weight(发生了脏读,参考事务隔离级别、CAP理论、BASE理论)。锁具有两个主要特性:原子性和可见性,而volatile看起来像半个锁,但是因为volatile足够轻量,性能足够好,这就诱导大家面对并发问题时优先考虑volatile,而同时可见性和原子性是两个复杂的概念,在一个复杂的系统中,即使是一个老手也需要一些时间去分辨某些变量是否需要可见性和原子性,这就极其容易造成volatile关键字的误用和滥用。

推荐的做法(在成为一个真正的高手之前):

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

也就是说,对于存在竞争的变量,仅当变量的状态与系统中任何变量(包括自己)无关时,可以使用volatile,并只做最简单的赋值和读取。简单说,只在系统标志位中使用volatile变量。

在判断原子性是否需要时,要仔细核对:即使weight=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
public class VisableTest {
private AtomicInteger weight = new AtomicInteger(200);

public static void main(String[] args) {
new VisableTest().test();
}

public void test() {
Thread A = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
weight.set(1);
});
Thread B = new Thread(() -> {
while (true) {
if (weight.get() != 200) {
System.out.println("visable");
break;
}
}
});
A.start();
B.start()
}
}

三 、 结论

在使用volatile变量去达到自己想要达到的目的时,必须100%确认你已经明白它会怎样运作,否则还是使用同步锁或JUC包下的原子类代替。

另外,通过上面的分析,其实volatile的适用场景非常的有限,如果在一个系统中发现大量的变量都加上了volatile关键字,那么你要小心了,因为要么volatile被滥用了,要么作者的某些目的无法通过众多的volatile去达到。

如有错误,欢迎指正~

我不喝咖啡,但是我相信知识有价。