并发编程(四):如何应对死锁

【关于作者】

关于作者,目前在蚂蚁金服搬砖任职,在支付宝营销投放领域工作了多年,目前在专注于内存数据库相关的应用学习,如果你有任何技术交流或大厂内推及面试咨询,都可以从我的个人博客(https://0522-isniceday.top/)联系我

1.死锁的情况

针对有关联的资源,我们用锁进行保护的同时,在去求锁的粒度和性能之间,很容易就产生死锁问题,例如针对互斥锁章节的问题。我们可以进行如下处理:

image-20210727222418258

代码如下:

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

此时如果我们同时调用Account A的transfer(B,100)和Account B的transf(A, 100)就会发生死锁问题,其实就是ABBA问题,AB之间互相持有锁并相互等待的场景。

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

2.如何预防死锁

死锁只有以下四个条件同时发送时,才会出现死锁:

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

也就是说只需要破坏上述某一个条件,就可以成功避免死锁:

针对条件1互斥,由于用锁就是为了互斥,这里无法破坏,因此针对234点入手:

  1. 对于“占用且等待”:一次性申请所有的资源,就能避免
  2. 对于“不可抢占”:占用部分资源的线程进一步申请其他资源时,如果申请不到,就可以释放当前资源
  3. 对于“循环-等待”:可以靠有序申请资源来预防。所谓按序申请,是指资源是有线性顺序的

2.1.破坏“占用且等待”

理论上:破坏这个条件,可以一次性申请所有资源。对应到编程领域,一次性申请也可以看做一个临界区,并且针对该临界区采取互斥机制,来保证申请所有资源的原子性。

2.2.破坏“不可抢占条件”

核心是要能够主要释放抢占的资源,这点synchronized无法做到。java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的

2.3.破坏“循环等待条件”

破坏这个条件,需要对资源进行排序,然后按序申请资源。例如该转账的场景,就可以按照账户某个属性进行排序,按序申请资源。

代码如下:

class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = thisAccount 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;
        }
      }
    }
  } 
}

3.用“等待-通知”机制优化“循环-等待”

我们上面使用while循环来实现循环等待机制,但是如果循环内部的操作比较耗时则会比较影响性能,我们是否还有更优的方式实现循环等待呢,我们可以再线程要求的条件无法得到满足,则阻塞进入等待状态,当阻塞线程要求的条件满足时,通知等待的线程再去请求锁,那么我们使用while死循环的方式就能取消,从而避免过度消耗CPU

// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))

当线程T不能够一次性拿到所以资源时,为了避免死锁,则可以阻塞并等待所有资源可以申请,此时就引入了“等待-通知”机制。

完整的等待-通知机制:线程首先请求锁,请求成功后当执行完毕或当前线程申请的锁资源不满足,则释放锁,进入等待状态;当要求满足时,通知等待的线程,再次去请求锁。(zookeeper实现的分布式锁有点类似于该机制)

3.1.用 synchronized 实现等待 - 通知机制

Java中有多重方式实现等待-通知机制,例如Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现

等待队列:等待队列和互斥锁是一对一的关系,每个互斥锁都有自己的等待队列,当有一个线程进入临界区时,其余线程就会进入等待队列。

image-20210803220401095

wait():在并发程序中,有些程序进入了临界区之后,由于某些条件不满足,需要进入等待状态。此时可以调用wait()方法,调用之后当前线程就会被阻塞,并且进入到右边的等待队列中,并且会释放该线程持有的锁。

notify()/ notyfyAll():当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过(你所需要的条件都满足了,可以再去请求锁啦),并且被通知的线程会去重新获得锁,并且需要注意的是wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:

java.lang.IllegalMonitorStateException

曾经满足过?:因为notify()只保证在通知时间点条件是满足的,因此当队列被通知的线程执行时,条件可能又不满足了。

image-20210803222957606

3.2.等待-通知机制的范式

  1. 互斥锁:确认互斥锁是什么,转账的场景中的锁是Allocator的实例this
  2. 线程要求的条件是什么:锁资源同时能够满足
  3. 何时等待:线程要求的条件不满足则等待,如无法同时申请锁资源
  4. 何时通知:阻塞线程要求的条件满足则通知,如能够同时申请锁资源这一条件

3.3.尽量使用notifyAll()

notify()是会随机的通知等待队列的一个线程,而notifyAll()会通知等待队列中的所有线程。所以除非经过深思熟虑,否则尽量使用 notifyAll()。因为notify()可能会导致某些线程永远不会被通知到或者通知到了也无法继续执行

1.死锁的情况

针对有关联的资源,我们用锁进行保护的同时,在去求锁的粒度和性能之间,很容易就产生死锁问题,例如针对互斥锁章节的问题。我们可以进行如下处理:

image-20210727222418258

代码如下:

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

此时如果我们同时调用Account A的transfer(B,100)和Account B的transf(A, 100)就会发生死锁问题,其实就是ABBA问题,AB之间互相持有锁并相互等待的场景。

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

2.如何预防死锁

死锁只有以下四个条件同时发送时,才会出现死锁:

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

也就是说只需要破坏上述某一个条件,就可以成功避免死锁:

