Loading

Java并发之死锁问题学习

一.避免活跃性危险

我们在设计并发程序时,在安全性和活跃性之间通常存在着某种制衡。 我们使用加锁机制来确保线程安全,但如果过度的使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock),同样我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁(Resource Deadlock)。

注意:java应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的条件。这也就是我们要学习的重点:如何避免一些活跃性问题

二.死锁学习

1.什么是死锁?
要知道什么是死锁,我们就要知道经典的哲学家问题,这个问题是这样的:有5个哲学家去吃中餐,坐在一张圆桌上,他们旁边有五只筷子(每两个人中间放一根),哲学家们时而思考,时而吃饭,但每个人都需要一双筷子才能吃饭,并且在吃完后将筷子放回原处接着思考。 这里就有一个问题:我们都知道这五个哲学家不可能同时吃饭,如果他们尝试同时去同时拿筷子,他们每个人只能拿到一个筷子,并且每个人都在等待别人放下手中的筷子,这样他们都会饿死(因为他们都吃不到东西)。 这就是一种死锁。

于是我们有了对死锁的了解:每个人拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所需要的资源之前都不会放弃已拥有的资源。

2.将哲学家死锁类比到我们的程序中
1)当一个线程永远的持有一个锁,并且其他线程都尝试获得这个锁,那么他们将永远被阻塞。 在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远的等待下去,这就是最简单的死锁形式:抱死。

2)注意:我们可以把每个线程假想成为有向图中的一个节点,图中每条边表示的关系是:线程A等待线程B所占有的资源。 如果在图中形成了一条环路,那么就存在死锁。

3.我们应该怎样处理死锁?
1)这里有一个很好的例子:在数据库系统的设计中会考虑监测死锁以及从死锁中恢复。 数据库中,在执行一个事务时可能需要获取多个锁,并一直持有这些锁直到事务提交。 因此两个事务之间很可能会发生死锁。 数据库在检测到一组事务发生死锁时,它将选择一个牺牲者事务释放它所拥有的锁来让其他事务继续执行。

2)但是在Java中,JVM在解决死锁问题方面并没有数据库那么强大。 当一组java线程发生死锁时,那么这些线程永远不能再使用了。 具体的处理方法根据线程完成工作的不同,可能造成程序完全停止,也可能造成某个特定的子系统停止,等等其他问题。
但我们知道的是:恢复应用程序唯一的方法就是中止并重启它。

3)我们要知道:与很多其他并发问题一样,死锁造成的影响很少会立即显示出来,如果一个类可能发生死锁,并不意味着它每次都会死锁,这只是一个概率问题而已,我们要做的就是让这个概率变成接近于0

4.我们编程中可能会遇到的死锁:

1)锁顺序死锁

我们先看一小段代码:

public class LeftRightDeadlock{
  private final Object left = new Object();
  private final Object right = new Object();

  public void leftRight(){
     synchronized(left){
       synchronized(right){
         doSomething();
       }
     }
  }
  public void rightLeft(){
     synchronized(right){
       synchronized(left){
         doSomethingElse();
       }
     }
  }
}

很明显,如果我们两个线程各自同时运行上面两个方法,那么会造成下面的情况:
在这里插入图片描述
上面的情况就叫做:锁顺序死锁。

如何避免呢? 注意:如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

2)动态的锁顺序死锁

同样我们先看下面一个方法代码(先浏览一遍):

public void transferMoney(Account fromAccount, 
                           Account toAccount,
                          DollarAmout amount) throws InsufficientFundsException{
    synchronized(fromAccount){
     synchronized(toAccount){
        if(fromAccount.getBalance().compareTo(amount)<0) 
           throw new InsufficientFundsException();
        else{
          fromAccount.debit(amount);
          toAccount.credit(amount);
        }
     }
    }                          
}

下面我们对这个方法进行分析:
方法参数分析: 看完我们知道,按字面意思这是个转账方法,这个方法的输入是:两个账户对象,一个钱的对象。fromAccount表示转出方,toAccount表示接受方。

方法过程分析: 首先我们要知道这个方法会存在一个检查型异常情况,就是如果转出方的钱不够了,所以我们一个要声明一个异常,然后首先锁住转出对象,然后锁住转入对象,然后判断转出方的钱够不够,如果不够则抛出异常,如果够就对转出方的账户余额做减操作,对接受方账户做加操作。方法结束

