深入理解Java并发:线程安全问题的根源与解决方案
关于套路安线程安全问题这方面
首先我们要认识,什么是线程安全问题?
线程安全问题:一段因多线程并发执行而产生bug的代码,就是线程安全问题或是线程不安全
接下来,我们来细究bug产生的原因和解决方案
线程不安全的根源:
1.根本原因:操作系统对于线程的调度采用的是抢占式执行,使得线程的调度是随机的
2.多个线程同时修改一个变量
3.修改操作是非原子的
4.内存可见性问题引起的线程不安全
5.死锁
6.指令重排序引起的线程不安全
解决方案:
一、对于根本原因,操作系统对线程的随机调度是随机的,这点我们无法干预,所以针对这点我们无法解决,但我们可以通过其他原因的解决方案来解决bug
二、多个线程同时修改一个变量且修改操作是非原子的:
以以下线程不安全代码为例:
public class DemoCount {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i = 0;i<50000;i++){
count++;
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i<50000;i++){
count++;
}
});
t1.start();
t2.start();
Thread.sleep(1000);
System.out.println(count);
}
}
t1 t2两个线程同时对count进行多次++,我们发现打印的结果基本不可能出现我们想要的100000
究其原因:
1.我们要先了解count++的原理:
【load】CPU从内存上读取现最新count的值,读到寄存器上
【add】CPU在寄存器上完成+1的操作
【save】将寄存器上的新count值写回内存中
通过了解原理,我们发现count++这一操作并非原子的,这就会使得抢占式执行产生bug
例如这样:

假设此时count=0,t1线程读到count为0,读到寄存器上,t2线程也读到count为0,读到寄存器上
在t2线程完整完成两次++操作后,内存中count此时为2,但t1线程一开始读的count为0,此时t1先add后save,使得内存中的新count变成了从0++得到 1 ❌️❌️❌️
2.我们该如何解决?
(1)将并发式执行改成串行式执行(⚠️效率低不推荐)
我们让t2线程老老实实的等t1线程执行完毕后再执行,main线程的打印操作也老老实实等t2线程执行完毕后再打印,这样就能得到100000了,但串行式的执行显然会让效率大打折扣
(2)使用synchronized关键字(✅️加锁操作)
相关语法:
synchronized(锁对象){
代码....
}
对于锁对象,没有什么要求,任何类型的对象都可以,只要有两个锁都用到了同一个对象,这两个操作就会竞争这个锁,因为我们要获得锁对象才能执行里面的代码操作
加锁可以将count++的三步操作捆绑起来,使得操作变得看起来是原子的
代码实例:
public class DemoCount {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Thread t1 = new Thread(()->{
for(int i = 0;i<50000;i++){
synchronized (locker1) {
count++;
}
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i<50000;i++){
synchronized (locker1) {
count++;
}
}
});
t1.start();
t2.start();
Thread.sleep(1000);
System.out.println(count);
}
}
线程t1中的每一次count++中的三步操作都被捆绑起来了,想要执行就必须获得锁对象,而t2线程想要执行count++也需要锁对象,二者会竞争这个锁,执行到count++下一行的 } 就会开锁,此时二者再次竞争这个锁对象
通过上述加锁的操作,我们就能得到正确的100000了✅️✅️✅️
三、内存可见性问题
关于这个问题的研究,我们需要了解JMM:
JMM 全称是 Java Memory Model
关于Java官方文档术语:
每个线程都有一个自己的“工作内存(其实指的是CPU的寄存器)”,同时这些线程共享同一个主内存,当一个线程循环进行上述读取操作的时候,就会被主内存中的数据拷贝到该线程的“工作内存”(寄存器)上,后续别的线程修改,也是修改自己的工作内存,拷贝到主内存中,由于第一个线程任然在读自己的工作内存,因此感知不到主内存的变化
看这些专业术语,肯定也是不是很懂,接下来我们用通俗易懂的代码例子来讲讲!
代码实例:
public class Demo30 {
private static int flg = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flg==0){
}
System.out.println("t1结束");
});
t1.start();
Scanner scan = new Scanner(System.in);
scan.next();
flg=1;
}
}
观察代码,按正常来说,t1线程和main线程同时进行,如果主线程完成了 scan.next() 这行代码(也就是随便输入点什么),接下来就会执行到flg=1,此时t1线程内的循环结束,执行打印“t1结束”,可我们运行发现,t1线程的循环并未结束,也就是说,flg还是0!
首先我们要了解CPU是怎么读flg的:
1.CPU将内存的中的flg的值读取到寄存器上
2.将寄存器上的flg的值与我们要求的是否为0进行比较,进行判断
明明我们已经修改了flg的值想结束循环,为什么flg还是被当做0读呢?
内存可见性问题:
要知道计算机的执行速度超级超级快,当我们用户想着随便输入一个数,接下来执行flg=1来结束t1的循环时,t1线程中的循环可能已经执行上万次了!这时候CPU就在想,我老是读读读读一个0读到寄存器上,读了上万次0了都,一直都是这个0,要不我以后就只读寄存器上的flg=0吧!
此时,当用户来修改flg的值时,CPU还在读flg=0!这就是内存可见性问题
volatile关键字:
Java提供了volatile关键字,去修饰像flg这样的变量,使得flg不会再出现只读寄存器上的0的情况了
✅️✅️✅️解决方案:
private static volatile int flg = 0;
四、死锁
从底层原理来看,只要同时满足以下四个关于锁和资源的条件,死锁就必然会发生:
- 互斥条件(锁的本质): 资源(比如一把锁)一次只能被一个线程持有。线程A拿了,线程B就必须等。
- 请求与保持条件(占着茅坑不拉屎): 线程A已经持有了锁A,但它不释放,同时还想去申请锁B。
- 不可剥夺条件(强买强卖不行): 线程A持有的锁A,其他线程不能强行抢走,只能等线程A自己主动释放。
- 循环等待条件(完美的闭环): 线程A在等线程B的锁,线程B又在等线程A的锁,形成了一个首尾相连的等待环
接下来我们简单通过代码举个通俗易懂的例子来说说死锁和解决方法:
public class DieLocker {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("t1线程结束");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
synchronized (locker1){
System.out.println("t2线程结束");
}
}
});
t1.start();
t2.start();
}
}
⚠️⚠️t1线程的sleep一秒原因:
如果没有这个sleep:
计算机的执行速度非常之快,快点在t2还未start的时候,t1就已经做到了:
拿到锁1 -> 拿到锁2 -> 执行打印 -> 解放锁2 -> 解放锁1
在t2start()执行时,t2线程也顺利的:
拿到锁2 -> 拿到锁1 -> 执行打印 -> 解放锁1 -> 解放锁2
⚠️⚠️上述情况不是死锁,两线程顺利执行,我们sleep这一秒是为了观察死锁的现象
回到代码,我来说个大白话:
结束有同学AB,锁1和锁2是超级无敌好玩的玩具
同学A 抱着锁1不放的同时,还想抢同学B 抱着不放的锁2,但抢不到
同学B 抱着锁2不放的同时,还想抢同学A 抱着不放的锁1,但也抢不到
二者此时形成了一个僵持的局势,谁都不肯放手,谁都想抢对方的锁,这样的情况,就是死锁
死锁的解决方案:
方案1:
这里我们希望同学AB都别这么自私,请先放下自己怀里抱着不放的锁,再去拿对方的锁,在代码层面来讲,就是不要把多次拿锁的操作写成嵌套的形式
代码实例:
Thread t1 = new Thread(()->{
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (locker2){
System.out.println("t1线程结束");
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
}
synchronized (locker1){
System.out.println("t2线程结束");
}
});
同学A放下了自己抱着的锁1,去拿锁2
同学B放下了自己抱着的锁2,去拿锁1
方案二:
同学B懂礼貌,让同学A先玩完A再玩玩B,等A玩腻了不玩了,自己再去玩
代码实例:
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("t1线程结束");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
synchronized (locker2){
System.out.println("t2线程结束");
}
}
});
下面的指令重排序问题,我会通过后续博客——多线程下的单例模式的安全隐患里 提出-研究-解决
更多推荐



所有评论(0)