前言

我们使用加锁机制来保证线程安全,但是如果过度地使用加锁,则可能会导致死锁。下面将介绍关于死锁的相关知识以及我们在编写程序时如何预防死锁。

什么是死锁

学习操作系统时,给出死锁的定义为两个或两个以上的线程在执行过程中,由于竞争资源而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。简化一点说就是:一组相互竞争资源的线程因为互相等待,导致“永久”阻塞的现象

下面我们通过一个转账例子来深入理解死锁。

class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}

为了使以上转账方法transfer()不存在并发问题,很快地我们可以想使用Java的synchronized修饰transfer方法,于是代码如下:

class Account {
private int balance;
// 转账
synchronized void transfer(Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}

需要注意,这里我们使用的内置锁是this,这把锁虽然可以保护我们自己的balance,却不可以保护target的balance。使用我们上一篇介绍的锁模型来描绘这个代码就是下面这样:(图来自参考[1])

更具体来说,假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元,最后我们期望的结果应该是账户 A 的余额是 100 元,账户 B 的余额是 200 元, 账户 C 的余额是 300 元。

如果有两个线程1和线程2,线程1 执行账户 A 转账户 B 的操作,线程2执行账户 B 转账户 C 的操作。这两个线程分别运行在两颗的CPU上,由于this这个锁只能保护自己的balance而不能保护别人的,线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例(B.this),所以这两个线程可以同时进入临界区 transfer(),因此两个线程没有实现互斥。

出现可能的结果就为,两个线程同时读到账户B的余额为200元,导致最终账户 B 的余额可能是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),可能是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不可能是 200。

并发转账示意图(图来自参考[1])

于是我们应该使用一个能够覆盖所有保护资源的锁,如果还记得我们上一篇讲synchronized修饰静态方法时默认的锁对象的话,那这里就很容易解决了。这个默认的锁就是类的class对象。于是,我们就可以使用Account.class作为一个可以保护这个转账过程的锁。

class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}

这个方案虽然不存在并发问题,但是所有账户的转账操作都是串行的。现实世界中,账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实世界里是可以并行的。较于实际情况来说,这个方案就显得性能太差。

于是,我们尽量模仿现实世界的转账操作:

每个账户都有一个账本,这些账本都统一存放在文件架上。当转账A给账户B转账时,柜员会去拿A账本和B账本做登记,此时柜员在拿账本时会遇到三种情况:

  1. 文件架上恰好有A账本和B账本,那就同时拿走;
  2. 如果文件架上只有A账本和B账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
  3. A账本和B账本都没有,那这个柜员就等着两个账本都被送回来

在编程实现中,我们可以使用两把锁来实现这个过程。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把A账本拿到手),然后尝试锁定转入账户 target(再把B账本拿到手),只有当两者都成功时,才执行转账操作。

这个逻辑可以图形化为下图这个样子,(图来自参考[1]):

代码如下:

class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户A
synchronized(this) {
// 锁定转入账户B
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}

经过这样的优化后,账户 A 转账户 B 和账户 C 转账户 D 这两个转账操作就可以并行了。

但是这样却会导致死锁。例如情况:柜员张三做账户A转账户B的转账操作,柜员李四做账户B转账户C的转账操作。他们两个同时操作,于是就会出现下面这种情形:(图来自参考[1])

他俩会一直等待对方将账本放到文件架上,造成一个一直僵持的局势。

关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。(图来自参考[1])

Java并发程序一旦死锁,一般没有特别好的方法,恢复应用程序的唯一方式就是中止并重启。因此,我们要尽量避免死锁的发生,最好不要产生死锁。要知道如何才能做到不要产生死锁,我们首先要知道什么条件会发生死锁。

死锁发生的四个必要条件

虽然进程在运行过程中,可能发生死锁,但死锁的发生也必须具备一定的条件,死锁的发生必须具备以下四个必要条件:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

破坏死锁发生的条件预防死锁

只有这四个条件都发生时才会出现死锁,那么反过来,也就是说只要我们破坏其中一个,就可以成功预防死锁的发生