看到这你也许会说: 这个方法没问题啊,不会发生死锁吧!!!
那么很遗憾,你的感觉是错误的

那么在transferMoney中如何发生死锁呢?
执行上面方法的所以线程似乎都是按照相同的顺序来获得锁,但是事实上锁的顺序取决于参数(发现没,不信回去看看代码)。
那么在会出现这样一种情况:假如有两个线程,一个是X向Y转账,另一个是Y向X转账,这样是不是就会发生锁顺序死锁的情况了? 是的,会发生(尽管这种情况很少,但是我们仍然要考虑到,因为写程序有个很重要的法则:把最坏的情况都考虑到,写出的程序会更健壮)。

那么我们该如何去解决这个问题呢?
由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。

我们看下面代码(先浏览一遍):

private static final Object tieLock = new Object();

public void transferMoney(final Account fromAccount,
                          final Account toAccount,
                          final DollarAmount amount) throws InsufficientFundsException{
                          
       class Helper{
          public void transfer() throws InsufficientFundsException{
            if(fromAccount.getBalance.compareTo(amount)<0)
                throw new InsufficientFundsException();
            else{
              fromAccount.debit(amount);
              toAccount.credit(amount);
            }
          }
       }
       
      /*
        static int	identityHashCode(Object x)
        返回与默认方法hashCode()返回的给定对象相同的哈希码,无论给定对象的类是否覆盖了hashCode()
      */
       int fromHash = System.identityHashCode(fromAccount);
       int toHash = System.identityHashCode(toAccount);

      if(fromHash<toHash){
          synchronized(fromAccount){
            synchronized(toAccount){
              new Helper().transfer();
            }
          }
      }else if(fromHash>toHash){
        synchronized(toAccount){
          synchronized(fromAccount){
            new Helper().transfer();
          }
        }
      }else{
        synchronized(tieLock){
           synchronized(fromAccount){
             synchronized(toAccount){
               new Helper().transfer();
             }
           }
        }
      }
}                          

我们学习这段代码的根本是要学习它与之前的代码的不同之处,所以我们要结合实际情况来分析,假设上面的代码遇到了我们提出的问题:假如有两个线程,一个是X向Y转账,另一个是Y向X转账。
在这里插入图片描述
如果例外的X和Y的Hash码相同,则采用加一个互斥锁来防止悲剧发生。

上面主要采用一个不变性条件(一个对象的Hash码)来调节锁顺序,这个策略我们应该学习。

3)在协作对象之间发生的死锁
在实际的编程中其实某些获取锁的操作并不像在LeftRightDeadlock或者transferMoney中那么明显。 比如下面代码中:

代码中Taxi代表一个出租车类,Dispatcher代表一个出租车车队。

//出租车类
class Taxi{
  private Point location , destination;//位置,目的地
  private final Dispatcher dispatcher;//所属车队
  
  public Taxi(Dispatcher dispatcher){
    this.dispatcher = dispatcher;
  }
  
  public synchronized Point getLocation(){
    return location;
  }
  
  pulbic synchronized void setLocation(Point location){
    this.location = location;
    if(location.equals(destination))
       dispatcher.notifyAvailable(this);//告诉车队有车空闲并将其添加到车队可利用出租车集合中
  }
  
}

//车队类
class Dispatcher{
   private final Set<Taxi> taxis;//存放出租车
   private final Set<Taxi> availableTaxis;//存放可利用出租车

   public Dispatcher(){
      taxis = new HashSet<Taxi>();
      availableTaxis = new HashSet<Taxi>();
   }
   
   public synchronized void notifyAvailable(Taxi taxi){
     availableTaxis.add(taxi);
   }
   
   //此方法用于获取车队出租车的位置信息
   public synchronized Image getImage(){
     Image image = new Image();
     for(Taxi t: taxis)
        iamge.drawMaker(t.getLoocation());
     return image;
   }
   
}

上面代码尽管没有任何方法会显式地获取两个锁,但是有方法确实会获得两个锁:
例如:

 pulbic synchronized void setLocation(Point location){
    this.location = location;
    if(location.equals(destination))
       dispatcher.notifyAvailable(this);//执行这个方法会获得一个锁
  }

那么上面代码怎样会发生死锁呢?

