【多线程初阶】线程安全问题 死锁产生 何如避免死锁
线程安全
- 1.前言
- 2.线程安全产生的原因
- 3. 针对上述原因—解决线程安全
- 3.1引入—加锁操作
- 3.1.1synchronized关键字
- 3.1.2 synchronized修饰方法
- 3.2 死锁
- 3.2.1 可重入锁
- 3.2.2 两个线程,两把锁
- 3.2.3 N个线程M把锁
- 3.3 解决死锁问题
1.前言
首先,我们先来看以下代码,来了解什么是线程安全,为什么要解决线程安全问题。
private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 10000; i++) {count++;}System.out.println("t1线程结束");});Thread t2 = new Thread(() -> {for (int i = 0; i < 10000; i++) {count++;}System.out.println("t2线程结束");});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}
代码解释:
上述代码,创建了两个线程,两个for循环,都循环10000次来对count进行自增,按预期结果来说,最后,count的值应该是20000,但是执行之后的结果并非如此。

出现上述问题,就属于是线程不安全。
线程不安全:如果一个代码,在多线程并发执行的情况下,出现上述的Bug。即预期结果和实际的结果并不相符。
分析一下上述出现的Bug---CPU指令
count++;
这条指令:实际上执行3个cpu指令
1.load 把内存中的值(count变量)读取到cpu寄存器中。
2.add 把指定寄存器中的值,进行+1操作(还在寄存器中)
3. save 把寄存器的值,写回到内存中。

上述这种方式,相当于是串行执行,并不会有什么线程安全问题。
但是执行这三条指令的过程中,随时可能发生线程调度切换,对于线程调度,是随机的。
但是,如果这种交替进行执行的话,会出现线程不安全问题。

通过上述的分析,如果两个线程load到的数据都是0,那么就意味着一定会少加1次,也就是交替进行执行。
如果1个load到的数据是0,一个是1,结果才是正确的,即一个线程的load需要再另一个线程的save之后。
2.线程安全产生的原因
- 根本原因:操作系统对于线程的调度是随机的,抢占式执行。
- 多个线程同时修改同一个变量。
- 修改操作不是原子的。
原子:如果修改操作,只对应到1个CPU指令,认为是原子的。
CPU不会出现“一条指令执行一半”的情况
- 内存可见性问题,引起的线程不安全。
- 指令重排序。
3. 针对上述原因—解决线程安全
针对原因1:操作系统对于线程的调度是随机的,抢占式执行。
这是由于操作系统而导致的抢占式执行,我们是无法进行控制的。
针对原因二:
可以通过调整代码的顺序和结构,规避一些线程不安全。可以进行修改,但不是特别的通用。
private static int count = 0;// 将计数操作封装为同步静态方法private static synchronized void increment() {count++;}// 获取当前计数的同步方法private static synchronized int getCount() {return count;}// 定义线程任务private static class CountTask implements Runnable {private static final int iterations = 10000;@Overridepublic void run() {for (int i = 0; i < iterations; i++) {increment(); // 调用同步方法进行计数}System.out.println(Thread.currentThread().getName() + "线程结束");}}public static void main(String[] args) throws InterruptedException {// 创建任务实例CountTask task = new CountTask();// 创建并启动线程Thread t1 = new Thread(task, "t1");Thread t2 = new Thread(task, "t2");t1.start();t2.start();// 等待线程完成t1.join();t2.join();// 输出结果System.out.println("count = " + getCount());}
上述代码,也可以实现线程安全,当每次执行任务中的run方法时,都需要去调用count++ 封装的方法。这样,也是可以保证线程安全的。一般是不建议这样使用的。
针对原因三:
解决线程安全问题最主要的方案。
修改操作,不是原子的。
原子:就是如果修改操作,只对应到1个CPU指令,认为是原子的。
在上述的线程不安全的代码中,一个count++ 这条语句,1个CPU指令值执行了一半,没有完全执行完对应的一条指令。
在解决线程安全的时候,就需要让 修改操作不是原子的 打包成一个原子的操作。
3.1引入—加锁操作
在Java中,使用synchronized 关键字,来进行代码的加锁。
synchronized{//执行一些要保护的逻辑
}
- { 左大括号,进入代码,就意味加锁。
- { } 大括号中,执行一些保护的逻辑。
- } 出代码块,解锁。
所谓的,“加锁”,一旦加锁成功,其他人如果想要加锁,就要阻塞等待。“解锁”,解开。
3.1.1synchronized关键字
本意是“同步”,指的是互斥。
对于上述的线程不安全代码,我们就可以使用锁,把刚才的count++ 包裹起来,在count++ 之间,先加锁,然后在进行count++ ,
计算完毕在解锁。
Object locker = new Object();Thread t1 = new Thread(() -> {for (int i = 0; i < 100000; i++) {synchronized (locker) {count++;}}System.out.println("t1线程结束");});

