Java Volatile 关键字
Java 语言中的 volatile 关键字可以被看作是一种程度较轻的 synchronized ;跟 synchronized 相比 volatile 使用起来简单方便,编码较少。但是也仅仅只有 synchronized 的部分功能。通过下文,让我们更加理解其意义。合理使用 volatile 关键字,从而减少不必要的系统开销。
- 锁提供了两种主要特性: 互斥(mutual exclusion)和 可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。
- 可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。
如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。
1. volatile特性
- 保证可见性
- 禁止指令重排
1.1. 可见性
内存可见性:(Memory Visibility)是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了对象状态后,其他线程能够立即看到发生的状态变化。
举个例子:
public class VolatileTest{
// static volatile boolean flag = true;
static boolean flag = true;
public static void main(String[] args) {
new Thread("t1") {
public void run() {
while (flag) {
}
System.out.println("end......");
}
}.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
System.out.println("end main......");
}
}执行结果:
运行上面代码,会发现程序无法终止。
- 线程t1的run()方法中有个循环,通过 flag 来控制循环是否结束,主线程中休眠了1秒,将 flag 置为false,按说此时线程t1会检测到 flag 为false,打印“end main......”,为何结果不是这样
- 运行上面的代码我们可以判断,t1中看到的 flag 一直为true,主线程将 flag 置为false之后,t1线程中并没有看到,所以一直死循环。
- 那么t1中为什么看不到被主线程修改之后的flag?
首先要先了解一下java内存模型(JMM),Java线程之间的通信由Java内存模型(称为JMM)控制, JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。 本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
Java内存模型的抽象示意图如下:
1.1.1. 根据上图说说上面代码,执行过程。线程A(t1)跟线程B(main)的交互:
- 首先,线程A将去 主内存 读取 共享变量 到 本地变量 中,此时线程A读取到 flag 为 true。
- 然后,线程B也有自己 本地变量,将 flag 修改为 false 刷新到 主内存 中。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。对JMM了解之后,我们再看看文章开头的问题,线程t1中为何看不到被主线程修改为false的 flag 的值,有两种可能:
- 线程B(main)修改了 flag 之后,未将其刷新到主内存,所以线程A(t1)看不到
- 线程B(main)将 flag 刷新到了主内存,但是线程A(t1)一直读取的是自己工作内存中 flag 的值,没有去主内存中获取 flag 最新的值
对于上面2种情况,java帮我们提供了这样的方法,使用 volatile 修饰共享变量,就可以达到上面的效果,被 volatile 修改的变量有以下特点:
- 线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
- 线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
1.2. 禁止指令重排
volatile 实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象先了解一个概念,内存屏障(Memory Barrier) 又称内存栅栏,是一个CPU指令,它的作用有两个:
- 证某些变量的内存可见性
- 保证特定操作的执行顺序
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排,也就是说通过插入内存屏障,就能禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
2. JMM内存屏障插入策略
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。