如果一个线程在收到GPS接收器的更新事件时调用setLocation,那么它将首先更新出租车的位置,然后判断它是否到达了目的地。 如果已经到达,它会通知Dispatcher:它需要一个新的目的地。 因为setLocation和notifyAvailable都是同步方法,因此调用setLocation的线程将首先获取Taxi的锁,然后获取Dispatcher的锁。 同样,调用getImage的线程将首先获取Dispatcher锁,然后再获取每一个Taxi的锁。 我们可以看到,如果有两个线程同时分别调用setLocation 和 getImage方法,就会发生死锁。

所以,注意:如果在持有锁时调用每个外部方法,那么将出现活跃性问题。在这个外部方法中可能 会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

如何解决这个问题呢?开放调用

4)开放调用
在前面的协作对象之间的死锁中,Taxi和Dispatcher并不知道他们将要陷入死锁,况且他们本来就不应该知道。 方法调用相当于一种抽象屏障,因而你无须了解在被调用方法中所执行的操作。 但也正是由于不知道,从而造成死锁。

如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。(Open Call) 依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时持有锁的类相比,也更易于编写。

这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易的多。 同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序活跃性简单。 通过尽可能的使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此就容易确保采用一致的顺序获得锁。

class Taxi{
  private Point location, destination;
  private final Dispatcher dispatcher;

 public synchronized Point getLocation(){
   return location;
 }
 
 public void setLocation(Point location){
   boolean reachedDestination;
   synchronized(this){
     this.location = location;
     reachedDestination = location.equals(destination);
   }
   if(reachedDestination)
       dispatcher.notifyAvailable(this);
 }
}

class Dispatcher{
  private final Set<Taxi> taxis;
  private final Set<Taxi> availableTaxis;

  public synchronized void notifyAvailable(Taxi taxi){
    availableTaxi.add(taxi);
  }
  public Image getImage(){
    Set<Taxi> copy;
    synchronized (this){
      copy = new HashSet<Taxi>(taxis);
    }
    Image image = new Image();
    for(Taxi t: copy)
         iamge.drawMarker(t.getLocation());
    return image;
  }
}

下面我们针对上面代码的两种实现对其中的setLocation进行比较,看看到底第二种好在哪?

//会发生死锁的实现
pulbic synchronized void setLocation(Point location){
    this.location = location;
    if(location.equals(destination))
       dispatcher.notifyAvailable(this);
  }
  
//开放调用实现
public void setLocation(Point location){
   boolean reachedDestination;
   synchronized(this){
     this.location = location;
     reachedDestination = location.equals(destination);
   }
   
   if(reachedDestination)
       dispatcher.notifyAvailable(this);
 }

可以看到,二者的差别也就是调用方法持锁的方式不同,前者是在方法体持有锁,后者是方法中持有锁(方法体本身不持有锁,开放调用),这样就可以达到一种效果:
如果有两个线程同时分别调用setLocation 和 getImage方法,那么setLocation首先锁住Taxi进行相关操作后会立即释放,而不是一直持有,然后执行后面的。同理,getImage也是如此,这样就避免了死锁。

注意:在程序中尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。

5)资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当他们在相同的资源集合上等待时,也会发生死锁。

我们假设有两个资源池,例如两个不同的数据库的连接池。资源池通常采用信号量来实现当资源池为空时的阻塞行为。 如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程A可能持有与数据库D1连接并等待与数据库D2的连接,而线程B则持有与D2的连接并等待D1的连接,这样就发生了资源死锁。

注意:资源池越大,出现上面情况的可能性越小。如果每个资源都有N个连接,那么在发生死锁时不仅需要N个循环等待的线程,而且还需要大量不恰当的执行时序。

还有一种基于资源的死锁形式就是线程饥饿死锁(Thread-Starvation Deadlock)(前面讲过)。例如:一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。 在这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。 如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。
如下:

public class ThreadDeadlock{
  ExecutorService exec = Executors.newSingleThreadExecutor();
  
  public class RenderPageTask implements Callable<String> {
     public String call() throws Exception {
       Future<String> header,footer;
       header = exec.submit(new LoadFileTask("header.html"));
       footer = exec.submit(new LoadFileTask("footer.html"));
       String page = renderBody();
       
       return header.get()+page+footer.get();
     }
  }
}

总结:死锁非常重要,我们要尽可能多的掌握不同情形下的死锁类型,并知道它的解决思路。

posted @ 2020-12-04 11:48  文牧之  阅读(13)  评论(0)    收藏  举报  来源