并发进阶常见的死锁类型

Posted 今日说码

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发进阶常见的死锁类型相关的知识,希望对你有一定的参考价值。

导读


之前我们介绍了形成死锁的条件,讲解了如何避免死锁。本篇将从实践的角度出发,介绍常见的死锁类型及其解决方案。常见的死锁类型可以分为四种:锁顺序死锁、动态锁顺序死锁、协作对象间死锁、资源死锁

01

锁顺序死锁

锁顺序死锁的原理是最简单的,a()方法先获取锁1后获取锁2;b()方法先获取锁2后获取锁1,两个线程同时调用这两个方法就很可能发生死锁,代码如下:

a() {
   
synchronized(first) {
synchronized(second) {    doSth(); }    } } b() {
   
synchronized(second) {
synchronized(first) {    doSth(); }    } }

遇到这种代码是很好解决的,只需要将拿锁的顺序统一了就好了,即两个方法都先获取锁1后获取锁2,这样就不会有死锁风险了。然而说起来容易做起来却很难,在实际项目里,程序员很难确定之前的代码里两个对象的拿锁顺序,甚至有可能在写b()方法时不知道已经有一个a()方法了,因此在实际项目里这种死锁仍然让人头疼,虽然它的原理很简单。

动态锁顺序死锁

02

动态的锁顺序死锁相对来说更隐蔽,参考下面的代码,这是一个银行转帐的方法transferMoney()有三个参数,原始帐户、目标帐户和转账金额,这个方法先获取fromAccount的锁,再获取toAccount的锁。看起来似乎没有瑕疵,因为所有调用这个方法的拿锁顺序都是固定的,然而实际上并不是。假设有两笔交易,一笔是小明给小刚转5块钱,另一笔是小刚给小明转4块钱。这个时候如果系统两个线程同时执行这两笔交易就会发生死锁,因为钱的方向不同导致两个线程拿锁的顺序不同了。

transferMoney(Account fromAccount, Account toAccount, double money) {
   synchronized(fromAccount) {
synchronized(toAccount) {    fromAccount.transferTo(toAccount, money); }    } }

解决这种问题也不复杂,我们可以打破由钱的方向决定帐户顺序的规则。Object类有一个hashCode()方法,我们可以根据hashCode的大小为帐户排序。代码如下:

transferMoney(Account fromAccount, Account toAccount, double money) {    if(fromAccount.hashCode() > toAccount.hashCode()) {
synchronized(fromAccount) {    synchronized(toAccount) { fromAccount.transferTo(toAccount, money);    } }    }    else if(fromAccount.hashCode() < toAccount.hashCode()) {
synchronized(toAccount) {    synchronized(fromAccount) { fromAccount.transferTo(toAccount, money);    } }    }    else {
synchronized(CLASSNAME.class) {    synchronized(fromAccount) {
synchronized(toAccount) {    fromAccount.transferTo(toAccount, money); }    } }    } }

在上面的代码中我们先比较两个账号的哈希值,先获取哈希值大的对象的锁,再获取哈希值小的对象的锁。如果两个对象的哈希值相同,则先获取当前类对象的锁,再分别获取两个账号对象的锁,此时两个账号锁对象的顺序已经不重要了,因为即使两个帐户同时向对方转账,两个线程在获取CLASSNAME.class锁的时候只能有一个拿到锁,所以不会出现死锁。除了哈希值之外我们还有更好的办法用于比较两个对象的大小,通常情况下我们的对象都有一个唯一的ID,我们可以使用这个ID比较两个对象的大小,使用这种方法可以规避两个对象的哈希值相等的情况。

03

协作对象间死锁

上面讲的都是同一个类的对象之间发生的死锁,不同类的对象之间也会导致死锁,并且这种死锁更加隐蔽。参考下面的代码,这是一个简单的借书系统,由于篇幅原因这里省去了一些不必要的方法,只保留了核心代码。下面的代码中我们定义了两个类Book和BookManager,这两个类都是线程安全的(所有方法都被synchronized修饰)。


Book类定义了两个方法,setAvailable()方法根据传入的布尔变量调用BookManager的returnBook()或者lentBook()方法;toString()方法返回书的名字。

class Book {
   private String name;
   private BookManager manager;
   public synchronized void setAvailable(boolean isAvailable) {
if(isAvailable) {    manager.returnBook(this); }
else {    manager.lentBook(this); }    }
   public synchronized String toString() { return name;    } }

BookManager类定义了两个ArrayList属性,第一个属性存储所有的书籍信息,第二个属性存储目前可以出借的书籍信息。returnBook()方法向可出借的方法属性中添加一个元素,lentBook()方法与之相反;BookManager的toString()方法则返回allBooks的toString()方法。

class BookManager {
   private List<Book> allBooks = new ArrayList<Book>();
   private List<Book> availableBooks = new ArrayList<Book>();
   public synchronized void returnBook(Book book) { availableBooks.add(book);    }
   public synchronized void lentBook(Book book) { availableBooks.remove(book);    }
   public synchronized String toString() {
return allBooks.toString();    } }

有的同学可能没有发现这两个类会发生死锁,这个死锁隐藏的确实有点深。设想一种情况:一个用户想借走一本书,借书的过程中需要拿锁,调用setAvailable()方法时需要先获取这本书的锁再获取BookManager对象的锁。此时有另一个用户想看看目前系统里都有什么书,于是调用了BookManager的toString()方法,调用toString()方法需要先获取BookManager对象的锁,再获取Book对象的锁,这时两个用户的操作就有可能发生死锁。有的同学可能会说这里有没有获取Book对象的锁,其实获取了,因为ArrayList的toString()方法会调用元素的toString()方法,而Book的toString()方法由synchronized修饰了,因此会获取Book对象的锁。

这种死锁虽然很复杂,但是也是可以避免的,至于如何避免......这里作为作业留给大家思考,下期揭晓答案。

资源死锁

04

资源死锁和锁顺序死锁相似,只不过资源死锁是由于获取资源的顺序不同导致的,而锁顺序死锁是由于获取锁的顺序不同导致的。举一个资源死锁的例子:两个线程要连接两个数据库,线程1可能持有DB1的连接并等待DB2的连接,而线程2则持有DB2的连接等待DB1的连接,这时候两个线程就发生了死锁。资源死锁的解决方案和锁顺序死锁相同。

小码的总结

本篇介绍了常见的死锁类型及其解决方案,其中动态锁顺序死锁相对来说是最容易避免的,因为这种死锁在同一个方法中拿锁,我们只需要关注新编写的代码就可以避免这种死锁,而其它类型的死锁则需要关注之前编写的代码。因此我们在编写代码的时候要尽量缩小变量的作用域,一言不合就用public的后果就是分析代码的时候很难确定哪里用到了这个变量。限制变量的作用域就可以更快的确定哪里用到了它,从而更好的避免另外三种死锁。

遇到不理解的问题

可查看连载文章。


以上是关于并发进阶常见的死锁类型的主要内容,如果未能解决你的问题,请参考以下文章

Java并发编程实战 04死锁了怎么办?

4种Golang并发操作中常见的死锁情形

java并发编程死锁

并发编程--锁--如何使用命令行和代码定位死锁

Mysql并发时经典常见的死锁原因及解决方法

4种Golang并发操作中常见的死锁情形