多线程——内存可见性问题和指令重排序问题(volatile详解)
目录
1.内存可见性问题
1.1 一段非线程安全的代码
1.2 分析原因
2.volatile
2.1 处理机制
2.2 不保证原子性
2.3 (Java内存模型)JMM
3.指令重排序问题
4.小结
在前面学习线程安全问题中,我们说内存可见性问题和指令重排序问题会造成线程安全问题,这两个问题是怎么造成线程安全问题的呢?这是我们这期要探寻的答案。
1.内存可见性问题
1.1 一段非线程安全的代码
public class Main {private static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("线程1开始");while (flag == 0) {//.........}System.out.println("线程1结束");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入flag的值:");flag = scanner.nextInt();});t1.start();t2.start();}
}
代码解读:在线程t1中,有一个永远为真的 while 循环,现在希望通过在线程t2中修改 flag 达到值来结束线程t1。对于多线程来讲,这两个线程同时启动,那么我们通过线程t2对 flag 进行修改,应该是可以结束 t1 这个线程的。但是,当我们运行后发现,线程t1并不会结束,而是继续死循环:
很明显,这个代码存在了bug,存在线程安全问题。像这种一个线程读取,另一个线程修改,当修改线程对数据进行修改时,读取线程并没有读取到的问题,就叫“内存可见问题”。
1.2 分析原因
在前面的学习,我们说遇到线程安全问题,可以给期望运行的逻辑代码加锁,但是在这里,如果我们尝试加锁,那是不行的。可以简单地分析:我们加锁的目的就是让先抢到锁的线程先执行,其它线程阻塞等待,就这个代码而言,如果是线程t2先抢到锁,自然线程t1是可以正常结束,而如果是线程t1先抢到锁,一直进入 while 循环中,而循环条件永远满足,自然就不会释放锁,会一直永远循环,即便是想让t2线程先执行,对t1线程用sleep,这种情况就属于t2先抢到锁,但这种做法又会影响程序的效率,我们使用多线程的目的就是希望得到更高的效率,这种做法就不再符合我们的初衷。这种概率的可能不是我们想要的,打败妖怪,只需要从很多种可能中找到一种可能就可以击败,但对多线程来说,任何一种可能都不能放过。
在计算机中,程序运行是快速运转的,可以达到每秒几十亿次,这取决于CPU,不过就现代计算机而言,也不会相差多少。我们人类的几秒钟,在计算机看来就是“沧海桑田”。因此,在Java中,JVM优化了我们书写的代码。是的,没有听错,是优化。优化了怎么还出问题呢?是如何进行优化的呢?
在上面的代码中,JVM会对我们的代码,在保证逻辑不变的前提下进行修改,使得程序效率更高,这就是所谓的优化。但是,虽然是优化,确保保证了代码的逻辑不变,而在多线程中,这种编译器的判断就引来了比较严重的失误。
比如:有两个快递员负责一个驿站工作,有一天王先生买了一个包裹,王先生就问快递员1“我的包裹到了吗?”,快递员1说“没有。”,并且在自己的备忘录里记录王先生的包裹flag == 0。几天后呢,是快递员2把王先生的包裹送上货架,快递员1没来得及更新自己的备忘录。王先生又打电话问快递员1“我的包裹到了吗?”,快递员1马上查看自己备忘录,还是 flag == 0,就是“您的快递还没到”。本来快递实际已经送到,但是王先生迟迟没有收到快递,陷入了等待。
所以,对于计算机来说,每秒可以达到几十亿次,而执行这么多次(执行的次数取决于我们在多久之后输入,如果十几秒才输入,那就更不得了了),flag 一直等于0,于是JVM就认为,既然都是一样的结果,干脆把它看成一个固定值,所以线程t2去修改的时候,线程t1得到的值依旧不会发生改变。
在这里,JMM(Java内存模型)是这么描述的:每个线程有自己的工作内存,同时这些线程共享一个主内存,当一个线程循环进行读取变量操作的时候,就把主内存的数据拷贝到该线程的工作内存中,后续另一个线程进行修改操作时,也是修改自己工作内存的数据,再拷贝到主内存中,由于第一个线程仍然在等待自己的工作内存,因此感受不到主内存的变化。
所以,为了解决这个问题,引入了关键字 volatile。
2.volatile
2.1 处理机制
volatile
是 Java 提供的一种轻量级同步机制,用于确保多线程环境下变量的可见性和有序性,用于修饰变量,
在计算机中,每个线程有自己的一个工作内存(理解为每个人上班都有一个工位),这些线程不论是读操作还是写操作,都要从主内存中获取数据。所以,上述的问题就是:线程t1一直决定flag不会变,就干脆把这个flag“据为己有”,每次需要读的时候就读自己的工作内存数据,而不会从主内存中获取数据。使用关键字 volatile ,就可以确保这些线程在读写写时,都会从主内存获取一个最新的数据,然后再读或者写,如果是写,还会把写之后的数据更新到主内存。
所以,对上述代码的 flag 使用volatile 修饰,来看是否符合预期结果:
public class Main {private volatile static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("线程1开始");while (flag == 0) {//.........}System.out.println("线程1结束");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入flag的值:");flag = scanner.nextInt();});t1.start();t2.start();}
}
输出结果:
当输入一个非零的 flag 值时,程序运行结束,说明符合预期结果。
flag 被 volatile 修饰后,使得 flag 是可见的,当线程想要对 flag 进行读 / 写时,都会从主内存中获取最新数据。
volatile机制:
- 对于普通变量,线程针对写操作时,修改可能会先写入自己的工作内存中,而不会立即同步到主内存中,针对读操作时可能从线程工作内存中读取旧值
- 对于volatile变量,线程针对写操作时会立即同步到主内存中,读的操作会从主内存中读取最新值
实现原理:JMM
- 写一个volatile变量时,JMM把该线程对应的工作内存中的共享变量刷新到主内存中
- 读一个volatile变量时,JMM把该线程对应的工作内存中的数据设为无效,从主内存读取最新值
2.2 不保证原子性
在前期解决线程安全问题时,我们用到关键字 synchronized ,这是针对原子性问题。那么,volatile 关键字是否也可以保证原子行呢?
先来回顾代码:
public class Main {private volatile static int count = 0;public static void main(String[] args) throws InterruptedException {//线程1Thread thread1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//线程2Thread thread2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//开启线程thread1.start();thread2.start();//等待线程thread1.join();thread2.join();//两个线程各自增 5000 次,期望输出 count = 10000System.out.println("count = " + count);}
}
这段代码两个线程,各自增5000次,期望结果 count = 10000。在定义时,count 是被关键字 volatile 修饰的。来看结果:
显然这个结果,是不符合预期结果的,因此 volatile 不保证原子性。
2.3 (Java内存模型)JMM
JMM 是 Java 虚拟机规范中定义的一种模型,目的是屏蔽掉各种硬件和操作系统的内存访问异常,以实现让Java程序可以在任何平台下都能达到一致的并发效果。
这个工作内存其实并不难理解,就是CPU的寄存器。
核心概念:
- 线程之间共享的变量存放在主内存中
- 每一个线程都有自己的工作内存
- 当线程要修改一个共享变量的时候,会先从修改工作内存的副本(拷贝),再同步到主内存
- 当线程要读取一个共享变量的时候,会从主内存中把这个共享变量拷贝到工作内存中,再从工作内存读取数据
因为每个线程都有自己的工作内存,这些工作内存中的内容相当于一个共享变量的副本,因此在修改一个线程的工作内存中的值,另一个线程可能来不及发生变化。
导致线程不安全的过程:
(1)初始情况,两个线程的工作内存中的数据一样(线程1和线程2都从主内存拷贝数据 a = 5)
(2)如果线程1发生修改,但是可能没有及时同步到主内存中,对应的线程2得到的又是主内存中的数据
经过这两拨操作,就导致了线程出现安全问题。文章开头的代码就是因为这种导致,所以使用关键字 volatile 修饰变量后,线程1修改操作时及时地让更新的内容同步到主内存中,线程2读取时也会从主内存从读取最新值。
3.指令重排序问题
关于指令重排序问题,先来理解这个术语。拆开来看:指令就相当于一个要执行的任务,比如生活中我们要去拿快递就是一个指令,重排序就是调整指令的执行顺序。
假设你要去超时买牙膏、酱油、零食、洗衣粉,这几样东西在超市布局如下:
现在让你最短时间内就进这个超市买完你的需求品,任何出超市,你肯定不会按清单牙膏、酱油、零食、洗衣粉购买,而是先买零食、洗衣粉、牙膏,最好买酱油就出超市,这就是指令重排序。
但在计算机中,线程指令不能重排序,如果指令重排序就会造成线程安全问题。
分析:
指令重排序是指编译器在逻辑不变的情况下,调整代码的执行顺序,以达到提升性能的效果。但在多线程环境下,以读操作和写操作为例,指令重排序问题是在读操作时可能会把读之前的操作放在读之后,写操作时可能把写之后的操作放在写之前。
来看代码:
public class Main {private static int x = 0;private static int y = 0;private static int a = 0;private static int b = 0;public static void main(String[] args) throws InterruptedException {for (int i = 0; ; i++) {x = y = a = b = 0;//每次循环后都初始化为0Thread t1 = new Thread(() -> {a = 1; // 操作1x = b; // 操作2});Thread t2 = new Thread(() -> {b = 1; // 操作3y = a; // 操作4});t1.start();t2.start();t1.join();t2.join();if (x == 0 && y == 0) {System.out.printf("第%d次循环后出现重排序 (x=%d, y=%d)\n", i, x, y);break;}}}
}
先看结束条件:x == 0 && y == 0,再看前面的代码,在给 x 和 y 赋值时,都是先执行了 a = 1, b = 1,这样看来,结束的条件应该不会成立。程序是一直死循环,还是会结束?来看运行结果:
结果现实第 4898 次循环后程序就退出了(当然这个循环每次结果会不一样,因为多线程的调度是随机的、不可抢占的)。为什么会出现 x = 0, y = 0?因为在某时候的执行顺序可能是:
线程1:x = b (读到b=0) 线程2:y = a (读到a=0) 线程1:a = 1 线程2:b = 1
这种情况下,a 和 b 还没来得及修改,所以这是程序退出的原因。
指令重排序问题怎么解决呢?
在文章前面就已经给出答案,也就是使用关键字 volatile,对变量用 volatile 修饰即可。
关键字 volatile 的两个功能就是解决这两个问题:内存可见性问题和指令重排序问题。
两个问题的处理是差不多的,它们的解决方案都是依赖JMM指定的内存屏障规则,内存可见性问题是确保主内存访问,指令重新排序问题确保指令有序执行。
4.小结
本期主要介绍线程安全——内存可见性和指令重排序问题的解决方法,关键字 volatile 主要用于解决内存可见性和指令重排序问题,但不保证原子性。处理机制是
- 对于普通变量,线程针对写操作时,修改可能会先写入自己的工作内存中,而不会立即同步到主内存中,针对读操作时可能从线程工作内存中读取旧值
- 对于volatile变量,线程针对写操作时会立即同步到主内存中,读的操作会从主内存中读取最新值
在前面学习线程状态的转换时,我们看到等待状态有个方法:wait()。wait()、join()、sleep() 它们有什么区别呢?欲知后事如何,且听下回分解!