前言
Java 线程死锁是一个经典的多线程问题。为了保证线程安全,通常会在程序中使用各种能保证并发安全的工具,锁就是其中之一,但如果使用不恰当,就有可能会造成死锁的发生。
死锁
定义
死锁(DeadLock
)是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。例如,有一座跨河的独木桥,桥面不宽仅仅可通过一人,有两人甲与乙相对而行至桥中,同时占用了公共资源(桥),甲如果想通过独木桥的话,乙必须退出桥面让出桥的资源,让甲通过。但是乙不爽了:为什么要我退出去,甲就不能退出去让我先过吗?于是两人就僵持不下,导致谁也过不了桥,这就是死锁。
产生原因和必要条件
由以上过桥的例子可知产生死锁现象的原因:一是系统提供的资源不能满足每个进程的使用需求(桥面不够宽,不足以容纳两人通过);二是在多道程序运行时,进程推进的顺序不合法(如果规定了过桥的顺序,无论是先甲后乙,还是先乙后甲,都正常通过)。
产生死锁有 4 个必要条件:
- 互斥条件:对于独占资源,每个资源每次只能给一个进程使用,进程一旦申请到了资源后占为己有,则排斥其他进程共享该资源。
- 不抢占条件:正在使用的资源不可抢占,进程获得的资源尚未使用完毕之前,只能由占有者自己释放,不能被其他进程强行占用。
- 占有且等待条件:进程因未分配到新的资源而受阻,但又不放弃已占用的资源。
循环等待条件:存在进程的循环等待链,前一个进程占有的资源正是后一个进程需求的资源,结果就形成了循环等待的僵持局面。
必然死锁的案例
创建一个名称为面试官:手写一个必然死锁的案例?
DealThread
的线程类:public class DealThread implements Runnable { public String name; // 两把不同的锁 public Object lock1 = new Object(); public Object lock2 = new Object(); public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public void run() { if ("ijk".equals(this.name)) { synchronized (lock1) { try { System.out.println(Thread.currentThread().getName() + "获取到了 lock1,name = " + name); TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock2) { System.out.println("获取到 lock1 后 --> 也获取到了 lock2"); } } } if ("xyz".equals(this.name)) { synchronized (lock2) { try { System.out.println(Thread.currentThread().getName() + "获取到了 lock2,name = " + name); TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock1) { System.out.println("获取到 lock2 后 --> 也获取到了 lock1"); } } } } }
运行类
DeadLockDemo
代码如下:public class DeadLockDemo { public static void main(String[] args) { DealThread dt = new DealThread(); dt.setName("ijk"); new Thread(dt, "线程A").start(); try { TimeUnit.SECONDS.sleep(1); }catch (InterruptedException e) { e.printStackTrace(); } dt.setName("xyz"); new Thread(dt, "线程B").start(); } }
分析
程序无法继续向下执行了,直觉告诉我们,很可能发生了死锁。那么如何验证自己的判断呢?可以使用 JDK 自带的工具来检查是否有死锁的现象。进入 CMD 工具,进入 JDK 安装文件夹中的 bin 目录,执行
jps
命令,查看当前系统的 Java 进程以及其进程 id:
如图所示,得知了运行类DeadLockDemo
的 id 值后,再执行jstack
命令来分析线程的情况,查看进程内的堆栈信息;附加选项:-l(小写字母 l),可以观察锁的持有情况。
根据图中的信息,确实存在死锁,这就验证了我们的判断。那这个死锁是如何产生的?双方互相持有对方的锁,互相等待对方释放锁。这种情况下,解决顺序死锁的办法就是保证所有线程以相同的顺序获取锁。预防死锁
死锁可能会造成整个系统的崩溃,降低了系统的性能......死锁的危害就不过多说明了。那如何避免死锁呢?根据上述的 4 个必要条件,只要我们破坏其中一个,就可以成功避免死锁的发生。其中,互斥条件我们没有办法破坏,因为我们用锁为的就是互斥。
破坏不抢占条件
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
这需要使用到 JUC 下的 Lock 的 API:// 支持中断的 API void lockInterruptibly() throws InterruptedException; // 支持超时的 API boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 支持非阻塞获取锁的 API boolean tryLock();
破坏占有且等待条件
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
以转账为例,业务员类 Allocator 它有两个重要功能,分别是:同时申请资源apply()
和同时释放资源free()
。账户类 Account 里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。class Allocator { private List<Object> als = new ArrayList<>(); // 一次性申请所有资源 synchronized boolean apply(Object from, Object to){ if(als.contains(from) || als.contains(to)){ return false; } else { als.add(from); als.add(to); } return true; } // 归还资源 synchronized void free(Object from, Object to){ als.remove(from); als.remove(to); } } class Account { // actr 应该为单例 private Allocator actr; private int balance; // 转账 void transfer(Account target, int amt){ // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, target)) ; /** * 申请完两个账户的资源后还需要再分别锁定 this 和 target 账户 * 因为还存在其他业务,比如客户取款。 * 这个时候也是对全局变量 balance 做操作 * 如果不加锁,并发情况下会出问题 */ try{ // 锁定转出账户 synchronized(this){ // 锁定转入账户 synchronized(target){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } finally { actr.free(this, target) } } }
破坏循环等待条件
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有一个不同值的属性 id(类似于银行卡号),这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。
比如下面代码中,从 ① 到 ⑥ 处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。class Account { // 唯一值 id private int id; // 账户余额 private int balance; // 转账方法 void transfer(Account target, int amt){ Account left = this; ① Account right = target; ② if (this.id > target.id) { ③ left = target; ④ right = this; ⑤ } ⑥ // 锁定序号小的账户 synchronized(left){ // 锁定序号大的账户 synchronized(right){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } }
排查工具
死锁的排查工具除了上文用到的 jps 与 jstack 命令,还有一些可视化的界面工具(
虽说有亿点点丑)。jconsole
jconsole.exe 也位于 JDK 安装目录中 的 bin 目录,双击打开即可。然后在启动界面选择需要测试的程序:
点击连接进入,点击”不安全的连接“按钮,进入监控主界面:
在监控界面的上方工具栏,点击切换到”线程“模块,然后点击下方的”检测死锁“按钮:jvisualvm
这个 jvisualvm.exe 也位于 JDK 安装目录中 的 bin 目录,双击打开即可。如果你的 JDK 版本不是 8,那么需要安装独立版了。地址:VisualVM。注意:在 JDK 8 以上的高版本已经没有了。
使用步骤和 jconsole 的大同小异,左侧选择调试的进程,双击即可进入:
然后点击”Thread“模块,上方区域有红色字体显示检测到死锁,下方区域可以看到各个线程的信息,不同颜色对应的状态在右下方有说明。
选择”线程A“,点击”threaddump“按钮就会生成死锁的详细信息:参考
知乎专栏
极客时间 - Java 并发编程实战
CSDN