volatile的概述
一个现象引发的思考
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
try {
Thread.sleep(0);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
Thread.sleep(1000);
stop = true;
}
上面这段代码,我在将stop设置为true的时候,程序应该会停下来,然而并没有。这是什么原因呢?
这里就涉及到一个概念,可见性
百度百科:
volatile 是干什么用的呢?
volatile 可以使得在多处理器环境下保证了共享变量的可见性
可见性:
举个例子:在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是 一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。
为了实现跨线程写入的内存可见性,必须使用到一些机制来实现。而 volatile 就是这样一种机制
从硬件层面了解可见性的本质
我们知道,CPU的运算速度很快,其次是内存(运存),再次才是我们的硬盘,那么如果仅仅是cpu的运算速度很快的话,内存和硬盘的速度是跟不上cpu的速度。那么这台计算器的性能就是最慢的硬盘的性能。这就是 “木桶效应” 。
为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化
- CPU 增加了高速缓存
cpu在执行运算任务的时候,少不了和内存交互,读取或者写入数据。而由于计算机的存储设备与CPU的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近CPU运算速度的高速缓存来作为内存 和处理器之间的缓冲;将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
- 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率
- 编译器的指令优化,更合理的去利用好 CPU 的高速缓存
高速缓存示意图
通过高速缓存的存储交互很好的解决了处理器与内存的速度差异,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。
为了保证缓存一致性
开发大佬们设计了两种方式:
- 总线锁:效率低,使用总线锁之后,在很多场景会变成串行操作。
总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的
- 缓存一致性协议:在P6架构之后的CPU引入,粒度比总线锁细。效率更高,如果CPU不支持缓存锁,那么默认用总线锁。
所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。
缓存一致性
什么叫缓存一致性呢?
首先,有了高速缓存以后,每个 CPU 的处理过程是: 先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。
由于在多 CPU 中,每个线程可能会运行在不同的 CPU 内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题
缓存一致性协议(硬件层面)
缓存一致性协议保证缓存一致性
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。
MESI表示缓存行的四种状态,分别是
- M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致,当缓存是这个状态时,会将其他缓存中的数据置为无效。
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
指令重排序
Java语言规范JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。
指令重排序的意义:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。
通过内存屏障禁止了指令重排序
X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)
- Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(storebufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或
者写是可见的 - Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
以上是硬件层面解决可见性的措施
以下是软件层面解决可见性的措施。
Java Memory Model (java内存模型)(JMM)
JMM是干什么的?
简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:
如下: 在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
JMM在Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题
导致可见性问题的根本原因是缓存以及指令的重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 是如何解决可见性有序性问题的
简单来说,其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有
- 总线锁和缓存锁,缓存锁是基于MESI协议。
- 而对于指令重排序,硬件层面提供了内存屏障指令。
而JMM在这个基础上提供了
- volatile、
- synchronized、
- final等关键字
使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。(这也是volatile的原理)
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序, 在 JMM 中把内存屏障分为四类
屏障类型 | 指令示例 | 备注 |
---|---|---|
LoadLoad Barriers | load1; LoadLoad;load2 | 确保load1数据的装载优先于load2及所有后续装载指令的装载 |
StoreStore Barriers | store1; StoreStore; store2 | 确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储 |
LoadStore Barriers | load1;LoadStore;store2 | 确保load1数据装载优先于store2以及后续的存储指令刷新到内存 |
StoreStore Barriers | store1;StoreStore;load2 | 确保store1 数据对于其他处理器变得可见,优先于load2及所有后续所有装载指令的装载。这条内存屏障是一个全能型的内存屏障 |
HappenBefore原则
除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
1. 程序顺序规则 (as-if-serial);
- 不能改变程序的执行结果(在单线程环境下,执行的结果不变.)
- 依赖问题, 如果两个指令存在依赖关系,是不允许重排序
例如:
int a=2; //1
int b=3; //2
int result=a*b; //3
/*
1 和 3、2 和 3 存在数据依赖,所以在最终执行的指令中,
3 不能重排序到 1 和 2 之前,否则程序会报错。
由于 1 和 2 不存在数据依赖,所以可以重新排列 1 和 2 的顺序
*/
a happens -before b ; b happens before c
2. 传递性规则;
如果 1 happens-before 2; 3happens- before 4; 那么传递性规则表示: 1 happens-before 4;
3. volatile 变量规则;
对于 volatile 修饰的变量的写的操作, 一定 happen-before 后续对于 volatile 变量的读操作; 内存屏障机制来防止指令重排。根据 volatile 规则,2 happens before 3
示例:
public class test {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2 修改
}
public void reader() {
if (flag) { // 3 true
int i = a; // 4
}
}
}
1 happens-before 2 是否成立? 是 -> ?
3 happens-before 4 是否成立? 是 as-if-serial 规则
2 happens -before 3 ->volatile规则
1 happens-before 4 ; i=1成立.
- start 规则;
如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作
public class test {
public void demo01() {
int x = 0;
Thread t1 = new Thread(() -> {
//读取x的值 一定是20
if (x == 20) {
}
});
x = 20;
t1.start();
}
}
- join 规则;
如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
public class test {
public void demo01() {
int x = 0;
Thread t1 = new Thread(() -> {
x = 200;
});
t1.start();
t1.join(); //保证结果的可见性。
//在此处读取到的x的值一定是200.
}
}
- 监视器锁的规则;
对一个锁的解锁,happens-before 于 随后对这个锁的加锁
public class test {
public void demo01() {
int x = 10;
synchronized (this) {
//后续线程读取到的x的值一定12
if (x < 12) {
x = 12;
}
}
x = 12;
}
}
- final:final关键字提供了内存屏障的规则.
下一篇
多线程学习(四)之 “死锁”