前言
本篇通过了解线程不安全产生的原因,解决线程不安全的方式,线程不安全的情况例如线程抢占式执行,多线程操作统一变量,非原子性修改,内存可见性问题与指令重排序问题,如有错误,请在评论区指正,让我们一起交流,共同进步!
本文开始
1. 多线程执行产生的线程不安全问题
问题产生:
在多线程情况下,线程的无序调度,会产出bug, 称之为线程不安全问题;
原因产生:
通过下面例子认识一下 !
两个线程通过调用同一个类同一个方法add, 一起计算变量count的值,每个线程调用方法一次,使变量count自增一次,每个线程都调用方法add 10000 次,最后想要得到结果是20000;但是结果确与我们想的不一样,通过代码来看一下吧!
两个线程调用同一个方法代码实现(有线程安全问题):
class Sum{
private int count = 0;
public void add() {
count++;
}
public int getCount(){
return count;
}
}
public class ThreadDemo2 {
public static void main(String[] args) throws InterruptedException {
Sum s = new Sum();
Thread t1 = new Thread( () -> {
for (int i = 0; i < 10000; i++) {
s.add();
}
});
Thread t2 = new Thread( () -> {
for (int i = 0; i < 10000; i++) {
s.add();
}
});
//启动线程
t1.start();
t2.start();
//线程等待
t1.join();
t2.join();
//获取计算结果
System.out.println(s.getCount());
}
}
结果如下:
上述结果只是一次执行的结果,可以多次执行,通过执行结果可以发现,每次执行的结果都会小于20000,这就是线程安全产生的问题;
为什么会产生上述结果的原因?
实际在执行调用add方法时,进行的自增++操作,在寄存器上是分三部执行的load,add,save, 这三种执行的顺序是无法确定的,所以可能产生寄存器增加一次,或者多次,但最后结果只显示增加1了次,产生的计算结果小于20000的这种情况;
【注】寄存器进行++操作本质:
load: 把内存数据读取到cpu寄存器中
add: 把寄存器中的值,进行+1 操作 =》增长1
save: 再把寄存器中的值写会到内存中
只是语言描述可能有点抽象,通过画图来进一步理解一下!!!
寄存器完整的自增操作
上述是严格按照寄存器1先执行三部操作,寄存器2再执行三部操作,得到的结果才是2,如果三部操作前后执行顺序有交叉部分,可能就出现线程安全情况,从下图了解情况;
由上述图可得,自增两次,结果确只有一次,一次结果被覆盖的情况,这只是其中一种情况,中间可能有两次,甚至可多次自增情况被覆盖,造成虽然自增了很多次,但结果只有少数次. 这是因为多线程的调度是无序的,所以这三步的指令执行顺序也是不确定,这就产生了bug,导致结果会小于20000;
由此,我们再认识一下常见的线程不安全的原因!
2. 线程安全
为什么会出现线程安全?
在多线程情况下,线程的无序调度会造成线程安全 又称 线程抢占式执行
出现线程安全的原因:
① 线程的抢占式执行 <=> 根本原因
多个线程执行操作,不能确定操作的执行的顺序,这就是现线程的抢占式执行;
② 多个线程修改同一个变量
计算一个数字,定义一个变量,count计算,使用两个线程或多个线程对这个count变量进行++操作,由于++操作分为三部load, add, save这三部分执行的顺序不能确定,结果就可能产生某一次或两次操作被覆盖的情况,导致最后的结果是小于20000的;这就产生了bug;
完整代码参考最开始代码
③ 线程的修改,不是原子性的
什么是原子性?
原子:不能分割的最小单位,将一些操作看成整体,不能分开;
例如:++操作,它对应的CPU指令可以看作三部分,load,add,save, 如果分开执行就认为不是原子的了;必须将这3部分看作一个整体,再执行就可以看作是一个原子操作;
问题又来了,怎么给线程变成原子性呢? =》加锁
认识锁的两个操作:
① 加锁:当线程加锁后,其他线程必须等待此线程执行结束
② 解锁:线程解锁后,其他线程才能继续竞争这个锁;
加锁操作需要使用关键字:synchronized;
使用关键字修饰代码块,将线程中需要加锁的代码,都放入代码块中,这就实现了原子性;
加锁操作的目的:
加锁,就是让两个线程的部分代码串行化,大部分代码是并发的;
这就要与join区分一下了,join 让两个线程完整的进行串行化,而不是部分;
部分串行化例如:上述代码两个线程,调用一个方法add, add之前会创建循环变量i, 循环条件的判断, 调add之后count会++,给count加锁后,count之前的操作认为是并发的,线程1调用count执行完后,线程2再调用count这是串行化,count后面的执行返回,变量i++等操作也是并发的;
对上述2代码进行修改,对count进行加锁操作:最后得到的count结果就是20000
class Sum{
private int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
public int getCount(){
return count;
}
}
不同的加锁方式:
加锁操作中()括号:里面是加锁对象,不能是基本数据类型;
静态类加锁()括号中是类对象,如上图;
【注】类对象:表示.class文件的内容(方法,属性等)
④ 内存可见性问题
通过一段代码,发现内存可见性问题:
public static int flag = 0;//控制循环条件
public static void main(String[] args) {
Thread t = new Thread( () -> {
while (flag == 0) {
}
System.out.println("循环结束!");
});
Thread t2 = new Thread( () -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
flag = scanner.nextInt();
});
t.start();
t2.start();
}
上述代码本来是,t线程执行循环为死循环,等t2线程执行输入操作更改flag的值,这样flag!=0, 条件为假循环结束;但是循环却没有结束,这个原因其实就是内存可见性问题;
判断while( falg == 0) 这个条件指令有两步
1.load: 从 内存中读取数据到cup寄存器上
2.cmp:比较寄存器中的值是否是0
【注】读取速度:寄存器 > 内存 > 硬盘
内存可见性问题的产生:
根据读取速度,发现load读取内存开销较大,因为是死循环,没有修改load的时候结果都是一样的,编译器就会做出优化操作,优化掉load; 这样只有第一次执行load, 后面操作只执行cmp比较操作;
这样就是t2线程更改了flag的值,但是寄存器不再读取,只用修改前的值,操作就会死循环,发生线程安全问题;
内存可见性: 在多线程环境下,编译器对代码进行优化,产生了误判(像上述代码认为flag的值没改),从而引起了Bug, 导致代码出错;
【注】编译器优化:智能调整代码执行逻辑,在保证程序结果不变的前提下,通过加减语句,语句变换,等一系列操作,让代码执行效率提升;
处理内存可见性问题:
使用volatile关键字:被volatile修饰的变量,编译器会禁止代码优化,从而保证每次都是从内存中重读取数据;
代码修改:
volatile public static int flag = 0;//控制循环条件
//volatile: 保证内存可见性
【注】volatile : 1.不保证原子性,适用场景一个线程读,一个线程写的情况;synchronized: 多个线程写;
2.volatile 禁止指令重排序
⑤ 指令重排序问题
问题:由于一些代码操作的执行的顺序不同,可能会产生bug;
指令重排序: 编译器优化,保证整体逻辑不变,调整代码的执行顺序,让程序更高效;
例如new 对象操作,认为分为3步:
1.先申请内存空间
2.调用构造方法(初始化内存数据)
3.对象的引用赋值(内存地址的赋值)
线程1先执行1,3操作,2操作执行顺序并不确定,在此期间其他线程使用对象,调用其方法属性,虽然对象不为空,但是没有初始化,就可能会产生bug;
解决方式:给代码加volatile,创建的就会禁止指令重排序;
总结
✨✨✨各位读友,本篇分享到内容如果对你有帮助给个👍赞鼓励一下吧!!
感谢每一位一起走到这的伙伴,我们可以一起交流进步!!!一起加油吧!!!