java线程-线程活跃性问题

101 阅读5分钟

在使用多线程,如果有共享变量被多个线程读写,可能会产生线程安全问题。如果发生线程切换,可能会产生性能问题。实际上,在使用多线程,可能还会产生死锁,活锁,饥饿锁等线程活跃性问题。

死锁

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

我们用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽说这个方法不存在并发问题,但是所有账户的操作都是串行的,导致性能太差。

我们需要两把锁,一把锁锁住this,另一把锁锁住target

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

这样账户A转账户B,账户C转账户D就可以并行了。

我们使用细粒度锁,可以提高并行度,是性能优化的一个重要手段。但是有可能会导致死锁。

死锁的定义

一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

上述代码如何发生死锁?

我们假设线程T1执行账户A转账户B的操作,同时线程T2执行账户B转账户A的操作。当T1和T2同时执行到①处的代码时,T1获得了A的锁,T2获得了B的锁。之后T1和T2在执行②时,T1试图获取B的锁时发现,B已经被T2锁定了,所以T1开始等待,T2则试图获取A的锁时,发现A已经被T1锁定,所以T2也开始等待。于是T1和T2会无线等待下去,导致死锁。

上述代码还算比较简单,能够很快分析出死锁的发生原因,但在实际坏境中,死锁的检测并非这么简单,会出现非常复杂的情况,那么计算机就需要一定的算法来检测死锁。一个比较简单的方法就是有向图+DFS算法来检测有没有环,如果有环,则存在发生死锁的可能,如果没有则不会发生死锁。

juc并没有实现死锁检测,但是可以通过jstack等java工具来把线程的运行状态打印出来。

死锁检测算法.png

发生死锁的条件

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

如何预防死锁

上述代码有个比较简单预防死锁的做法,就是使用类锁,但类锁粒度太大,会导致转账业务均无法并发执行,严重影响转账业务的执行效率。

我们只要破坏其中一个发生死锁的条件,就可以成功避免死锁的发生。

  1. 对于占有且等待条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 循环等待,可以靠按序申请资源来预防。所谓按序申请,是指资源时有线性顺序的,申请时可以先申请资源序号小的,再申请资源序号大的。

破坏占有且等待条件

转账操作,需要两个资源:1.转出账户,2.转入账户。我们可以增加一个账本管理员,然后只允许管理员管理账本的转入转出。

class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申请所有资源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}
​
class Account {
  // actr 应该为单例
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 锁定转出账户
      synchronized(this){              
        // 锁定转入账户
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}
​

破坏不可抢占条件

synchronized做不到,lock可以做到。

void transfer3(Account target, double amt) {
  while(true){
    lock1.lock();
    try {
      boolean tryLock = lock2.tryLock();
      try {
        if (tryLock && (this.balance > amt)) {
          this.balance -= amt;
          target.balance += amt;
        }
        return;
      } finally {
        lock2.unlock();
      }
​
    } catch (Exception e) {
      throw new RuntimeException(e);
    } finally {
      lock1.unlock();
    }
  }
}

破坏循环等待条件

我们假设每个账户都有不同的id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序申请。

1-6处的代码对转出账户this,和转入账户target排序。然后按照序号从小到大的顺序锁定账户。

class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = this        //1
    Account right = target;    //2
    if (this.id > target.id) { //3
      left = target;           //4
      right = this;            //5
    }                          //6
    // 锁定序号小的账户
    synchronized(left){
      // 锁定序号大的账户
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}
​

活锁

预防死锁的方式二(破坏不可抢占的条件)存在问题。

活锁.png 线程t1和t2一直循坏加锁,尝试加锁,释放锁,我们把这种情况叫做活锁。

死锁与活锁的区别:处于死锁状态的两个线程均处于阻塞状态,不消耗cpu资源,但活锁线程处于一直循坏加锁,尝试加锁,释放锁的状态,非常消耗cpu资源。

预防活锁的方法也很简单,只需要调用tryLock的超时方法,随机sleep一段时间即可。

lock1.lock();
Random r = new Random();
try {
  boolean tryLock = false;
  try {
    tryLock = lock2.tryLock(r.nextLong() % 10, TimeUnit.MILLISECONDS);
  } catch (Exception e) {
    //
  }
  try {
    if (tryLock && (this.balance > amt)) {
      this.balance -= amt;
      target.balance += amt;
    }
  } finally {
    lock2.unlock();
  }
​
} catch (Exception e) {
  throw new RuntimeException(e);
} finally {
  lock1.unlock();
}

饥饿锁

死锁和活锁是两个或者两个以上线程的整体状态,饥饿则是一个线程的状态,死锁和活锁都会导致线程处于饥饿状态,一直获取不到锁。

非公平锁由于新来的线程不需要排队,就可以竞争锁,所以排队中的线程就有可能一直竞争不到锁,就会出现饥饿现象。

自旋+CAS也有可能一直无法成功执行CAS,导致一直自旋,从而出现饥饿现象。