四个条件中我们不能破坏互斥,因为我们使用锁目的就是保证资源被互斥访问,于是我们就对其他三个条件进行破坏:

  • 占用且等待:一次性申请所有的资源,这样就不存在等待了。
  • 不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 循环等待,靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化申请后就不存在循环了。

下面我们使用这些方法去解决如上的死锁问题。

破坏占用且等待条件

一次性申请完所有资源。我们设置一个管理员来管理账本,柜员同时申请需要的账本,而管理员同时出他们需要的账本。如果不能同时出借,则柜员就需要等待。

“同时申请”:这个操作是一个临界区,含有两个操作,同时申请资源apply()和同时释放资源free()。

class Allocator {
private List<Object> als = new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply( Object from, Object to){
if(als.contains(from) || als.contains(to)){    //from 或者 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))  //最好可以加个timeout避免一直循环

try{
// 锁定转出账户
synchronized(this){ //存在客户对自己账户的操作
// 锁定转入账户
synchronized(target){           
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)    //释放资源
}
}
}

破坏不可抢占条件

破坏不抢占要能够主动释放它占有的资源,但synchronized是做不到的。原因为synchronized申请不到资源时,线程直接进入了阻塞状态,而线程进入了阻塞状态也就没有办法释放它占有的资源了。不过SDK中的java.util.concurrent提供了Lock解决这个问题。

支持定时的锁

显式使用Lock类中的定时tryLock功能来代替内置锁机制,可以检测死锁和从死锁中恢复过来。使用内置锁的线程获取不到锁会被阻塞,而显式锁可以指定一个超时时限(Timeout),在等待超过该时间后tryLock就会返回一个失败信息,也会释放其拥有的资源。

破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源。我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。

比如下面代码中,①~⑤处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。

class Account {
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;
}
}
}
}
}

小结

记得学习操作系统时还有避免死锁,其和预防死锁的区别在于:预防死锁是设法至少破坏产生死锁的四个必要条件之一,严格地防止死锁的出现,但是这也会使系统性能降低;而避免死锁则不那么严格的限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁,死锁避免是在系统运行过程中注意避免死锁的最终发生。避免死锁的经典算法就是银行家算法,这里就不扩开介绍了。

还有一个避免出现死锁的结论:如果所有线程以固定顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。查看参考[4]理解。

我们使用细粒度锁锁住多个资源时,要注意死锁的产生。只有先嗅到死锁的味道,才有我们的施展之地。

参考:

[1]极客时间专栏王宝令《Java并发编程实战》

