多线程二之volatile关键字
一个问题引发的思考
请看如下代码:
代码的执行结果是服务器死锁了没有执行结束
public class VolatileDemo {
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
System.out.println("rs: " + i);
});
thread.start();
Thread.sleep(1000);
stop = true;
}
}
解决死锁
-
方法一
将System.out.print()打印在代码块中,可以防止死锁,具体代码如下:
public class VolatileDemo {
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
System.out.println("rs: " + i); //在指定的代码块中打印print方法
}
});
thread.start();
Thread.sleep(1000);
stop = true;
}
}
能够解决死锁的原因:
这里可以分为两个层面来解答
- println底层使用到了synchronized这个同步关键字,这个同步会防止循环期间对于stop值的缓存。
- 因为println有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存,可以通过如下代码去证明:
public class VolatileDemo {
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
synchronized (VolatileDemo.class){
}
}
});
thread.start();
Thread.sleep(1000);
stop = true;
}
}
- println是IO操作,我们知道磁盘IO的效率一定比cpu计算的效率要慢好多,所以IO的执行可以使得cpu有时间去做内存刷新的事情,从而导致这个现象,比如我们也可以在代码块中写入new File()来执行一个IO操作。
public class VolatileDemo {
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
new File("c://");
}
});
thread.start();
Thread.sleep(1000);
stop = true;
}
}
-
方法二
Thread.sleep(0),代码如下:
public class VolatileDemo { public static boolean stop = false; 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; } }
这是因为Thread.sleep(0)会导致线程切换,线程切换回导致缓存r效从而读取到了新的值。
-
方法三
使用volatile关键字来保证可见性:
通过对上述代码查看汇编指令,使用HDDIS工具,具体的使用方法详见压缩文档。可以看到,使用volatile关键字之后,多了一个Lock指令。
思考:Lock汇编指令是如何来保证可见性问题的?
什么是可见性
定义:在单线程的环境下,如果向一个变量先写入一个值,然后再没有写干涉的情况下读取这个变量的值,那么这个时候读取到的这个变量的值应该就是写入的那个值。这个本来是一个很正常的事情,但是在多线程的环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。
硬件层面
CPU/内存/IO设备
- CPU层面增加了告诉缓存
- 操作系统,进程、线程、CPU时间片来切换
- 编译器的优化,更合理的利用CPU的高速缓存
CPU层面的高速缓存
因为高速缓存的存在,会导致一个缓存一致性的问题。
总线锁 & 缓存锁
总线索,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁把CPU和内存之间的通信锁住了,这使得锁定期间,其他的处理器不能操作其他内存地址的数据,所以总线锁的开销比较大,这个机制显然是不合适的。
所谓的缓存锁,就是指在内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行行锁操作回写到内存的时候,不在总线上面加锁,而是修改内部的内存地址,基于缓存一致性的协议来保证操作的原子性。
总线锁和缓存锁如何选择,取决于很多因素,比如CPU是否支持、以及存在无法缓存的数据的时候(比较大或者多个缓存行的数据的时候),必然还是会使用总线锁。
缓存一致性协议
缓存一致性协议图:
MSI、MESI、MOSI....
为了 达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI。
MESI表示缓存行的四种状态,分别是:
- M(Modify)表示共享数据只缓存在当前CPU缓存中,并且是被修改的状态,也就是缓存的数据和主内存中的数据不一致。
- E(Exclusive)表示缓存的独占状态,数据只缓存在当前的CPU缓存中,并且没有被修改。
- S(Shared)表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存中的数据一致
- I(Invalid)表示缓存已经失效
引出了MESI的一个优化
Store Buffers
Store Buffers是一个写的缓存,对于上述的情况,CPU0可以先把写入的操作先存储到Store Buffers中,Store Buffers中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowdgement,继续往下执行其他指令,直到收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。
指令重排序
我们来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0和executeToCPU1,分别由两个不同的CPU来执行。引入Sotre Buffers之后,就可能出现b1返回true,但是assert(a1)返回false。很多同学肯定会表示不理解,这种情况怎么可能成立?那么,接下来我们分析一下:
executeToCPU0(){
a = 1;
b = 1;
}
executeToCPU1(){
while(b == 1){
assert(a == 1);
}
}
X86的memory barrier指令包括Ifence(读屏障) sfence(写屏障) mfence(全屏障)
- Store Memory Barrier(写屏障),高速处理器在写屏障之前的所有已经存储在存储缓存(store buffers)中的数据同步到主内存,简单来说就是对写屏障之前的指令的结果对屏障之后的读或者写是可见的。
- Load Memory Barrier(读屏障),处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- Full Memory Barrier(全屏障),确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
volatile int a= 0;
executeToCpu0(){
a = 1;
// storeMemoryBarrier()写屏障,写入到内存
b = 1;
}
extecuteToCpu1(){
while(b == 1){
loadMemoryBarrier(); // 读屏障
assert(a == 1); // trues
}
}
软件层面
volatile int a = 0;
executeToCpu0(){
a = 1;
storeload();
b = 1;
}
executeToCpu1(){
while(b == 1){
assert(a == 1);
}
}
JMM
高速缓存模型图:
简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中巴共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没有主动限制编译器对于指令的重排序,也就是说在JMM这个模型上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是简历在不同的操作系统和硬件层面之上对问题 进行了统一的抽象。然后再根据Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性的问题。
JMM是 如何解决可见性和有序性问题的
其实通过前面的内容分析我们可以发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性的问题的呢?
其实前面在分析硬件层面的内容的时候,已经提到过,对于缓存一致性的问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上面提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。
Happens-Before模型
除了显示引用volatile关键字能够保证可见性以外,在java中,还有很多的可见性保障的规则。从jdk1.5开始,引入了happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这个两个操作必须要存在happens-before关系。做个两个操作可以是同一个线程,也可以是不同的两个线程。
程序顺序规则as-if-serial语义
- 不能改变程序的执行结果(在单线程的环境下,执行的结果不变)
- 依赖问题,如果两个指令存在依赖关系,是不允许重排序
int a = 0;
int b = 0;
void test(){
int a = 1;
int b = 1;
int c = a * b;
}
a happens before b; b happens before c;
传递性规则
a happens before b; b happens before c;
那么 a happens before c;
volatile变量规则
volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作。内存屏障机制来防止指令重排:
public class VolatileDemo {
int a = 0;
volatile boolean flag = false;
public void wirte(){
a = 1; // 1
flag = true; // 2
}
public void read(){
if(flag){ // 3
int i = a; // 4
}
}
}
解析:
1 happens-before 2 成立
2 happens-before 3 成立 ---因为volatile规则
3 happens-before 4 成立
所以: 1 happens-before 4 成立,所以 i= 1;
监视器锁规则
int x = 10;
synchronized(this){
// 后续线程读取到的x的值一定是12
if(x < 12){
x = 12;
}
}
x = 12;
start规则
public class startDemo{
int x = 0;
Thread t1 = new Thread(() -> {
// 读取x的值, 一定是20
if(x == 20){
}
});
}
x = 20;
t1.start();
Join规则
public class Test{
int x = 0;
Thread t1 = new Thread(() -> {
x = 200;
});
t1.start();
t1.join(); // 保证结果的可见性
// 在此处读取到的x的值一定是200
}
final关键字提供了内存屏障的规则。
系统指令排序流程图: