说在前面的话
正如我开篇所说,我们要整理一些java并发编程的学习文档,这一篇就是第四篇:volatile关键字。 主要讲volatile关键字的主要作用和实现这些作用的原理。
开整
先整体来说:volatile关键字可以修饰变量,volatile修饰的变量有两个作用
- 第一是确保变量在线程间可见
- 第二是确保对变量的操作不可重排序。
tips:我个人觉得,关于volatile关键字,把解决的问题搞明白才是关键。明白要解决的问题是什么,那么在以后的搬砖过程中,一定要注意这些问题就好了。所以在我的这篇文章中我也是重点说明这两个问题。至于volatile解决这两个问题的原理,我觉得吧…并非重点。 所以我使用形象的方式让你明白就行。如果一定要研究底层原理,要查看汇编指令,也可以私信我。我讨论讨论。。。。。
okkkkkk.
要说明这两个问题,就要先了解其他的几个问题。
问题
第一个问题:变量在线程间不可见的问题
看看代码先
说明:准备两个线程,线程A死循环使用一个公共变量,当变量为指定值的时候退出。线程B在线程A启动之后将公共变量修改为指定值。 理论上修改之后,线程A就应该退出循环,可是实际上…
package com.qidian;
import java.util.concurrent.TimeUnit;
/**
* @author 戴着假发的程序员
* @company 江苏极刻知学-起点编程
*/
public class VolatileDemo {
// 公共变量
private static int x = 3;
public static void main(String[] args) throws InterruptedException {
// 线程A 循环的访问变量
new Thread(()->{
System.out.println("线程A启动");
while(true){
if(x == 4){
break;
}
}
System.out.println("线程A退出");
}).start();
// 线程B稍等10毫秒就修改变量x为4
TimeUnit.MICROSECONDS.sleep(10);
new Thread(()->{
x = 4;
System.out.println("线程B修改x为:"+x);
}).start();
}
}
执行结果:
很明显:线程B已经将变量x修改为4了。但是线程A依然没有结束死循环,没有退出…
这就足以说明线程B修改变量x的值这件事是背着线程A操作的。这就是线程间的变量不可见。
当我们使用volatile修饰x变量之后,就不会有这个问题了。(你可以自己试试)
嗯…关于这个问题的原因可以继续看后面的说明。
第二个问题:指令重排序。
我们所有的程序最终都是CPU的指令,CPU为了提高指令执行的效率,对于相互没有影响的指令进行了重新排序。比如下面的程序:
int x = 10;
int y = 100;
int z = x + y;
第一行和第二个分别是给x和y赋值,交换它们的赋值顺序,不会对程序最终执行的结果造成任何影响,所以这两个赋值的指令就可以重排序。但是第三行给z赋值就要用到x和y,那么第三行的赋值语句就必须在第一行和第二行执行之后,就不能重排序。
在多线程的情况下,指令重排序就有可能会出现意外。
看一段代码:
代码说明一下:准备四个公共变量x,y,a,b 。准备两个线程,线程A给x赋值为1,给a赋值为y。 这两个赋值语句是没有前后关系的可以重排序。线程B给y赋值为1,给b赋值x,这两个赋值语句也是没有前后关系的,可以重排序。
然后循环的执行上面的操作。经过分析 a 和 b的值可能为一下几种情况:
情况1: a = 1,b = 1
情况2: a= 0,b = 1;
情况3: a = 1,b = 0;
不可能出现的情况是:a =0;b=0; 但是我的程序的执行结果吗…
package com.qidian;
import java.util.concurrent.CountDownLatch;
/**
* @author 戴着假发的程序员
* @company 江苏极刻知学-起点编程
*/
public class VolatileDemo1 {
// 准备四个共享变量
private static int x,y,a,b;
public static void main(String[] args) throws InterruptedException {
// 准备一个死循环
int count = 0;
while(true){
x = y = a = b = 0;
// 准备一个 CountDownLatch 确保两个线程结束之后再比较四个变量的值
CountDownLatch cd = new CountDownLatch(2);
// 线程A,先个x赋值,再把y的值赋值给a
new Thread(()->{
x = 1;
a = y;
cd.countDown();
}).start();
// 线程B,先给y赋值,再把x的值赋值给b;
new Thread(()->{
y = 1;
b = x;
cd.countDown();
}).start();
cd.await();// 1 1 1 1 , 1 1 1 0 , 1 1 0 1,
System.out.println("第"+(++count)+"次执行:x = " + x + " , y = " + y + " , a = " + a + " , b = " + b);
if(a == 0 && b == 0){
break;
}
}
}
}
结果:
这就是说,在线程A或或者线程B中,出现了两个赋值语句的顺序颠倒的情况,否则不可能出现a和b都是0的情况。(不明白的同学可以去我的b站看看视频说明)
关于问题的说明
首先要说问题2:指令重排序。
这个问题其实没啥要说的,这就是CPU级别的操作,本身就是为了提高指令的执行效率。如果你的程序真的有我上一章节写的那种类似的情况,那么变量记得使用volatile修饰就行了。
关于问题1:变量在线程间不可见,我们就要聊到另外一个问题,就是JMM内存模型。
JMM内存模型
JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
通俗点说: CPU的执行速度贼快,但是内存的读写速度有限,CPU要从内存读写数据,于是乎内存的读写速度限制了CPU的执行速度。于是乎,就有了更好的办法,那就是在CPU和内存之间加上高速缓存(也叫本地缓存)。CPU从内存中加载数据到高速缓存中,然后重复使用。等到完全使用完成之后才会将最后的值写回内存。
那么问题是什么呢?
问题是现在的CPU都是多核的,每个核都会对应一个高速缓存。像下面这幅图一样:
每个核都可以执行一个线程。每个线程加载和使用共享数据的流程:
read,load,(use,asign),store,write。 其中use和asign就是CPU和高速缓存之间的操作。在线程没有结束之前,这个数据不会写入内存。
所以我们之前的程序中线程A就是这样的:
线程A从内存中加载了变量X的值,然后就把整个值放在自己的高速缓存中,循环的使用。而且使用期间不检查内存中的x是否已经发生了改变,这时线程B修改了内存重的变量x。但是线程A并不知道。这就是变量的线程间不可见的原因。
volatile是如何解决问题的呢?
现在已经大致明白了:
- 共享变量在线程间不可见是因为JMM内存模型,导致一个CPU在将某个数据放在自己的高速缓存中的时候,这个数据就不能和其他的线程共享可见课。
- 指令重排序的问题是CPU为了提高指令的执行速度而做的工作。
那么怎么解决呢? 当然就是使用volatile修饰这些变量或者对象了。
那么原理是什么?
OKKKKK。我们来看看原理:
什么代码,截图,证明我就略了,我就画图说说原理吧!
先说第一个问题的解决原理:
如果变量没有使用volatile修饰,那么如果某个线程在自己的高速缓存中的数据就是和其他线程不可见的。
大佬开会,小蓝和三个小绿都从小黄哪里复印了一份秘密文件。都在认真的阅读文件,这时小蓝发现文件有问题,于是乎修改了文件,并且提醒小黄也修改了文件但是其他三个小绿并不知道这事,于是乎,小绿们拿到的秘密文件就不是最正确的一版了。
如果使用volatile修饰的变量,当某个线程把修改的变量值写回主内存的时候,通过缓存一致性协议立刻通知其他的CPU将自己高速缓存中的对应的变量作废,那么他们就会从主内存中获取最新的变量值。
大佬继续开会这次,小黄宣布了规矩:如果我这里的文件修改了,你们必须从我这里重新复印一份新的秘密文件。于是后就这样了:当小蓝修改了文件之后,就把修改的内容也更新到了小黄哪里,更新的过程中就告诉三个小绿,让他们把之前的秘密文件扔掉,再从小黄哪里复印最新的秘密文件。
嗯!!! 其实保证变量在线程间可见的情况大概就是这样的吧!!!
话说回来,其实很多时候我主要是要明白问题是什么,在写程序的时候要注意避免这些问题就OK啦!
再说第二个问题的解决原理
有个名词:内存屏障。
专业解释大致是这样的:一组处理器指令,用于实现对内存操作的顺序限制。
具体阻止指令重排序的做法就是:所有的使用volatile修饰的变量,在赋值获取值的操作指令之前都会有对应的内存屏障,防止其它指令越过它。额。。。。。”越过?“ 反正就是保证volatile修饰的变量的获取值和赋值的的指令位置不变。前后的指令也不能交换。
大致就是这样的:
哪里有厕所。。。。。
上厕所这件事情,有两个流程。。。。第一:上厕所。第二:擦屁股。这两个流程应该是不能乱来的。
但是大锤已经忍的有点缺氧了,可能记不清顺序了,于是乎我们再这两个任务之间加了一个“墙“,防止他乱来:
这就是屏障。
总结
volatile解决的两个问题是:
- volatile修饰的变量在线程间可见。
- volatile修饰的变量的操作指令不能被重排序。
产生这两个问题的原因是
- JMM内存模型导致了线程间变量的不可可见性。
- CPU指令乱序执行提高执行执行效率。
其它的吗…还有啥呢??? 欢迎补充…
我是”起点编程“的"戴着假发的程序员" 欢迎关注…欢迎评论。。。。。
起点编程-是你我的未来…