[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

[3]iywwuyifan.避免死锁和预防思索的区别.https://blog.csdn.net/masterchiefcc/article/details/83303813

[4]AddoilDan.死锁面试题(什么是死锁,产生死锁的原因及必要条件).https://blog.csdn.net/hd12370/article/details/82814348

【Java并发基础】死锁的更多相关文章

  1. Java 并发基础

    Java 并发基础 标签 : Java基础 线程简述 线程是进程的执行部分,用来完成一定的任务; 线程拥有自己的堆栈,程序计数器和自己的局部变量,但不拥有系统资源, 他与其他线程共享父进程的共享资源及 ...

  2. java并发基础(五)--- 线程池的使用

    第8章介绍的是线程池的使用,直接进入正题. 一.线程饥饿死锁和饱和策略 1.线程饥饿死锁 在线程池中,如果任务依赖其他任务,那么可能产生死锁.举个极端的例子,在单线程的Executor中,如果一个任务 ...

  3. Java并发基础概念

    Java并发基础概念 线程和进程 线程和进程都能实现并发,在java编程领域,线程是实现并发的主要方式 每个进程都有独立的运行环境,内存空间.进程的通信需要通过,pipline或者socket 线程共 ...

  4. 【搞定 Java 并发面试】面试最常问的 Java 并发基础常见面试题总结!

    本文为 SnailClimb 的原创,目前已经收录自我开源的 JavaGuide 中(61.5 k Star![Java学习+面试指南] 一份涵盖大部分Java程序员所需要掌握的核心知识.欢迎 Sta ...

  5. java并发基础(二)

    <java并发编程实战>终于读完4-7章了,感触很深,但是有些东西还没有吃透,先把已经理解的整理一下.java并发基础(一)是对前3章的总结.这里总结一下第4.5章的东西. 一.java监 ...

  6. java并发基础及原理

    java并发基础知识导图   一 java线程用法 1.1 线程使用方式 1.1.1 继承Thread类 继承Thread类的方式,无返回值,且由于java不支持多继承,继承Thread类后,无法再继 ...

  7. 【Java并发基础】使用“等待—通知”机制优化死锁中占用且等待解决方案

    前言 在前篇介绍死锁的文章中,我们破坏等待占用且等待条件时,用了一个死循环来获取两个账本对象. // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, targe ...

  8. java并发基础(六)--- 活跃性、性能与可伸缩性

    <java并发编程实战>的第9章主要介绍GUI编程,在实际开发中实在很少见到,所以这一章的笔记暂时先放一放,从第10章开始到第12章是第三部分,也就是活跃性.性能.与测试,这部分的知识偏理 ...

  9. Java 并发基础常见面试题总结

    1. 什么是线程和进程? 1.1. 何为进程? 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的.系统运行一个程序即是一个进程从创建,运行到消亡的过程. 在 Java 中,当我们启 ...

随机推荐

  1. PowerShell 通过 WMI 获取系统服务

    本文告诉大家如何通过 WMI 获取系统服务 通过 Win32_Service 可以获取系统启动的服务 Get-WmiObject Win32_Service | Format-List Caption ...

  2. TransactionDefinition接口中定义了七个事务传播行为

    1.PROPAGATION_REQUIRED如果存在一个事务,则支持当前事务,如果没有事务则开启一个新的事务.使用spring声明式事务,spring使用AOP来支持声明式事务,会根据事务属性,自动在 ...

  3. onload事件属性,JQ中的load,ready方法

    onload事件属性,JQ中的load,ready方法 前言 页面中的很多操作,需要我们在所需资源下载完成后,才可以进行操作,而资源没有及时下载,我们进行操作的话,是会报错.因此我们需要熟练掌握哪些事 ...

  4. PDF.JS 读取文件流前端展示 C#

    最近再搞PDF得展示问题,因为aspose.pdf成本太高,只能使用pdf.js这个开源强大的前端东东了. 在百度了很久后 网上大都是node,java,php的事例,有位大哥的是C#的后台代码按他写 ...

  5. k8s的网络方案对比

    如下图,三台虚拟机k8s-master.k8s-node-1.k8s-node-2组成k8s集群,网络拓扑和节点IP分配如下图: 一.flannel组网方案 https://github.com/co ...

  6. 024.讲MFC_窗口指针

    窗口指针通过HWND获得CWnd指针 //如何通过窗口句柄获得窗口指针获得应用程序主窗口的指针 //如何获得应用程序主窗口的指针一.建立名为dialogPoint的mfc工程,添加两个button 双 ...

  7. CentOS遇到Qt编译问题(error: cannot find -lGL)

    1.安装Qt,进入CentOS系统的终端,依次执行以下命令 chmod +x qt-opensource-Linux-x64-5.5.1.run ./qt-opensource-linux-x64-5 ...

  8. 以windows服务方式快速部署免安装版Postgres数据库

    目录 以windows服务方式快速部署免安装版Postgres数据库 1.下载Postgresql数据库免安装包 2.安装环境准备及验证 解压文件 测试环境依赖 3.创建并初始化数据目录 创建数据目录 ...

  9. 字符串format格式方式

    1.format后面都是个元组类型!!! 不一一对应则报错,比如少了一个替换的元素,format可以少但是不能多,一多就会报错 括号里可以加索引,看例2 可以用索引只取一个值,比如括号里都是{1} r ...

  10. JavaScript数组的方法 | 学习笔记分享

    数组 数组的四个常用方法 push() 该方法可以向数组的末尾添加一个或多个元素,并返回数组的新长度 可以将要添加的元素作为方法的参数传递,这些元素将会自动添加到数组的末尾 pop() 该方法可以删除 ...