饥饿与公平

时间:2020-01-09 10:35:52  来源:igfitidea点击:

如果一个线程因为其他线程抢占了所有时间而没有被授予CPU时间,则称为"饥饿"。该线程"饿死了",因为允许其他线程代替它使用CPU时间。饥饿的解决方案称为"公平",即公平地授予所有线程执行的机会。

Java中饥饿的原因

以下三个常见原因可能导致Java中的线程不足:

  • 高优先级的线程会吞噬低优先级的线程的所有CPU时间。
  • 线程会无限期地等待进入同步块,因为其他线程一直被允许访问它。
  • 在对象上等待的线程(在该对象上称为wait())将无限期地等待,因为其他线程会不断唤醒它而不是在唤醒它。

高优先级的线程吞没了低优先级的线程的所有CPU时间

我们可以分别设置每个线程的线程优先级。优先级越高,授予线程的CPU时间就越多。我们可以在1到10之间设置线程的优先级。确切的解释方式取决于应用程序所运行的操作系统。对于大多数应用程序,最好不要更改优先级。

线程被无限期阻塞,等待进入同步块

Java的同步代码块可能是造成饥饿的另一个原因。 Java的同步代码块无法保证允许等待进入同步块的线程进入的顺序。这意味着在理论上存在线程永远试图进入该块而始终被阻止的风险,因为其他线程在访问该块之前一直被授予访问权限。这个问题称为"饥饿",一个线程被"饿死"是因为其他线程被允许占用CPU时间而不是它。

在对象上等待的线程(在其上称为wait())保持无限期等待

如果多个线程在对象notify()上调用了wait(),则notify()方法不能保证唤醒哪个线程。可能是任何正在等待的线程。因此,存在这样的危险,即永远不会唤醒等待在某个对象上等待的线程,因为总是会唤醒其他等待线程而不是其他线程。

用Java实现公平

尽管不可能在Java中实现100%的公平性,但我们仍然可以实现同步结构来增加线程之间的公平性。

首先让我们研究一个简单的同步代码块:

public class Synchronizer{

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

}

如果有多个线程调用doSynchronized()方法,则其中一些线程将被阻塞,直到第一个被授予访问权限的线程离开该方法为止。如果有多个线程被阻塞等待访问,则无法保证下一个线程被授予访问权限。

使用锁代替同步块

为了增加等待线程的公平性,我们首先将代码块更改为由锁保护,而不是由同步块保护:

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()方法的while(isLocked)循环内的wait()调用中被阻塞。请记住,调用wait()的线程会释放Lock实例上的同步锁,因此等待输入lock()的线程现在可以这样做。结果是,多个线程最终可能在lock()内部调用了wait()。

如果回头看doSynchronized()方法,我们会注意到lock()和unlock()状态之间的注释,这两个调用之间的代码需要花费很长的时间才能执行。让我们进一步假设,与输入lock()方法和调用wait()相比,此代码需要花费较长的时间执行,因为该锁已被锁定。这意味着等待锁定锁并进入关键部分的大部分时间都花在了lock()方法内的wait()调用中,而不是在尝试进入lock()方法时被阻塞。

如前所述,如果有多个线程正在等待进入,则同步块不能保证授予哪个线程访问权限。 wait()也不保证在调用notify()时唤醒了哪个线程。因此,当前的Lock类版本与doSynchronized()的同步版本在公平性方面没有不同的保证。但是我们可以改变这一点。

当前版本的Lock类将调用其自己的wait()方法。相反,如果每个线程在一个单独的对象上调用wait(),以便只有一个线程在每个对象上调用了wait(),则Lock类可以确定要在哪个对象上调用notify(),从而有效地准确选择要使用的线程唤醒

公平锁

下面显示了以前的Lock类变成了一个称为FairLock的公平锁。我们会注意到,与前面显示的Lock类相比,实现在同步和wait()/notify()方面有所变化。

确切地说,我是从上一个Lock类开始的,是一个较长的故事,涉及几个增量设计步骤,每个步骤都解决了上一步的问题:嵌套监视器锁定,滑动条件和信号丢失。为了使文本简短,该讨论被省略了,但是在该主题的相应文本中讨论了每个步骤(请参见上面的链接)。重要的是,每个调用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。取而代之的是,仅将同步所需的块嵌套在" synchronized"块中。

FairLock创建一个新的QueueObject实例,并将其排队给每个调用lock()的线程。调用unlock()的线程将把队列中的顶端QueueObject并在其上调用doNotify()来唤醒等待该对象的线程。这样,一次仅唤醒一个等待线程,而不唤醒所有等待线程。这部分决定着" FairLock"的公平性。

请注意,如何仍在同一同步块内测试并设置锁的状态,以避免出现打滑情况。

还要注意," QueueObject"实际上是一个信号量。 doWait()和doNotify()方法将信号内部存储在QueueObject中。这样做是为了避免由于线程在调用queueObject.doWait()之前被抢占,另一个线程调用unlock()和queueObject.doNotify()而被抢占而导致的信号丢失。 queueObject.doWait()调用被放置在synced(this)块的外部,以避免嵌套的监视器锁定,因此,当没有任何线程在锁中的" synchronized(this)"块内执行时,另一个线程实际上可以调用unlock()方法。

最后,请注意在try try块内如何调用queueObject.doWait()。万一抛出InterruptedException,线程将离开lock()方法,我们需要将其出队。

性能说明

如果比较LockFairLock类,我们会注意到,在FairLock类的lock()unlock()内部还有更多的事情要做。这个额外的代码将导致" FairLock"成为比" Lock"慢得多的同步机制。这将对应用程序产生多大影响,取决于执行" FairLock"保护的关键部分中的代码需要花费多长时间。执行时间越长,同步器增加的开销就越小。当然,这也取决于此代码被调用的频率。