【JavaEE】多线程之线程安全(synchronized篇),死锁问题
目录
线程安全问题
观察线程不安全
线程安全问题的原因
从原子性入手解决线程安全问题 ——synchronized
synchronized的使用方法
synchronized的互斥性和可重入性
死锁
死锁的三个典型情况
死锁的四个必要条件
破除死锁
线程安全问题
在前面的章节中,我们也了解到多线程为我们的程序带来了更高效的运行。但与此同时,多线程也是会带来风险的——线程安全问题。
造成线程不安全的罪魁祸首也就是多线程的抢占式执行,带来的随机性。
在以单线程的形式运行的时候,代码执行的顺序是固定的,程序的结果也就是固定的。
在以多线程的形式运行的时候,此时便是多个线程之间的抢占式执行,代码的执行顺序可能性也就从一种变成无数种情况。所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的。只要有一种情况下的代码运行结果不正确,就是有bug,也就是线程不安全。
观察线程不安全
假设现在要对 counter 类中的 count 变量自增十万次,然后以两个线程来分别自增五万次,代码如下。
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadDemo12 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter.count);
}
}
从运行结果看出,输出结果与我们预计的结果不相符合,所以,这就是一个线程不安全的表现,一个典型的线程安全问题。下面我们分析一下为什么会出现这样的线程安全问题
对于 count++ 操作,在CPU上执行的时候,会有三条指令,对于指令,我们可以理解为是机器语言。(一个线程要执行,是会编译成很多的CPU指令的)
1. 先把内存中的值,读取到 CPU 的寄存器中。 称为 load 操作。
2. 把 CPU 寄存器里的值进行 +1 运算。 称为 add 操作。
3. 把得到的结果写入内存中。 称为 save 操作。
当程序是两个线程并发执行 count++ 的时候,也就是两组 load add save 在执行,此时不同的线程调度顺序就会产生一些结果上的差异。
(CPU中有个重要的组成部分:寄存器,寄存器可以存数据,空间更小,访问速度更快,CPU进行的运算都是针对寄存器的数据进行的;寄存器也有很多种,功能也各不相同,有参与运算的,也有保存上下文的等)
在图中定义一个时间轴,越往下就是越晚执行,由于线程之间是随机调度的,所以调度的顺序会有很多种可能性。
能得出正确结果的调度顺序
我们先列举出结果正确的情况,也就是如果两个线程之间是这样调度的,那就可以获得我们所期望的结果。
从上图可以看出,t1 线程和 t2 线程之间是有先后顺序的,要么 t1 线程先全部执行完,后执行 t2 线程,要么 t2 线程全部先执行完,后执行 t1 线程。如果我们的程序都是以这种方式去执行的话,那我们得到的结果也就是正常的,因为如果是以这种方式执行的时候,首先 t1 线程把内存中的值读取到CPU(load),然后在寄存器里进行 +1 运算(add),再接着把得到的结果再写入内存中(save),此时内存中就是修改后的值了,也就是1,然后 t2 线程再进行相同的操作,所得的结果就是2了,以此类推每个线程进行 5w 次。这样来看,所得的结果就是正常的。
无法得到正确结果的调度顺序
但是由于线程质之间是随机调度的,实际调度的顺序可不止这两种,还会有很多种可能性,可以认为是无穷种,简单列举出六种:
而这些由于线程随机调度出现的其他各种情况,都是可能导致线程安全问题的,例如这个调度顺序:
当按照这个调度顺序去执行代码的时候:
第一步:首先 t2 读取内存中的值(count = 0)到 寄存器2 中去;
第二步:t1 也读取内存中的值(count = 0)到 寄存器1 中去;
第三步:t2 在寄存器中对 count 进行 +1 运算,此时 寄存器2 中存的值为 count = 1;
第四步:t2 把寄存器中的值(count = 1)写入内存中,此时内存中 count 从 0 变为 1 了;
第五步:t1 在寄存器中对 count 进行 +1 运算,此时寄存器1 中存的值为 count = 1;
第六步:t1 把寄存器中的值(count = 1)写入内存中,此时内存中 count 从 1 变为 1;
由此我们可以看出,每个线程自增一次,按照我们的需求来说,现在的 count 应该为 2 才对,可是结果却是1,与我们预期结果就不符合了。所以两个线程,一个线程自增 5w 次,其中就会发生很多类似于这种覆盖结果的情况,所以这也就导致了两个线程各自自增 5w 次后得到结果却与我们的预期有所相差,这也就是线程安全问题了。
因此在这个代码中,两个线程之间有无数种调度方式,有且仅有两种调度方式是可以得到正确结果,所以说概率也是极小的。
而出现这种线程安全问题,本质上就是因为线程的抢占式执行,导致某个线程执行到任意一个指令的时候,线程都是有可能被调度走的,让该 CPU 来给别的线程进行执行。
线程安全问题的原因
1. 根本原因:线程之间的抢占式执行,随机调度。
2. 代码结构:多个线程同时修改同一个变量。从上述的线程不安全代码中就可以看出,进行的是两个线程同时修改 count ,就导致了线程不安全。而一个线程修改一个变量,多个线程读取同一个变量,多个线程修改多个不同的变量,就是线程安全的。
3. 原子性:原子性指的是不可以再拆分的基本单位,如果修改操作不是原子性,那么出问题的概率就非常高;例如上述代码的 count++ 可以拆分成 load add save 三个步骤,所以就不是原子性,就会容易出现线程安全问题。
而解决线程安全问题,最主要就是从原子性入手,把这个非原子的操作,变成原子的。
4. 内存可见性问题:当一个线程读,一个线程改,也会出现线程安全问题。(结合后续实例进行分析)
5. 指令重排序:本质上就是因为编译器优化出 bug ,编译器在保持代码逻辑不变的情况下,进行调整,从而加快程序的执行效率。但是调整就会导致代码的执行顺序改变了,线程之间的随机调度就会可能导致结果不一样,造成线程安全问题。(结合后续案例分析)
以上是造成线程安全问题的典型原因,但并不是全部,一个代码是线程安全还是不安全,需要具体问题具体分析。
从原子性入手解决线程安全问题 ——synchronized
从前面的事例中,两个线程同时对一个变量进行自增,结果无数种调度方式中,只有两种是正确的。而这两种方式,在每一个线程执行的时候,都是保证了其 load add save 作为一个整体执行,所以结果是正常的。而实际上,在无数种调度方式中,大多数是无法保证 load add save 作为一个整体去执行,往往会出现线程1在执行的时候,线程2也去执行,就会出现结果覆盖的现象,从而结果与预期并不符合。因此,也可以知道,count++这个操作并不是原子的。
那么解决这个线程安全问题,我们就需要通过 "加锁" 把这个不是原子的,转成 "原子" 的。从而变为另一种情况:当一个线程在进行执行的时候,另一个线程是无法进行执行的。
使用 synchronized 关键字来进行加锁。
当我们对方法进行加锁的时候, 所得结果也就变为正确结果了。这正是因为 synchronized 使count++ 操作从 非原子性 变为 原子性 ,就保证了在执行 load add save 的过程是完整的,不会有别的线程来干扰。
在程序运行过程中,进入方法就会进行加锁,出了方法就会进行解锁。如果两个线程同时尝试加锁,此时只有一个线程能获取到锁,另一个线程就进入阻塞等待,也就是进入 BLOCKED 状态。一直阻塞到加锁成功的线程释放锁,当前线程才能进行加锁。
还是针对刚刚的案例进行图解析,添加一个操作 lock 表示加锁,unlock 表示解锁。
从上图可以看出,t1 的加锁操作让 t2 想要进行执行时进入了阻塞等待,一直等到 t1 执行结束才开始执行,也就保证了 count++ 操作的原子性。
这里的加锁,保证了原子性,其实并不是让三个操作(load add save)一次完成,也不是在这三步操作过程中不进行调度,而是让其他也想操作的线程阻塞等待了,所以说,加锁本质上是把并行(可以同时执行)变为串行(一个一个执行)。
加锁后,保证了执行的准确性,但是可想而知,执行的速度也就会大打折扣。
加锁,是对对象进行加锁,如果两个线程针对同一个对象进行加锁,就会产生阻塞等待(锁等待 / 锁竞争);如果两个线程针对不同对象加锁,就不会阻塞等待(锁等待 / 锁竞争)
synchronized的使用方法
1. 修饰方法:进入方法就加锁,离开方法就解锁。
(这里虽然synchronized修饰的是方法,但是锁不是加到方法上的,而是加到对象上的)
1.1 修饰普通方法:锁对象为 this
1.2 修饰静态代码块:锁对象为 类对象
2.修饰代码块:手动指定锁对象
1.修饰方法
还是上面的案例:
t1 执行 add,就加上锁了,针对 counter 这个对象就上锁了,t2 执行 add 的时候,也尝试对 counter 进行加锁,但是由于 counter 已经被 t1 给占用了,因此这里的加锁操作就会阻塞。但并不代表这个对象不能用了,这个对象的其他方法和属性还是可以正常使用的。
2. 修饰代码块
this 也可以换成任意想加锁的对象。一样的道理,进入代码块就加锁,出了代码块就解锁。
总之,加锁要明确锁对象,针对哪个对象进行加锁。
而关于锁对象的规则,也很简单:
1. 如果两个线程针对同一个对象进行加锁,就会出现锁竞争 / 锁冲突,一个线程能够获取到锁(先到先得),另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功。
2. 如果两个线程针对不同对象加锁,此时不会发生锁竞争 / 锁冲突,这两个线程都能获取到各自的锁,不会有阻塞等待。
synchronized的互斥性和可重入性
synchronized的互斥性也就可以理解为上述讲到的 "阻塞等待" 相关内容 ,一个线程上了锁,其他线程只能等待这个线程把锁释放了。
synchronized 是可重入的。
一个线程针对同一个对象连续加锁两次,如果出现问题了,就是不可重入的,如果没有问题,就是可重入的。
在上述代码中,锁对象为 this ,只要有线程调用 add ,进入 add 方法的时候,就会先加锁,紧接着遇到代码块,再次尝试加锁,但是由于第一个尝试加锁和第二个尝试加锁的线程是同一个线程,所以可以正常运行,运行结果也是正常的。
但如果不能正常运行,这时候就是出现了死锁状态了,也就说明锁是不可重入的。虽然这种情况在Java 中 synchronized 是可重入的,不会出现死锁状态,但C++,Python 以及 操作系统原生的锁,都是不可重入的,也就会产生死锁现象,下面就来认识一下死锁的相关内容。
死锁
死锁的出现,会导致线程无法继续执行后续的工作,程序便会出现严重的bug。
死锁的三个典型情况
1. 一个线程,连续对同一个对象,加锁两次,如果锁是不可重入锁,就会进入死锁。
2. 两个线程两把锁,线程1 和 线程2 各自针对A 和B 进行加锁,然后再尝试获取对方的锁。
这时候就会进入死锁状态了。
可以举个例子:小王(线程1)和小林(线程2)去饺子馆吃饺子,他们都喜欢蘸酱油和醋,这时候小王拿到了酱油(对A对象加锁),小林拿到了醋(对B对象加锁),然后小王说:你先把醋给我,小林说:你先把酱油给我,这时候两人互不相让,小王在等小林把醋给他,小林在等小王把酱油给他,这时候就僵住了。也就相当于进入了死锁。
public class ThreadDemo13 {
public static void main(String[] args) {
Object jiangyou = new Object();
Object cu = new Object();
Thread t1 = new Thread(()->{
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cu) {
System.out.println("小王把酱油和醋都拿到了");
}
}
});
Thread t2 = new Thread(()->{
synchronized (cu) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (jiangyou) {
System.out.println("小林把醋和酱油都拿到了");
}
}
});
t1.start();
t2.start();
}
}
通过代码演示,我们也可以看出这种情况的输出日志为空,说明没有线程拿到两把锁,也就是进入死锁了。
也可以通过 jconsole 来观察线程的状态:
可以看出,两个线程的状态都是 BLOCKED ,表示获取锁获取不到的阻塞状态;
再通过 jconsole 中的判断死锁操作可以看出,两个线程确实是进入了死锁状态;
3. 多个线程,多把锁。以案例来进行分析:
有五个哲学家进行就餐,吃一碗面条,面条放在桌子中央,桌面上只有五根筷子,每一位哲学家进行就餐就需要拿起左右两根筷子。
每位哲学家有两种状态:1.思考人生(相当于线程的阻塞状态)2.拿起筷子吃面条(相当于线程获取到锁然后进行执行),且要同时拿起左右两根筷子。由于操作系统的随机调度,这五位哲学家,随时都可能想吃面条,或者思考人生。
这个时候,出现了一种情况,同一时刻,所有哲学家都想吃面条了,且此时五位哲学家都同时拿起来左手的筷子。这就导致所有哲学家都拿不起右手的筷子,都在等待右边的哲学家放下,这时候也就再次僵住了,也就相当于进入了死锁状态。
因此,这个案例,我们也可以将五位哲学家认为是五个线程,五根筷子认为是五把锁。在某一时刻,线程1 获取锁1,线程2 获取 锁2,线程3 获取锁3,线程4 获取锁4,线程5 获取锁5,然后线程1 因为要执行某个任务需要获取锁2,但锁2 又被线程2 所占有,线程2 因为要执行某个任务需要获取锁3,但锁3 又被线程3 所占有,线程3 因为要执行某个任务需要获取锁4,但锁4 又被线程4 所占有,线程4 因为要执行某个任务需要获取锁5,但锁5 又被线程5 所占有,线程5 因为要执行某个任务需要获取锁1,但锁1 又被线程1 所占有。这样每个线程都进入了阻塞等待状态,这就进入了死锁状态了。
死锁的四个必要条件
通过上述三个典型的死锁情况,可以总结出出现死锁的四个必要条件。
1. 互斥使用:线程1 拿到锁,线程2 想拿,就需要等待。
2. 不可抢占:线程1 拿到锁之后,必须是线程1 主动释放,不能说是线程2 把锁强行获取到。
3. 请求和保持:线程1 拿到锁A之后,再尝试获取锁B,A这把锁还是保持着的,不会因为正在获取锁B 就把锁A 释放了。
4. 循环等待:线程1 尝试获取到锁A 和锁B ,线程2 尝试获取到锁B 和 锁A ,线程1 在获取B 的时候等待线程2 释放B,同时线程2 在获取锁A 的时候等待线程1 释放锁A;
实际上前三个条件,对于 synchronized 这把锁来说,是固定的规则。
第四个条件,循环等待,也是唯一一个与代码结构相关的。因此对于避免死锁,也正是要从这个出发点来解决。
破除死锁
解决死锁,也就是要从循环等待这个突破口出发。
给锁编号,指定一个固定的顺序来加锁,任意线程加多把锁的时候,都让线程遵循这个顺序,此时循环等待也就破除了。
针对第二个死锁典型情况,破除死锁的办法就是给定一个顺序来进行加锁:小王和小林都应该先拿酱油再拿醋。这样死锁问题也就迎刃而解了。
//如果线程1拿到A锁,线程2拿到B锁,然后线程1又想获得B锁,线程2又想获得A锁,这样就造成了死锁,但是如果调节好顺序,也就消除了死锁的隐患了
//比如两个线程都先拿到A锁,然后再去拿B锁 核心问题就是破除循环等待
public class ThreadDemo13 {
public static void main(String[] args) {
Object jiangyou = new Object();
Object cu = new Object();
Thread t1 = new Thread(()->{
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cu) {
System.out.println("小王把酱油和醋都拿到了");
}
}
});
Thread t2 = new Thread(()->{
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cu) {
System.out.println("小林把醋和酱油都拿到了");
}
}
});
t1.start();
t2.start();
}
}
针对第三个死锁典型情况,也是一样的道理,指定固定顺序来进行加锁:每一位哲学家都先拿起左右两边编号小的那把筷子,再拿起编号大的筷子,类比于线程和锁那就是:
线程1 先获取到锁1,线程2 先获取到锁2 ,线程3 先获取到锁3,线程4 先获取到锁4,由于线程5 正在等待锁1 的释放,也就是进入了阻塞等待,就暂时没有获取到锁,(因为指定了固定顺序:先拿编号小的筷子,再拿编号大的筷子)所以这个时候线程4 也就能获取到锁5 。
紧接着线程4 两个锁都获取到了,执行完后释放锁4 和锁5,线程3 也就可以获取到锁4,线程3执行完后,释放锁4 和锁3,线程2 也就可以获取到锁3,线程2执行完后,释放锁3 和锁2,线程1也就可以获取到锁2,线程1执行完后,释放锁2 和锁1,这时候线程5 获取到锁1 了,再获取到锁 5,然后进行执行。从而执行完成,解决死锁问题!