并发进阶(九)常见的死锁类型

导读

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

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()

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()方法返回书的名字。

classBook {

privateString name;

privateBookManager manager;

public synchronized voidsetAvailable(booleanisAvailable) {

if(isAvailable) { manager.returnBook(this);}

else{ manager.lentBook(this);} }

public synchronizedString toString() {return name; }}

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

classBookManager {

privateList allBooks =newArrayList();

privateList availableBooks =newArrayList();

public synchronized voidreturnBook(Book book) {availableBooks.add(book); }

public synchronized voidlentBook(Book book) {availableBooks.remove(book); }

public synchronizedString toString() {

returnallBooks.toString(); }}

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

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

资源死锁

02

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

小码的总结

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

遇到不理解的问题

直接在公众号留言即可

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180802G0OWTV00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券