这篇文章可能篇幅比较长,大概需要6-8分钟阅读,但是读完应该是会有所收获的。最近要忙毕设的论文,脑阔疼,加上得想着写文章,可能比较匆忙,如果文章有错的地方,可以留言修改呀。以后更新的频率可能暂时一周一更,主要论文难搞,而且也得学技术才有总结,我太难了。
首先,每个线程都有自己的工作内存,除此之外还有一个cpu的主存,工作内存是主存的副本。线程工作的时候,不能直接操作主内存中的值,而是要将主存的值拷贝到自己的工作内存中;在修改变量,会先在工作内存中修改,随后刷新到主存中。
注意: 什么时候线程需要将主存中的值拷贝到工作内存
假设有一个共享变量flag为false,线程a修改为true后,a的工作内存修改了,也刷新到了主存。这时候线程b对flag进行对应操作时,是不知道a修改了的,也称a对b不可见。所以我们需要一种机制,在主存的值修改后,及时地通知所有线程,保证它们都可以看到这个变化。
1public class ReadWriteDemo {
2
3 //对于flag并没有加volatile
4 public boolean flag = false;
5 public void change() {
6 flag = true;
7 System.out.println("flag has changed:" + flag);
8 }
9
10 public static void main(String[] args) {
11
12 ReadWriteDemo readWriteDemo = new ReadWriteDemo();
13 //创建一个线程,用来修改flag,如上面描述的a线程
14 new Thread(new Runnable() {
15 @Override
16 public void run() {
17 try {
18 Thread.sleep(3000);
19 readWriteDemo.change();
20 } catch (InterruptedException e) {
21 e.printStackTrace();
22 }
23 }
24 }).start();
25
26 //主线程,如上面描述的b线程
27 while(!readWriteDemo.flag) {
28 }
29 System.out.println("flag:" + readWriteDemo.flag);
30 }
31
32}
按照分析,没有加volatile的话,主线程(b线程)是看不到子线程(a线程)修改了flag的值。也就是说,在主线程看来,在没有特殊情况下,flag 永远为false,while(!readWriteDemo.flag) {}
的判断条件为true,系统不会执行到System.out.println("flag:" + readWriteDemo.flag);
为了避免偶然性,我让程序跑了6分钟。可以看到,子线程确实修改了flag的值,主线程也和我们预期一样,看不到flag的变化,一直在死循环。如果给flag变量加一个volatile呢,预期结果是,子线程修改变量对主线程来说是可见的,主线程会退出循环。
可以看到,都不到一分钟,在子线程修改flag的值后,主线程随即就退出循环,说明立刻感知到了flag变量的变化。
有趣的是什么呢:如果ab两个线程间隔时间不长,当b线程也延迟10s读(不是上面的立刻读),你会发现两个线程之间的修改也是可见的,为什么呢,stakc overflow上有解答,执行该线程的cpu有空闲时,会去主存读取以下共享变量来更新工作内存中的值。更有趣的是,在写这篇文章的时候,cpu及内存是这样的,反而能正常执行,但是能出现问题就能说明volatile的作用。
image-20210409163242085
首先要先讲一下java内存模型,java的的内存模型规定了工作内存与主存之间交互的协议,定义了8中原子操作:
对带有volatile的变量进行写操作会怎么呢。JVM会像处理器发送一条lock前缀的指令,a线程就锁定主存内的变量,修改后再刷新到主存。b线程同样会锁定主存内的变量,但是会发现主存内的变量和工作内存的值不一样,就会从主存中读取最新的值。从而保证了每个线程都能对变量的改变可见。
在编程世界里面,原子性是指不能分割的操作,一个操作要么全部执行,要么全部不执行,是执行的最小单元。
1public class TestAutomic {
2 volatile int num = 0;
3 void add() {
4 num++;
5 }
6
7 public static void main(String[] args) throws InterruptedException {
8 TestAutomic testAutomic = new TestAutomic();
9 for (int i = 0; i < 1000; i++) {
10 new Thread(new Runnable() {
11 @Override
12 public void run() {
13 try {
14 Thread.sleep(10);
15 testAutomic.add();
16 } catch (InterruptedException e) {
17 e.printStackTrace();
18 }
19 }
20 }).start();
21 }
22 //等待12秒,让子线程全部执行完
23 Thread.sleep(12000);
24 System.out.println(testAutomic.num);
25 }
26
27}
预期现象:都说不能保证原子性了,所以,应该结果是不等于1000
不同电脑执行的结果不一样,我的是886,可能你们的不是,但是都说明了volatile都无法保证操作的原子性。
这要从num++操作开始讲起,num++操作可以分为三步:
我们知道线程的执行具有随机性,假设a线程和b线程中的工作内存中都是num=0,a线程先抢了cpu的执行权,在工作内存进行了加1操作,还没刷新到主存中;b线程这时候拿到了cpu的执行权,也加1;接着a线程刷新到主存num=1,而b线程刷新到主存,同样是num=1,但是两次操作后num应该等于2。
解决方案:
对于我们写的程序,cpu会根据如何让程序更高效来对指令进行重排序,什么意思呢
1a = 2;
2b = new B();
3c = 3;
4d = new D();
经过优化后,可能真实的指令顺序是:
1a = 2;
2c = 3;
3b = new B();
4d = new D();
并不是所有的指令都会重排序,重排序与否全是看能不能使得指令更高效,还有下面一种情况。
1a = 2;
2b = a;
这两行代码无论什么情况下都不会重排序,因为第二条指令是依赖第一条指令的,重排序是建立在排序后最终结果仍然保持不变的基础上。下面将给出volatile防止重排序的例子:
1public class TestReorder {
2 private static int a = 0, b = 0, x = 0, y = 0;
3
4 public static void main(String[] args) throws InterruptedException {
5 while (true) {
6 a = 0; b = 0; x = 0; y = 0;
7 //a线程
8 new Thread(new Runnable() {
9 @Override
10 public void run() {
11 try {
12 Thread.sleep(10);
13 a = 1;
14 x = b;
15 } catch (InterruptedException e) {
16 e.printStackTrace();
17 }
18
19 }
20 }).start();
21
22 //b线程
23 new Thread(new Runnable() {
24 @Override
25 public void run() {
26 try {
27 Thread.sleep(10);
28 b = 1;
29 y = a;
30 } catch (InterruptedException e) {
31 e.printStackTrace();
32 }
33
34 }
35 }).start();
36
37 //主线程睡100ms,以保证子线程全部执行完
38 Thread.sleep(100);
39 System.out.println("a=" + a + ";b=" + b + ";x=" + x + ";y=" + y);
40
41 }
42 }
43
44}
还记得上面说过两个线程如果沉睡时间差不多,它们之间是可见
预期结果:
可以发现除了上面预期的三种情况,还出现了一种a = 1; b = 1; x = 0; y = 0的情况,相信大家也知道了,这种情况就是因为重排序造成的。要么是a线程重排序先执行x = b;
再执行a = 1;
,要么是b线程重排序先执行了y = a;
再执行了b = 1;
;要么是两个线程都重排序了。
如果private volatile static int a = 0, b = 0, x = 0, y = 0;
加了volatile关键字会怎么样呢?
为了保证正确性,又持续跑了5分钟,可以发现,确实不会再出现x=0;y=0的情况。
先来讲讲4个内存屏障的作用
内存屏障
StoreStore屏障 | 禁止上面的普通写和下面的的volatile写重排序 |
StoreLoad屏障 | 禁止上面的volatile写和下面volatile读/写重排序 |
LoadLoad屏障 | 禁止下面的普通读和上面的volatile读重排序 |
LoadStore屏障 | 禁止下面的普通写和上面的volatile读重排序 |
可能看作用比较抽象,直接举例子叭
S1; StoreStore; S2
,在S2及后续写入操作之前,保证S1的写入操作对其它线程可见。S; StoreLoad; L
,在L及后续读/写操作之前,保证S的写入对其它线程可见。L1; LoadLoad; L2
,在L2及后续读操作之前,保证L1读取数据完毕。L; LoadStore; S
,在S及后续操作之前,保证L读取数据完毕。那么volatile是如何保证有序性的呢?
举例,有个对volatile变量的写S,有个对volatile变量的读L,会怎么样呢。
S1; StoreStore; S ;StoreLoad L
这样能够把S(对volatile变量保护在中间)防止重排序。L1; LoadLoad; L ; LoadStore S
,一样把volatile变量保护的好好的。