Java并发编程之锁的活跃性问题

Java基础

浏览数:226

2020-5-30

引子

在安全性和活跃性之间通常存在一种制衡。当我们使用锁来保证线程的安全的同时,如果过度使用加锁,可能会导致死锁。 应用无法从死锁中恢复过来,所以在设计时一定要避免会排除这些可能会出现的活跃性问题。

死锁

死锁描述了这样一种情景,两个或多个线程永久阻塞,互相等待对方释放资源

如果线程1锁住了A,然后尝试对B进行加锁,同时线程2已经锁住了B,接着尝试对A进行加锁,这时死锁就发生了。线程1永远得不到B,线程2也永远得不到A,并且它们永远也不会知道发生了这样的事情。为了得到彼此的对象(A和B),它们将永远阻塞下去

pubic class LeftRightDeadlock{
    
    private Object left =new Object();
    private Object right =new Object();
    
    public void leftRight(){
        
        synchronized(left){
          synchronized(right){
              
              dosomething();
          }
        }
    }
    punlic void rightLeft(){
        synchronized(right){
            synchronized(left){
                dosomething();
            }
        }
    }
}

很常见的错误,如果同时2个线程去请求这2个方法就会出现死锁

更加复杂的死锁场景发生在数据库事务中。一个数据库事务可能由多条SQL更新请求组成。当在一个事务中更新一条记录,这条记录就会被锁住避免其他事务的更新请求,直到第一个事务结束。同一个事务中每一个更新请求都可能会锁住一些记录 当多个事务同时需要对一些相同的记录做更新操作时,就很有可能发生死锁

不过好在数据库设计中考虑了检测死锁和死锁恢复,数据库会选择牺牲一个事物来释放锁,从而让其他事物继续进行。 不过Java应用并没有这种处理,所以我们在设计时要格外注意。

避免死锁

加锁顺序

当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。

如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生

按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁,但总有些时候是无法预知的

加锁限时

另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行

需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务

时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题

死锁检测

死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

每当一个线程获得了锁,会在线程和锁相关的数据结构中将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁

当出现死锁的时候可以给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级

饥饿

如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为饥饿。而该线程被饥饿致死正是因为它得不到CPU运行时间的机会。解决饥饿的方案被称之为公平性 – 即所有线程均能公平地获得运行机会

在Java中,下面几个常见的原因会导致线程饥饿:

  • 高优先级线程吞噬所有的低优先级线程的CPU时间。

你能为每个线程设置独自的线程优先级,优先级越高的线程获得的CPU时间越多,线程优先级值设置在1到10之间,而这些优先级值所表示行为的准确解释则依赖于你的应用运行平台。对大多数应用来说,你最好是不要改变其优先级值

  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。

Java的同步代码区也是一个导致饥饿的因素。Java的同步代码区对哪个线程允许进入的次序没有任何保障。这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,因为其他线程总是能持续地先于它获得访问

实现公平性

public class FairLock {
    private boolean           isLocked       = false;
    private Thread            lockingThread  = null;
    private List<QueueObject> waitingThreads =
            new ArrayList<QueueObject>();

  public void lock() throws InterruptedException{
    QueueObject queueObject           = new QueueObject();
    boolean     isLockedForThisThread = true;
    synchronized(this){
        waitingThreads.add(queueObject);
    }

    while(isLockedForThisThread){
      synchronized(this){
        isLockedForThisThread =
            isLocked || waitingThreads.get(0) != queueObject;
        if(!isLockedForThisThread){
          isLocked = true;
           waitingThreads.remove(queueObject);
           lockingThread = Thread.currentThread();
           return;
         }
      }
      try{
        queueObject.doWait();
      }catch(InterruptedException e){
        synchronized(this) { waitingThreads.remove(queueObject); }
        throw e;
      }
    }
  }

  public synchronized void unlock(){
    if(this.lockingThread != Thread.currentThread()){
      throw new IllegalMonitorStateException(
        "Calling thread has not locked this lock");
    }
    isLocked      = false;
    lockingThread = null;
    if(waitingThreads.size() > 0){
      waitingThreads.get(0).doNotify();
    }
  }
}


public class QueueObject {

    private boolean isNotified = false;

    public synchronized void doWait() throws InterruptedException {

    while(!isNotified){
        this.wait();
    }

    this.isNotified = false;

}

public synchronized void doNotify() {
    this.isNotified = true;
    this.notify();
}

public boolean equals(Object o) {
    return this == o;
}

}

FairLock新创建了一个QueueObject的实例,并对每个调用lock()的线程进行入队列。调用unlock()的线程将从队列头部获取QueueObject,并对其调用doNotify(),以唤醒在该对象上等待的线程。通过这种方式,在同一时间仅有一个等待线程获得唤醒,而不是所有的等待线程。这也是实现FairLock公平性的核心所在

 

活锁

活锁是另一种形式的活跃性问题,该问题不会阻塞线程,但也不能继续执行,因为线程将不断重复相同的操作,而且总会失, 这就相当于两个在走廊相遇的人:A 向他自己的左边靠想让 B 过去,而B 向他的右边靠想让 A 过去。可见他们阻塞了对方。A 向他的右边靠,而B向他的左边靠,他们还是阻塞了对方。

解决方案是,让彼此重试的时候随机等待一段时间,这样就会有效避免活锁的发生。

总结

活跃性问题是非常严重的问题,当应用出现活跃性故障,往往只有中断应用程序才能解决,死锁最为常见,我们要注意锁的顺序性问题,对于此类方法调用,有良好的封装,对使用者透明也能避免锁使用的错误。

作者:12叔