volatile 关键字几乎是 Java 面试中必问的知识点,相信很多朋友对它的认知也仅限于会用,今天我们换个角度去看看。
首先来看一段 demo 代码:
public static void main(String[] args) {
ABC abc = new ABC();
abc.start();
for (; ; ) {
if (abc.isFlag()) {
System.out.println("字符串ABC");
}
}
}
class ABC extends Thread {
private boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag = " + flag);
}
}
执行以上代码,你会发现,永远都不会输出 “字符串ABC” 这行打印。按道理线程 ABC 改变了 flag 变量,主线程也应该能访问到呀。为什么会出现这种情况?我们接着往下看。
JMM(JavaMemoryModel)
**
JMM即Java内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
现代计算机的内存模型
现代计算机中,CPU 的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。
将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存中,这样处理器就无须直接操作缓慢的内存了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性。
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。
Java 内存模型描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量,存储到内存和从内存读取变量这样的底层细节。
JMM 有以下规定:
- 所有共享变量都存储于主内存中,此处所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存保留了被线程使用的变量的工作副本。
- 线程对于变量的所有操作都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 不同线程之间不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
本地内存和主内存的关系
正是因为这样的机制,才导致了可见性问题的存在,下面就讨论一下可见性的解决方案。
可见性的解决方案
加锁
public static void main(String[] args) {
ABC abc = new ABC();
abc.start();
for (; ; ) {
synchronized (abc) {
if (abc.isFlag()) {
System.out.println("字符串ABC");
}
}
}
}
为啥加锁可以解决可见性问题?
某一个线程进入 synchronized 代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量的最新值到工作内存,执行之后,将修改后的数据刷新到主内存,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值,肯定一直都是新的。
volatile 修饰共享变量
开头的代码修改如下
public static void main(String[] args) {
ABC abc = new ABC();
abc.start();
for (; ; ) {
synchronized (abc) {
if (abc.isFlag()) {
System.out.println("ABC");
}
}
}
}
class ABC extends Thread {
private volatile boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag = " + flag);
}
}
volatile 做了啥?
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果工作内存中的数据被修改并已经写回主内存,其他线程已经读取到工作内存的变量副本就会失效,当需要对数据进行操作时都需要再次从主内存中读取。
volatile 保证不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改写回主内存时,其他线程能够立即看到最新的值。
MESI(缓存一致性协议)
当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他CPU 将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中的该变量是无效的,那么它就会从内存重新读取。
如何发现数据是否是有效的?
嗅探
每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读取到处理器的缓存中。
总线风暴
由于 volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用 volatile。
禁止指令重排序
什么是重排序?
**
为了提高性能,编译器和处理器通常会对人工编写的代码执行顺序进行指令重排序。
重排序的类型有哪些?源码到最终执行会经过哪些重排序呢?
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标:在不改变程序执行结果的前提下,尽可能提高执行效率。
一般重排序可以分为如下三种:
- 编译器优化的重排序,编译器在不改变线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令级并行的重排序,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变。
编译器,runtime 和处理器都必须遵守 as-if-serial 语义
volatile 如何保证不会被重排序?
内存屏障
Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
为了实现 volatile 的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定 volatile 重排序规则表:
需要注意的是:volatile 写操作是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
写
读
上面我们提到过重排原则,为了提高处理速度,JVM 会对代码进行编译优化,也就是指令重排优化,并发编程下指令重排序会带来一些安全隐患:如指令重排导致的多个线程操作之间的不可见性。
从 JDK 5 开始提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。
happens-before
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。volatile 域规则:对一个 volatile 域的写操作,happens-before 于任意线程后续对这个 volatile 域的读。
现在 flag 变成了 false,那么后面的那个操作,一定要知道 flag 变了。
聊了这么多,我们要知道 volatile 是没办法保证原子性的,一定要保证原子性,可以使用其他方法。
无法保证原子性
所谓原子性操作就是指一次操作要么完全成功要么完全失败。假设现在有 N 个线程对同一个变量进行累加也是没办法保证结果是对的,因为读写这个过程并不是原子性的。要解决也简单,要么用原子类,比如 AtomicInteger,要么加锁(记得关注 Atomic 的底层)。
应用
public class Singleton {
//可见性和指令重排序都保证
private volatile static Singleton instance = null;
//私有构造
private Singleton() {
}
public static Singleton getInstance() {
//第一重检查锁定
if (instance == null) {
//同步锁定代码块
synchronized (Singleton.class) {
//第二重检查锁定
if (instance == null) {
//注意:非原子操作
instance = new Singleton();
}
}
}
return instance;
}
}
大家可能好奇为啥要双重检查?如果不用 volatile 会怎样?
我先讲一下禁止指令排序的好处。实际上创建对象要经过如下几个步骤:
- 分配内存空间;
- 调用构造器,初始化实例;
- 返回地址给引用;