4

译注:本文展示了实现一个多线程同步工具类的过程当中,会遇到和解决那些与并发有关的问题。关于锁的更详细的文章,以及现实当中应该用哪种公平锁,推荐这篇文章

饥饿与公平

原文地址: http://tutorials.jenkov.com/j...

如果一个线程没有被分配到 CPU 执行时间,该线程就处于“饥饿”状态。如果总是分配不到 CPU 执行时间(因为总是被分配到其他线程去了),那么该线程可能会被“饿死”。有一种策略用于避免出现该问题,称作“公平策略”,即保证所有的线程都能公平地得到被执行的机会。

产生饥饿的原因

在 Java 中,有三种最普遍的情形会导致饥饿的发生:

  1. 高优先级的线程总是吞占 CPU 执行时间,导致低优先级的线程没有机会;
  2. 某些线程总是能被允许进入 synchronized 块,以致某些线程总是得不到机会;
  3. 某些线程在等待指定的对象(即调用了该对象的 wait() 方法)时,完全得不到唤醒的机会,因为被唤醒的总是别的线程。

高优先级的线程总是吞占 CPU 执行时间

每个线程都可以单独设置优先级。优先级越高,该线程就能获得更多的 CPU 执行时间。优先级的值最低为 1 最高为 10。至于如何根据优先级来分配 CPU 执行时间,则依赖于操作系统的具体实现。在大多数应用中,我们最好不要去擅自修改它。

线程无限等待进入 synchronized 块的机会

Java 当中的 synchronized 代码块也是导致饥饿的一个因素。它不保证线程进入的顺序,所以理论上某个线程可能永远无法进入 synchronized 块,这种情况下可以说这个线程就被“饿死”了。

线程无限等待被锁对象唤醒的机会

当多个线程同时调用的某个对象的 wait() 方法并等待时,notify() 方法不保证一定能唤醒哪个指定的线程。所以如果它总是不去唤醒某个线程的话,这个线程就处于永久性地等待当中了。

如何在 Java 中实现公平策略

当然我们没办法实现 100% 的绝对公平,但还是可以通过一些结构上的设计来增加线程之间的公平性。

首先我们来看一个简单的 synchronized 代码块:

public class Synchronizer{
 public synchronized void doSynchronized(){
 //do a lot of work which takes a long time
 }
}

当多个线程调用 doSynchronized() 方法时,只有一个线程能够进入该方法并执行,而且该线程退出该方法后,正在等待的线程中无法保证哪一个才是接下来可以进入的。

用锁对象来代替 synchronized 块

为了增强公平性,第一步我们先把 synchronized 块改为锁对象:

public class Synchronizer{
  Lock lock = new Lock();
  public void doSynchronized() throws InterruptedException{
    this.lock.lock();
      // critical section, do a lot of work which takes a long time
      // 需要同步执行的代码
    this.lock.unlock();
  }
}

请注意 doSynchronized() 方法本身现在不再是同步的了,需要同步执行的代码现在由lock.lock()lock.unlock() 保护起来。

那么 Lock 类简单的实现是下面这个样子:

public class Lock{
  private boolean isLocked      = false;
  private Thread  lockingThread = null;
  public synchronized void lock() throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked      = true;
    lockingThread = Thread.currentThread();
  }
  public synchronized void unlock(){
    if(this.lockingThread != Thread.currentThread()){
      throw new IllegalMonitorStateException(
        "Calling thread has not locked this lock");
    }
    isLocked      = false;
    lockingThread = null;
    notify();
  }
}

结合上面 Synchronizer 类和这里的 Lock 实现,你会看到:首先,当多个线程调用 lock() 方法时,它们会被阻塞;其次,当 Lock 对象处于锁住状态时,进入 lock() 方法的线程会在 wait() 语句处阻塞。这里要注意:当线程成功调用 wait() 方法时,会自动释放 Lock 对象的锁,于是其他的线程能够得以进入 lock() 方法,最终会有多个线程都阻塞在 wait() 语句处。

我们回头看 doSynchronized() 方法中 lock() 和 unlock() 之间的部分,假设这部分代码需要很长时间来执行,甚至比线程在 wait() 语句处等待所花的时间都长的多。那么线程获得锁所需的时间主要也是耗在 wait() 语句处,而不是进入 lock() 方法的时候。

在目前这个版本的代码中,不论线程是在 synchronized 块阻塞,还是在 wait() 处阻塞,都不能保证哪个线程能一定被唤醒,所以目前的代码尚未提供公平策略。

(译注:之所以改成这样,目的是令线程在进入 lock() 方法时的阻塞时间尽可能短,也就是所有的线程都在 wait() 处阻塞,以便实施接下来的改动。)

目前版本的 Lock 对象是在调用自身的 wait() 方法。我们改掉这点,让每个线程调用不同对象的 wait() 方法的话,那么就可以自行挑选调用哪个对象的 notify() 方法,以此实现自行挑选唤醒哪个线程。

公平锁

下面的代码展示了将 Lock 类转化为 FairLock 类的结果。请注意同步方式和 wait()/notify() 的调用方式有了哪些的变化。

整个改动的实现是阶段性的,这个过程中需要依次解决内部锁对象死锁同步条件丢失以及解锁信号丢失等问题。由于篇幅长度所限这里就不详述了(请参考上面的链接)。这里最重要的改动点,就是对 lock() 方法的调用现在是放在队列中,所有的线程以队列中的顺序来依次获得 FairLock 对象的锁。

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;
  }
}

首先你可能注意到 lock() 方法不再是 synchronized。因为只有这个方法里面的部分代码才需要同步。

FairLock 会为每个线程创建一个新的 QueueObject 对象并将其加入队列。调用 unlock() 方法的线程会从队列中取第一个元素对象并调用它的 doNotify() 方法,这样唤醒的就只有一个线程,而不是一堆线程。这个就是 FairLock 的公平机制所在。

注意接下来就是在同步块中重新检查条件并更新锁状态,这是为了避免同步条件丢失。

此外 QueueObject 实际上是一个信号量,doWait()doNotify() 方法的目的是存取锁的状态信号,以避免解锁信号丢失,即在一个线程调用 queueObject.doWait() 之前,另一个线程已经在 unlock() 方法中调用了该对象的 queueObject.doNotify() 方法。至于将 queueObject.doWait() 方法的调用放在同步块外面,是为了避免内部对象死锁的情况发生,这样另一个线程就可以持有 FairLock 对象的锁,并安全的调用 unlock() 方法了。

最后就是对 queueObject.doWait() 这条语句进行异常捕获。如果这条语句执行时发生了 InterruptedException 异常,那么就需要在离开这个方法前将 queueObject 对象从队列中去掉。

关于执行效率的说明

我们把 LockFairLock 对比一下就会看到后者的 lock() unlock() 增加了很多代码,它们会导致其执行效率比前者略有下降。这个影响的程度如何,取决于 lock()unlock() 之间的同步代码的执行时间,该时间越长,则影响就越小。当然同时也取决于锁本身的使用频繁程度。


捏造的信仰
2.8k 声望272 粉丝

Java 开发人员