线程P4 | 线程安全问题及解决方法
何为线程安全?
要谈及何为线程安全,总得说来,我们可以用一句话来概况:
如果在多线程环境下代码运行结果和我们预期是相符的,即和单线程环境下的运行结果相同,那么我们就称这个程序是线程安全的,反之则不安全,即和预期不符;
为了大家更好地理解这句话,大家可以看一下下面这个例子
public class Demo21 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for(int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(() -> {for(int i = 0; i < 5000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
对于上述代码,我们预期的结果应该是输出10000,因为count经过了10000次的累加,然而事实上结果是....?
7019...??这样一个令人摸不着头脑的数字,甚至每次运行的结果都不一样,这是为什么呢?
原因分析:
实际上,count++的操作是分为三步的
- load从内存中读取数据到cpu的寄存器
- add把寄存器中的值+1
- save把寄存器中的值写回内存中
而由于线程是随机调度的,所以有可能t1刚执行到第1一步,cpu资源就被调度走了,那么count值就不会和预想结果一样,也可能t1和t2同时执行第一步,那么它们读取到的数据都是count = 0,而事实上count应该执行的操作是 + 2,因为这样随机调度的不确定性,就使得这样的代码是线程不安全的!!
线程不安全的原因
1) 根本原因
线程不安全的根本原因就是线程的随机调度,这样的随机带来了很多不确定性,使得线程的执行顺序是不确定的~~
2) 多个线程修改同一个变量
通过例子我们可以发现,t1和t2都在针对count这一个变量进行修改的操作,这样的操作就会引起线程安全问题,为了解决这样的问题,我们就会引入"锁"这样的概念,具体的解释我们会在解决安全问题篇讲述~~
3) 修改操作不是原子的
何为原子的?
原子的即原子性的操作,即这个操作是不可再分的。
我们上文提到了,虽然我们肉眼看起来count++这个操作就是对count进行了一个加法操作,但事实上,count++这个操作是包含了三部分的,所以这个操作并不是一个原子性的操作~~因此引发了线程安全问题
4) 内存可见性问题
在讲述这个原因之前,我们要先引入另外一个例子
import java.util.Scanner;public class Demo22 {public static int flg = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while (flg == 0) {}System.out.println("t1线程结束");});Thread t2 = new Thread(() -> {System.out.println("请输入flg的值:");Scanner scan = new Scanner(System.in);flg = scan.nextInt();});t1.start();t2.start();}
}
上述代码我们想实现的结果是,用户输入一个非0的数字后t1线程结束,然而真正的运行结果却无法结束t1线程
原因分析:
造成这样结果的原因就是因为t2修改了这个变量内存,但t1内却没有接收到这个变量的变化,这样的问题我们就称为 "内存可见性" 问题,造成这样的问题主要有以下两个要点:
- JVM识别到load加载的flg的值几百万次都一样[while循环的执行速度是很快的,可能一秒几百万次]
- load操作的花费开销是很大的,远远超过了其它操作
因此在很多次的执行之后,JVM就会觉得,反之每次结果都一样,那还有什么执行的必要吗??因此JVM就自动地优化了代码,将load操作变成了直接使用寄存器中之前"缓存"的值,而非每次去内存中重新获取,大大降低了花费,因此就造成了即使后面修改了flg的值也无法被t2感知到的结果
5) 指令重排序问题
指令重排序实际上也是编译器优化代码的一种方式,保证逻辑不变的前提下,调整原有代码的执行顺序,提高程序的效率,自然,指令顺序都发生了改变,安全也无法保证
在描述解决这些问题之前,我们先来讲一下"锁"的概念
线程加锁
加锁的关键字
形如上图的由synchronized关键字修饰的代码块就是相当于对一个Object对象加锁,当两个线程竞争同一把锁的时候,就会引发阻塞,一个进程拿到锁之后,另外一个进程就会因为拿不到锁而陷入阻塞状态,这样就不会因为随机的变化而造成线程安全问题
我们举个例子来理解一下~~
比如你想要追求你的crush~~她有对象的时候,就相当于她加上锁了,按理来说,你就不能再追求她了,但是如果她分手了,又回归了单身状态,那就是锁解除啦,然后你就可以追求她了,你们在一起之后,就相当于你竞争到了这把锁,那其它人就不能追求你的crush啦,除非你俩又分手了,她又回归了单身状态,那么别人就可以又来竞争这把锁,此时"男朋友"这个身份就是那把锁~~~
加锁例子
我们来完善一下开头的例子,看下我们加上锁之后的结果
public class Demo21 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(() -> {for(int i = 0; i < 5000; i++) {synchronized (locker) { //共同竞争Locker这把锁count++;}}});Thread t2 = new Thread(() -> {for(int i = 0; i < 5000; i++) {synchronized (locker) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
可以看到,此时结果就是10000了
提示
当对同一个线程多次加同一把锁的时候,是无效的,只会算一把锁,因为锁有可重入性~~
解决线程安全问题
对于问题1)
这个问题是无法解决的,这是系统的底层逻辑
对于问题2)、 3)
想要解决问题2和问题3,就是要对操作加锁,详情请参看锁篇章~~
对于问题4)、5)
想要解决这两个问题,我们要引入另一个关键字 volatile
volatile可以强制关闭JVM的代码优化机制,确保每次循环都要重新从内存中读取数据,虽然这增加了开销,但可以增加代码的准确性~~
同样的例子,我们对flg加上volatile关键字
import java.util.Scanner;public class Demo22 {public static volatile int flg = 0; //加上volatilepublic static void main(String[] args) {Thread t1 = new Thread(() -> {while (flg == 0) {}System.out.println("t1线程结束");});Thread t2 = new Thread(() -> {System.out.println("请输入flg的值:");Scanner scan = new Scanner(System.in);flg = scan.nextInt();});t1.start();t2.start();Object locker = new Object();synchronized (locker) {}}}
运行则可以发现,t1此时就可以正常结束了~~
❤❤ 觉得博主写的有帮助的话,请点个赞 b( ̄▽ ̄)d ,谢谢~~~ ❤❤
❤❤ 你的喜欢是我更新的最大动力~~~ ❤❤