JavaGuide之volatile

背景

Java中并发程序正确地执行,必须要保证原子性、可见性以及有序性,只要有一个没有被保证,就有可能会导致程序运行不正确。

  • 原子性:一个操作或者多个操作要么全部执行完成且执行过程中不被中断,要么就不执行。
  • 可见性:当多个线程同时访问同一个变量时,一个线程修改了这个变量的值,其它线程能够立即看到修改的值。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。

volatile其实就是解决上面“可见性”的关键字,它可以看作是轻量级的Synchronized,它保证了共享变量的可见性。为什么会存在可见性这种问题呢?下面看下Java的内存模型。

Java内存模型(JMM)

Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量(这些变量都是从主内存中拷贝来的)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行(提高效率),不同线程之间也无法直接访问对方工作内存中的变量,线程间变量传递均需要通过主内存来完成。

为什么会定义这种内存模型呢?(因为CPU的计算能力很强大,相对内存的速度比较慢,为了跟上CPU的处理速度所以优化了内存处理的速度)

基于上面描述的JMM定义的规则,我们不难想到有可能存在线程A获取到的值是线程B更新主内存之前的值。这个问题其实就是线程之间的可见性问题,一个线程修改的状态对另外一个线程是可见的。也就是线程修改的结果,另一个线程马上就能看到。这个时候如果用volatile修饰变量,就会具有可见性,任何线程对该变量的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存中重新读取最新数据,而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主内存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值。

注意:不是说被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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class VolatileDemo implements Runnable {

private static boolean flag = true;
//private static volatile flag = true;

@Override
public void run() {
while (flag) {

}
System.out.println(Thread.currentThread().getName() + "执行完毕");
//获取当前正在运行的线程
ThreadGroup currentGroup =
Thread.currentThread().getThreadGroup();

int noThreads = currentGroup.activeCount();
Thread[] lstThreads = new Thread[noThreads];

currentGroup.enumerate(lstThreads);
for (int i = 0; i < noThreads; i++)
System.out.println("线程号:" + i + " = " + lstThreads[i].getName());

}

public static void main(String[] args) throws InterruptedException {

VolatileDemo aVolatile = new VolatileDemo();

new Thread(aVolatile, "thread A").start();

System.out.println("main 线程正在运行");
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
String value = sc.next();
if (value.equals("1")) {
new Thread(new Runnable() {
@Override
public void run() {
aVolatile.stopThread();
}
}, "thread B").start();
break;
}
}
System.out.println("主线程退出了!");


//获取当前正在运行的线程
ThreadGroup currentGroup =
Thread.currentThread().getThreadGroup();

int noThreads = currentGroup.activeCount();
Thread[] lstThreads = new Thread[noThreads];

currentGroup.enumerate(lstThreads);
for (int i = 0; i < noThreads; i++)
System.out.println("线程号:" + i + " = " + lstThreads[i].getName());

System.out.println("----------------------------------------");
}

private void stopThread() {
flag = false;
}

}

这段代码中,flag是一个在几个线程中需要切换的变量,当没有被volatile修饰时,我们来看下执行结果是什么:


梳理一下运行过程:

  • 主线程开始运行,并开启了Thread A(执行死循环)
  • 当输入“1”时,主线程中开启了新线程Thread B,线程B只做了一件事情,将flag设置为false
  • Thread B执行完run()方法就会结速并被销毁
  • 输出当前还存活的线程(主线程,线程A,还有一个是监控线程,B线程由于执行完被销毁了)

奇怪,为什么Thread B明明修改了flag的值,为啥Thread A没有退出循环呢。其实,这里就是volatile的使用场景了。代码还是上面代码,我们先来看看flag被volatile修饰后的执行结果:


梳理一下运行过程:

  • 主线程开始运行,并开启了Thread A(执行死循环)
  • 当输入“1”时,主线程中开启了新线程Thread B,线程B只做了一件事情,将flag设置为false
  • 输出当前还存活的线程(主线程,线程A,还有一个是监控线程,线程B),其中线程B多运行几次有时候有,有时候没有,这个是由于打印的时候线程B的run方法有可能执行完,也可能没有执行完。
  • 由于flag被设置为false,flag又是被volatile修饰,所以会强制A线程去刷新工作线程,从主内存中重新读区flag的值,然后退出死循环,打印当前存活线程。

总结:

可以看到上述代码中flag是否被volatile修饰导致结果完全不一样。没有被volatile修饰时,虽然线程B修改了flag=false值,但是线程A读取的还是缓存在工作线程中的flag=true的值,有可能等线程B去更新主线程的flag值时,线程A或者其它线程再次去主内存中拷贝flag这个值时,结果就时对的,但是这种结果是不确定的。

而被volatile修饰时,线程B修改了flag的值,对线程A而言是直接可见的,线程A会清空缓存,然后去主内存拷贝最新的flag值,触发正确的逻辑。

volatile保证有序性

针对原子操作其实还有Atomic相关类,后面再说。其实volatile不光是解决可见性,其实还有禁止重排序问题。
volatile关键字禁止指令重排序有两层意思:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
1
2
3
4
5
6
7
//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前面,也不会放到语句4和语句5后面。但是1和2,4和5的顺序是不保证的。并且,volatile关键字能保证,执行到3时,1和2一定是执行完毕的,且1和2的执行结果对3,4,5是可见的。