针对条件1互斥,由于用锁就是为了互斥,这里无法破坏,因此针对234点入手:

  1. 对于“占用且等待”:一次性申请所有的资源,就能避免
  2. 对于“不可抢占”:占用部分资源的线程进一步申请其他资源时,如果申请不到,就可以释放当前资源
  3. 对于“循环-等待”:可以靠有序申请资源来预防。所谓按序申请,是指资源是有线性顺序的

2.1.破坏“占用且等待”

理论上:破坏这个条件,可以一次性申请所有资源。对应到编程领域,一次性申请也可以看做一个临界区,并且针对该临界区采取互斥机制,来保证申请所有资源的原子性。

2.2.破坏“不可抢占条件”

核心是要能够主要释放抢占的资源,这点synchronized无法做到。java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的

2.3.破坏“循环等待条件”

破坏这个条件,需要对资源进行排序,然后按序申请资源。例如该转账的场景,就可以按照账户某个属性进行排序,按序申请资源。

代码如下:

class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = thisAccount 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;
        }
      }
    }
  } 
}

3.用“等待-通知”机制优化“循环-等待”

我们上面使用while循环来实现循环等待机制,但是如果循环内部的操作比较耗时则会比较影响性能,我们是否还有更优的方式实现循环等待呢,我们可以再线程要求的条件无法得到满足,则阻塞进入等待状态,当阻塞线程要求的条件满足时,通知等待的线程再去请求锁,那么我们使用while死循环的方式就能取消,从而避免过度消耗CPU

// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))

当线程T不能够一次性拿到所以资源时,为了避免死锁,则可以阻塞并等待所有资源可以申请,此时就引入了“等待-通知”机制。

完整的等待-通知机制:线程首先请求锁,请求成功后当执行完毕或当前线程申请的锁资源不满足,则释放锁,进入等待状态;当要求满足时,通知等待的线程,再次去请求锁。(zookeeper实现的分布式锁有点类似于该机制)

3.1.用 synchronized 实现等待 - 通知机制

Java中有多重方式实现等待-通知机制,例如Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现

等待队列:等待队列和互斥锁是一对一的关系,每个互斥锁都有自己的等待队列,当有一个线程进入临界区时,其余线程就会进入等待队列。

image-20210803220401095

wait():在并发程序中,有些程序进入了临界区之后,由于某些条件不满足,需要进入等待状态。此时可以调用wait()方法,调用之后当前线程就会被阻塞,并且进入到右边的等待队列中,并且会释放该线程持有的锁。

notify()/ notyfyAll():当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过(你所需要的条件都满足了,可以再去请求锁啦),并且被通知的线程会去重新获得锁,并且需要注意的是wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:

java.lang.IllegalMonitorStateException

曾经满足过?:因为notify()只保证在通知时间点条件是满足的,因此当队列被通知的线程执行时,条件可能又不满足了。

image-20210803222957606

3.2.等待-通知机制的范式

  1. 互斥锁:确认互斥锁是什么,转账的场景中的锁是Allocator的实例this
  2. 线程要求的条件是什么:锁资源同时能够满足
  3. 何时等待:线程要求的条件不满足则等待,如无法同时申请锁资源
  4. 何时通知:阻塞线程要求的条件满足则通知,如能够同时申请锁资源这一条件

3.3.尽量使用notifyAll()

notify()是会随机的通知等待队列的一个线程,而notifyAll()会通知等待队列中的所有线程。所以除非经过深思熟虑,否则尽量使用 notifyAll()。因为notify()可能会导致某些线程永远不会被通知到或者通知到了也无法继续执行

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
嗨!很高兴回答你关于Java并发编程的问题。请问你想知道什么方面的内容呢?我可以分享一些学习笔记和建议给你。 1. 并发编程基础:了解并发编程的基本概念,如线程、进程、、同步等。学习Java中的并发编程模型以及相关的API,如Thread、Runnable、Lock、Condition等。 2. 线程安全性:学习如何保证多线程环境下的数据安全性,了解共享资源的问题以及如何使用同步机制来防止数据竞争和并发问题。 3. 线程间的通信:掌握线程间的通信方式,如使用wait/notify机制、Lock/Condition等来实现线程的协调与通信。 4. 并发容器:学习并发容器的使用,如ConcurrentHashMap、ConcurrentLinkedQueue等。了解它们的实现原理以及在多线程环境下的性能特点。 5. 并发工具类:熟悉Java提供的并发工具类,如CountDownLatch、CyclicBarrier、Semaphore等,它们可以帮助你更方便地实现线程间的协作。 6. 并发编程模式:学习一些常见的并发编程模式,如生产者-消费者模式、读者-写者模式、线程池模式等。了解这些模式的应用场景和实现方式。 7. 性能优化与调试:学习如何分析和调试多线程程序的性能问题,了解一些性能优化的技巧和工具,如使用线程池、减少竞争、避免死锁等。 这些只是一些基本的学习笔记和建议,Java并发编程是一个庞大而复杂的领域,需要不断的实践和深入学习才能掌握。希望对你有所帮助!如果你有更具体的问题,欢迎继续提问。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

哈哈哈张大侠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值