【Java并发基础】死锁

前言

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

什么是死锁

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

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

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

原文地址:https://www.cnblogs.com/myworld7/p/12230010.html

时间: 2024-10-09 01:18:16

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

Java 并发基础

Java 并发基础 线程简述 线程是进程的执行部分,用来完成一定的任务; 线程拥有自己的堆栈,程序计数器和自己的局部变量,但不拥有系统资源, 他与其他线程共享父进程的共享资源及部分运行时环境,因此编程时需要小心,确保线程不会妨碍同一进程中的其他线程; 多线程优势 进程之间不能共享内存,但线程之间共享内存/文件描述符/进程状态非常容易. 系统创建进程时需要为该其分配很多系统资源(如进程控制块),但创建线程的开销要小得多,因此线程实现多任务并发比进程效率高. Java语言内置多线程支持,而不是单纯采

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

Java 并发基础常见面试题总结 1. 什么是线程和进程? 1.1. 何为进程? 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的.系统运行一个程序即是一个进程从创建,运行到消亡的过程. 在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程. 如下图所示,在 windows 中通过查看任务管理器的方式,我们就可以清楚看到 window 当前运行的进程(.exe 文件的运行). 1.2

Java并发基础(六) - 线程池

Java并发基础(六) - 线程池 1. 概述 这里讲一下Java并发编程的线程池的原理及其实现 2. 线程池的基本用法 2.1 线程池的处理流程图 该图来自<Java并发编程的艺术>: 从图中我们可以看出当一个新任务到线程池时,线程池的处理流程如下: 线程池首先判断线程池里面线程数是否达到核心线程数.如果不是则直接创建新线程作为核心线程来执行该任务(该线程作为核心线程不会由于任务的完成而销毁),否则进入下一流程. 判断阻塞队列是否已经满了.如果没满则将该任务放入阻塞队列中,等待核心线程处理,

Java并发基础框架AbstractQueuedSynchronizer初探(ReentrantLock的实现分析)

AbstractQueuedSynchronizer是实现Java并发类库的一个基础框架,Java中的各种锁(RenentrantLock, ReentrantReadWriteLock)以及同步工具类(Semaphore, CountDownLatch)等很多都是基于AbstractQueuedSynchronizer实现的.AbstractQueuedSynchronizer 一般简称AQS,Abstract表示他是一个抽象类,Queued表示他是基于先进先出 FIFO 等待队列实现的,Sy

【Java并发基础】安全性、活跃性与性能问题

前言 Java的多线程是一把双刃剑,使用好它可以使我们的程序更高效,但是出现并发问题时,我们的程序将会变得非常糟糕.并发编程中需要注意三方面的问题,分别是安全性.活跃性和性能问题. 安全性问题 我们经常说这个方法是线程安全的.这个类是线程安全的,那么到底该怎么理解线程安全呢? 要给线程安全性定一个非常明确的定义是比较复杂的.越正式的定义越复杂,也就越难理解.但是不管怎样,在线程安全性定义中,最核心的概念还是正确性,可以简单的理解为程序按照我们期望的执行. 正确性的含义是:某个类的行为与其规范完全

【Java并发基础】并发编程bug源头:可见性、原子性和有序性

前言 CPU .内存.I/O设备之间的速度差距十分大,为了提高CPU的利用率并且平衡它们的速度差异.计算机体系结构.操作系统和编译程序都做出了改进: CPU增加了缓存,用于平衡和内存之间的速度差异. 操作系统增加了进程.线程,以时分复用CPU,进而均衡CPU与I/O设备之间的速度差异. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用. 但是,每一种解决问题的技术出现都不可避免地带来一些其他问题.下面这三个问题也是常见并发程序出现诡异问题的根源. 缓存--可见性问题 线程切换--原子性问

【Java并发基础】Java内存模型解决有序性和可见性

前言 解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化.但是,禁用这两者又会影响程序性能.于是我们要做的是按需禁用CPU缓存和编译器的优化. 如何按需禁用CPU缓存和编译器的优化就需要提到Java内存模型.Java内存模型是一个复杂的规范.其中最为重要的便是Happens-Before规则.下面我们先介绍如何利用Happens-Before规则解决可见性和有序性问题,然后我们再扩展简单介绍下Java内存模型以及我们前篇文章提到的重排序概念. volatile 在前一

【Java并发基础】管程简介

前言 在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的.除了Java之外,C/C++.C#等高级语言也都是支持管程的. 那么什么是管程呢? 见名知意,是指管理共享变量以及对共享变量操作的过程,让它们支持并发.翻译成Java领域的语言,就是管理类的状态变量,让这个类是线程安全的. synchronized关键字和wait().notify().notifyAll()这三个方法是Java中实现管程技术的组成部分.记得学习操作系统

【Java并发基础】Java线程的生命周期

前言 线程是操作系统中的一个概念,支持多线程的语言都是对OS中的线程进行了封装.要学好线程,就要搞清除它的生命周期,也就是生命周期各个节点的状态转换机制.不同的开发语言对操作系统中的线程进行了不同的封装,但是对于线程的声明周期这部分基本是相同的.下面先介绍通用的线程生命周期模型,然后详细介绍Java中的线程生命周期以及Java生命周期中各个状态是如何转换的. 通用的线程生命周期 上图为通用线程状态转换图(五态模型). 初始状态 线程被创建,但是还不允许分配CPU执行.这里的创建仅仅是指在编程语言