图中,synchronized() 括号中 填写的是用来加锁的对象。(Java中,任何一个对象都可以用作“锁”)
两个线程 :
Object locker = new Object();Thread t1 = new Thread(() -> {for (int i = 0; i < 100000; i++) {synchronized (locker) {count++;}}System.out.println("t1线程结束");});Thread t2 = new Thread(() -> {for (int i = 0; i < 100000; i++) {synchronized (locker) {count++;}}System.out.println("t2线程结束");});t1.start();t2.start();t1.join();t2.join();
分析上述代码,创建了两个线程,t1 和t2 , 由于这两个线程针对同一个对象进行加锁,产生互斥的效果。 当执行 t1.start() 和 t2.start(); 使两个线程进行就绪状态,CPU根据调度随机选择线程执行,但是synchronized块的存在,同一时间只有一个线程能执行count++,所以当进行t1线程里的synchronized(locker),会获取到锁,另一个t2线程就会进行阻塞状态,直到锁被释放。确保了count++的原子性。
最关键的是:1.多个线程针对同一把锁,才会产生互斥的效果。 2.如果是不同的锁对象,此时,也不会产生互斥效果,线程安全问题也没有得到改善。
3.1.2 synchronized修饰方法

synchronized 修饰普通方法,相当于针对this进行加锁。
- 修饰static 方法,相当于针对类对象加锁。
3.2 死锁

- 可重入锁,只是针对一个线程,一把锁,连续加锁多次情况。
- 两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁。
- N个线程M把锁----哲学家就餐问题。
3.2.1 可重入锁
可重入锁,只是针对一个线程,一把锁,连续加锁多次情况。 不会出现自己把自己锁死的情况。
Thread t1 = new Thread(() -> {for (int i = 0; i < 100000; i++) {synchronized(locker){synchronized (locker) {count++;}}}System.out.println("t1线程结束");});
分析: 上述代码,第一次进行加锁,能够加锁成功的(因为刚开始锁是没有人使用的),第二次加锁,锁对象就已经对占用了,那么,就需要进行阻塞等待。
上述的问题,要想解决阻塞等待,需要往下执行才可以,想要往下执行,等到第一次锁被释放才可以,这个问题——死锁
为了解决死锁,Java就提供了可重入的概念。
当某个线程针对1个锁,加锁成功之后,后续该线程再次针对这个锁进行加锁,是不会触发阻塞,而是直接往下执行,如果其他线程尝试加锁,就会正常阻塞。

可重入锁的原理: 关键在于让锁对象,内部保存,当前是那个线程持有这把锁,后续有线程针对这个锁加锁的时候,对比一下,锁的持有者的线程是否和当前加锁的线程是同一个。
那么,问题一: Java是如何知道哪个 } 是真正的解锁呢 ?
首先,引入一个变量,计数器sum = 0 ,当触发{ 把计数器++ , 当遇到 } 时,就让 计数器–,直到变为0 ,就是真正的解锁。
问题二 : 我们如何自己实现一个可重入锁 ?
1.在锁的内部记录当前是哪个线程持有的锁,后续,每次加锁, 都和当前的锁进行对比。
2.通过计数器来记录当前加锁的次数,确保何时是真正的释放锁。
3.2.2 两个线程,两把锁
两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁。 互不相让 , 死锁。
Thread t1 = new Thread(() -> {for (int i = 0; i < 100000; i++) {synchronized (locker1) {try{Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2) {count++;}}});
上述代码,就明显体现了 死锁,当我拿到locker1这把锁,还没有释放,就又拿起了另一把锁locker2.
生活中很简单的例子,吃饭的时候,我们都会盛到碗里进行一碗一碗饭的吃,但是,你还没有吃完碗里的饭,又到锅里进行吃了。 “吃着碗里的,看着锅里的。”
产生这种现象的最主要原因:有循环,加锁的过程中,使用了嵌套。
3.2.3 N个线程M把锁
典型的例子:哲学家就餐问题
5 位哲学家围坐在圆桌旁,每人面前有一碗面条,每两人之间有一根筷子。哲学家只做两件事:思考和吃面。要吃面就需要拿起左右两根筷子,但如果每位哲学家同时拿起左手边的筷子,就会导致所有人都等待右手边的筷子,造成死锁。
5个哲学家:5个线程
5根筷子: 5把锁
死锁会造成一些不必要的麻烦,那么如何去避免代码出现死锁呢?
3.3 解决死锁问题
构成死锁的4个必要条件:
-
锁是互斥的,一个线程拿到1把锁之后,另一个线程在尝试获取锁,必须要阻塞等待。
-
锁是不可抢占的,即不可剥夺。
-
请求和保持: 一个线程拿到一把锁之后,不释放锁1的情况下,在获取锁2.
解决: 加锁的过程中,不去嵌套。

-
循环等待: 多个线程,多把锁之间的等待,构成了“循环”。
B线程等待A,C也等待A,D也等待A。
解决: 约定好加锁的顺序。
哲学家就餐问题而言:
1.我们先约定序号小的锁,后获取序号大的锁。

2.先从t1-t5先从序号小的进行拿起,

- 当t5吃完面条,可以先离开了,依次从t4–t1依次类推,就可以完成整个过程了。
