线程安全问题(二)——死锁
死锁
- 前言
- 可重入锁
- 逻辑
- 两个线程两把锁(死锁)
- 死锁的特点
- 多个线程多把锁(哲学家就餐问题)
- 总结
前言
在前面的文章中,介绍了锁的基本使用方式——锁
在上一篇文章中,通过synchronized关键字进行加锁操作,使得【多线程修改同一变量】的情况可以得到解决。
那么在本文中,将会继续讲解锁的相关知识点。
可重入锁
我们可以通过synchronized确定锁对象,对线程进行加锁的操作。那么如果锁对象重复使用是否会出现不一样的结果?
在下面的案例中,t1和t2线程使用了连续synchronized,设置的加锁对象同样是counter,如果运行这段代码,结果却是正确的。
理论上,当synchronized(counter)开始使用时,只有执行完其中的代码(大括号中的代码块)才会释放锁。而这个锁中又嵌套了相同的锁,按道理来说此时counter锁对象还没有被释放,应该出现阻塞等待状态最终代码无法运行才是。
那么为什么在Java中这段代码可以编译通过?
原因: 在Java中,已经对synchronized内部进行了特殊处理。在每个锁对象中,会记录当前是哪个线程持有这把锁,接下来,当针对这个对象进行加锁操作的时候就会进行判定,判定当前尝试加锁线程是否是这一对象锁的线程。 通过这样的操作,系统就可以知道如果不是,就会阻塞;如果是,就会直接放行。
class Counter { public static int count; void add(){ count++; } public int getCount(){ return count; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(()-> { for (int i = 0; i { for (int i = 0; i
逻辑
当加了多层锁的时候,代码如何知道执行到哪里要真正进行解锁。如果有若干层加锁操作,如何判定当前遇到的}是最外层的} ?
以我的理解,进行加锁操作时在内部给锁对象设计了一个计数器(int n)。 每次遇到【{ 】时,n++,遇到【 } 】时,n–,当n=0时才真正解锁。
通过这样的方式,针对同一线程中的同一锁对象进行的锁操作,可以让程序猿避免了死锁的情况,我们也称之为可重入锁。
两个线程两把锁(死锁)
现在存在线程t1和线程t2;锁对象A和B。
当进行锁操作的时候,可能会出现这样的情况:线程1和线程2都需要使用到锁A和锁B,对于线程A来说,首先获取锁A然后获取锁B;而对于B来说,首先获取锁B再获取锁A。
让两个线程同时获得第一把锁,接下来需要尝试去获取对方的锁。
在下面的代码启动后,线程t1获取到了锁locker1,线程t2获取到了锁locker2. 对于t1,接下来需要获取locker2才能继续执行接下来的代码 对于t2,需要获取locker1才能继续执行接下来的代码
两个线程之间无法让步,于是一同进入了阻塞等待状态,都在等待对方释放锁。
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("获取到了2把锁"); } } }); Thread t2 = new Thread(()-> { synchronized (locker2){ try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker1){ System.out.println("获取到了2把锁"); } } }); t1.start(); t2.start(); }
在执行这段代码后,我们可以通过jconsole查看线程状态。如下图所示,我们可以知道两个线程都处于阻塞状态,同时我们也可以知道阻塞所需要获取的锁目前在哪个线程身上。
死锁的特点
1.锁具有互斥性。这是锁的基本特点,当一个线程拿到锁A时,其他线程只能等待该线程释放。
2.锁不可抢占。只有该线程主动释放锁,别的线程无法抢占。
3.请求和保持。线程拿到锁以后可以继续尝试获取其他锁。
4.循环等待。多个线程多个锁的状态中,出现了A等待B,B等待A的情况。
当全部满足这些条件以后,就可以发生死锁。
多个线程多把锁(哲学家就餐问题)
情景:在一个餐桌上存在五个哲学家,他们做两件事情:一是思考哲学,二是就餐。
每个哲学家左右手各有一根筷子以供就餐时使用。如果哲学家手中只有一根筷子,他会等到另一根筷子拥有的时候才会就餐,而不会放下筷子。
通过这个情景,我们可以很明显的预知到一种情况:所有的哲学家手中都拿起左边的筷子,于是所有哲学家都停下了,进入阻塞等待状态。
我们通过这个问题可以反映到线程的情况,因为多线程多锁的原因,如果没有合理安排则会导致线程阻塞甚至死锁!
解决这种情况的一种办法就是约定好加锁的顺序,破除循环等待的情况。
在下面的代码中,两个线程轮流获取locker1和locker2,这就可以有效规避死锁的问题了。
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("获取到了2把锁"); } } }); Thread t2 = new Thread(()-> { synchronized (locker1){ try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2){ System.out.println("获取到了2把锁"); } } }); t1.start(); t2.start(); }
总结
死锁在多线程中是及其常见的一个问题,导致了线程的不安全。是我们要极力规避的情况。我们了解了死锁发生的情况,死锁的原因等多个点。
本文使用源码☞ 源码