# 前言
首先讲解一下多线程的概念,然后通过一个案例来加深对volatile关键字的理解
最后讲解一些volatile关键字的原理及牵扯到的相应知识。
# 请讲述一下volatile关键字
它是轻量的synchronized
在多处理器开发中保证了共享变量的可见性
可见性就是当一个线程进行修改共享变量的时候,保证将变量的更新操作通知到其他的线程
如果该关键字使用恰当,那么效率比synchronized要好,因为不会引起线程的上下文切换和调度
volatile 关键字能保证内存的可见性,但是不能保证原子性
# 为什么使用volatile关键字?
下面从一个具体的案例来说明一下volatile关键字
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class Test {
private static ThreadPoolExecutor pool = new ThreadPoolExecutor(5,5,1,
TimeUnit.SECONDS,new ArrayBlockingQueue<>(200));
static {
pool.allowCoreThreadTimeOut(true);
}
private volatile static boolean b = false; //开关。volatile关键字
static void f(){
//任务处理
}
public static void main(String[] args) throws InterruptedException{
pool.execute(new Runnable() {
@Override
public void run() {
int i=0;
while(!b){ // ②
i++;
f();
}
System.out.println("thread over");
}
});
Thread.sleep(500); // 时间到,开关关闭
b = Boolean.TRUE; // ①
System.out.println("main over");
}
}
可以看到:
- 共享变量 b 初始为 false,主线程 ① 和线程 ② 同时从主存中拿到 b
- 主线程拿到 b 后,将b改为true,如果不加volatile,线程②将看不到b的变化
- 那么,线程 ② 的 b 还是初始的 false,while(!b)将会进入死循环,也就执行不了 System.out.println(“thread over”);
# 深入理解volatile关键字
在讲解volatile关键字之前我们需要具备一些基础知识
## 上下文切换
多线程会共同使用一组计算机上的CPU,而线程数大于给程序分配的CPU数量时,为了让各个线程都有执行的机会,就需要轮流使用CPU。利用时间片轮转的方式,CPU给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一个任务后,继续服务下一个任务,这个过程叫做上下文切换。即对于单核CPU,CPU在一个时刻只能运行一个线程,当在运行一个线程的过程中转去运行另外一个线程,这个叫做线程上下文切换。
## 指令重排序
从虚拟机和硬件两个层面解释
虚拟机层面。为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则(这规则后面再叙述)将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。
硬件层面。同样是出于以上目的,CPU会将接收到的一批指令按照其规则重排序,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序。
重排序的分类
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
指令重排序场景:
class ResortDemo {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // ①
flag = true; // ②
}
Public void reader() {
if (flag) { // ③
int i = a * a; // ④
……
}
}
}
当两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 ④ 时,能否看到线程 A 在操作 ① 对共享变量 a 的写入?
- 答案是:不一定能看到。
- 由于操作 ① 和操作 ② 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;
- 同样,操作 ③ 和操作 ④ 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
## 缓存一致性
为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能。CPU增加了高速缓存,操作系统增加了进程、线程,通过CPU时间片的切换最大化的提升CPU的使用率。 通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。
同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等。最常见的就是 MESI 协议。简单介绍下MESI协议,MESI 表示缓存行的四种状态,分别是:
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数 据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
- CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据
- CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写
## 导致重排序的原因
虽然 MESI 可以保证缓存一致性,但是也是会存在一定的问题。基于上图中的原因,CPU 又引入了 storeBuffers 的缓冲区。CPU 只需要在写入共享数据时,直接把数据写入到 storebufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。
当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 storeBufferes 中的数据数据存储至 cache line 中。最后再从缓存行同步到主内存。
重新看上面的代码: 当执行 ① 操作时,a的状态从 S->M,此时,线程 A 会先把变更写入到 storeBuffers,然后发送invalidate 去异步通知其他 CPU 线程,紧接着就执行了下面的 ② 操作。 此时,可能 ① 的变更还在 storeBuffers中,并未提交到主内存。什么时候会提交到主内存,也不确定。 所以,线程 B 调用 read 方法可能会出现,看到了 flag 的变更,但是看不到 a 的变更,就出现了重排序的现象。
## volatile 和 synchronized 是如何实现阻止指令重排序的
Java提供了两个关键字 volatile 和 synchronized 来保证多线程之间操作的有序性,volatile 关键字本身通过加入内存屏障来禁止指令的重排序,而 synchronized 关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现。在单线程程序中,不会发生「指令重排」和「工作内存和主内存同步延迟」现象,只在多线程程序中出现。
## volatile是如何通过内存屏障来禁止指令重排序的
被volatile修饰的变量在编译成字节码文件时会多个lock指令,该指令在执行过程中会生成相应的内存屏障,以此来解决可见性跟重排序的问题。
内存屏障的作用:
- 在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序。
- 在有内存屏障的地方,线程修改完共享变量以后会马上把该变量从本地内存写回到主内存,并且让其他线程本地内存中该变量副本失效(使用MESI协议)。
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。类似于Lock指令。具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。最后释放锁后会把高速缓存中的脏数据全部刷新回主内存,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。