JAVA更多的锁问题

img

饥饿和公平

如果一个线程因为其他线程获取了所有的CPU时间而没有获取到CPU时间,被称为“饥饿”。线程被“饿死”,因为其他线程能够使用CPU而它不能。饥饿的解决方案被称为“公平” - 所有线程都被公平地授予执行机会。

发生饥饿的原因

以下三个常见原因可能导致Java中线程的饥饿

线程抢占

具有高优先级的线程会从优先级较低的线程中占有所有CPU时间。

您可以单独设置每个线程的线程优先级。优先级越高,给予线程的CPU时间越多。您可以将线程的优先级设置在1到10之间,具体交互方式取决于运行应用程序的操作系统(关于线程优先级前面已经提及)。对于大多数应用程序,您最好保持优先级不变。

线程阻塞

线程被无限期地阻塞,等待进入同步块,因为其他线程在它之前一直被允许进入同步块中。

Java的同步代码块可能是饥饿的另一个原因。Java的同步代码块不保证进入同步块的线程的顺序。这意味着存在一个理论上的风险,即线程在尝试进入块时永远被阻塞,因为其他线程在它之前一直被授予访问权限。这个问题被称为“饥饿”,一个线程被“饿死”,因为其他线程获取了CPU时间而不是它。

线程等待

在一个对象上等待的线程(在它上面调用 wait() )一直无限期的等待,因为其他也在等待的线程不断被唤醒而不是它。

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

实现公平性

虽然不可能在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()方法已经不再声明为synchronized。相反,临界区由 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() 方法。

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

当前版本的Lock类在它自己上调用 wait() 方法。作为代替如果每个线程在一个单独的对象上调用 wait() ,那么在每个对象上只有一个线程调用 wait() ,Lock类可以决定调用哪个对象上的 notify() ,从而有效地选择哪个线程被唤醒。

公平锁

下面是之前的Lock类变成了一个名为FairLock的公平锁。你会发现,这个类的实现相比之前Lock类的 wait()/ notify(),已经改变了一些关于同步的地方。

如何从前一个Lock类开始到现在这个设计,涉及了几个增量设计步骤,每个步骤都解决了上一步的问题:嵌套监视器锁死Slipped Conditions信号丢失。本文为了让文本简短不讨论这些问题,但每个步骤都在相关主题的文本中讨论(参见上面的链接)。重要的是,现在每个线程调用lock()时都会排队,并且只有队列中的第一个线程允许获取锁,如果锁没有被锁定。所有其他线程都停下来等待,直到它们到达队列的头部。

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块内。

每个线程调用lock()时,FairLock为它创建一个新的QueueObject实例并将其排队 。线程调用unlock()将获取队列中头部的QueueObject并调用它的doNotify()方法,以唤醒在该对象上等待的线程。这样,一次只唤醒一个等待线程,而不是所有等待线程。这部分是FairLock实现公平所做的事。

注意锁的状态仍在相同的同步块中进行测试和设置,以避免出现Slipped Conditions。

还要注意,QueueObject它是一个信号量。 doWait()doNotify()方法在QueueObject内部存储信号。这样做是为了避免一个线程在调用queueObject.doWait()之前发生了线程抢占,然后另一个线程先调用unlock(),最后此线程才调用queueObject.doNotify(),导致丢失了信号。 queueObject.doWait()调用被置于synchronized(this)块之外,以避免发生嵌套监视器锁死,同时,当没有线程在lock()方法中的synchronized(this)块内执行时,另一线程能够调用unlock()

最后,注意queueObject.doWait()在try - catch块中被调用 。如果调用过程中抛出了InterruptedException,线程将离开lock()方法,因此我们需要将其出队列,防止后续的解锁过程对此线程进行无效唤醒。

关于性能的说明

如果你比较LockFairLock类,你会发现FairLock类的lock()unlock()会做更多的事。这里额外的代码将导致FairLock比起Lock来说是一个更慢的同步机制。这将对您的应用程序产生多大的影响取决于临界区中的代码需要多长时间才能执行完。执行所需的时间越长,同步器的额外开销就越小。当然它也取决于此代码被调用的频率。

嵌套监视器锁死

嵌套监视器锁死如何发生

嵌套的监视器锁死是一个类似于死锁的问题。嵌套的监视器锁死如下所示:

Thread 1 synchronizes on A
Thread 1 synchronizes on B (while synchronized on A)
Thread 1 decides to wait for a signal from another thread before continuing
Thread 1 calls B.wait() thereby releasing the lock on B, but not A.

Thread 2 needs to lock both A and B (in that sequence)
        to send Thread 1 the signal.
Thread 2 cannot lock A, since Thread 1 still holds the lock on A.
Thread 2 remain blocked indefinately waiting for Thread1
        to release the lock on A

Thread 1 remain blocked indefinately waiting for the signal from
        Thread 2, thereby
        never releasing the lock on A, that must be released to make
        it possible for Thread 2 to send the signal to Thread 1, etc.

这可能听起来像一个理论上的情况,但看看下面的Lock的实现:

//lock implementation with nested monitor lockout problem

public class Lock{
    protected MonitorObject monitorObject = new MonitorObject();
    protected boolean isLocked = false;

    public void lock() throws InterruptedException {
        synchronized(this){
            while(isLocked){
                synchronized(this.monitorObject) {
                    this.monitorObject.wait();
                }
            }
            isLocked = true;
        }
    }

    public void unlock() {
        synchronized(this) {
            this.isLocked = false;
            synchronized(this.monitorObject){
                this.monitorObject.notify();
            }
        }
    }
}

注意lock()方法先在“this”上同步,然后在成员变量monitorObject上同步。如果isLocked为false则没有问题,线程不会调用 monitorObject.wait() 。但是isLocked如果为true,则线程在调用lock()方法时将会在 monitorObject.wait() 调用上阻塞。

这样做的问题是,调用 monitorObject.wait() 只释放monitorObject上的同步监视器,而不释放与“this”关联的同步监视器。换句话说,刚刚阻塞的线程仍然在“this”上持有同步锁。

当之前锁定了Lock的线程尝试通过调用unlock()来解锁,它将在尝试进入unlock()方法的synchronized(this)块时被阻塞。它将会一直阻塞直到在lock()上等待的线程离开了synchronized(this)块。但是在lock()方法上等待的线程也永远不会离开那个同步块直到isLocked被设置为false,并且发生在unlock()上的monitorObject.notify()方法被执行。

简而言之,在lock()上等待的线程需要一个成功执行的unlock()调用才能退出lock()方法和其中的synchronized块。但是,在lock()上等待的线程离开同步块之前,没有线程可以执行unlock()

这个结果是任何调用lock()或者unlock()的线程将被无限制的阻塞,这称为嵌套监视器锁死。

一个更现实的例子

您可能声称您永远不会像前面所示那样实现锁,你将不会在一个内部监视器对象上调用wait()notify(),而且这很可能是真的。但是在某些情况下可能还是会出现如上所述的设计。例如,如果您要在Lock中实现 公平性,当这样时,您希望每个线程都在自己的队列对象上调用wait(),以便您可以一次通知一个线程。

看看这个公平锁的实现:

//Fair Lock implementation with nested monitor lockout problem

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();

        synchronized(this) {
            waitingThreads.add(queueObject);
            while(isLocked || waitingThreads.get(0) != queueObject) {
                synchronized(queueObject) {
                    try {
                        queueObject.wait();
                    } catch(InterruptedException e) {
                        waitingThreads.remove(queueObject);
                        throw e;
                    }
                }
            }
            waitingThreads.remove(queueObject);
            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;
        if(waitingThreads.size() > 0) {
            QueueObject queueObject = waitingThreads.get(0);
            synchronized(queueObject) {
                queueObject.notify();
            }
        }
    }
}

public class QueueObject {}

乍一看,这个实现可能看起来很好,但请注意lock()方法从两个嵌套的同步块内部调用queueObject.wait();。一个在“this”上同步,一个在queueObject局部变量上同步并且这个同步块嵌套在前一个同步块中。当线程在QueueObject实例调用queueObject.wait()时,它将释放QueueObject实例上的锁,但不释放与“this”相关联的锁。

另外注意,unlock()方法声明为synchronized,等同于 synchronized(this)块。这意味着,如果一个线程在lock()内等待,那么与“this”相关联的监视器对象将一直被等待线程锁定。调用unlock()的所有线程将被无限期的阻塞,等待那个等待线程释放对“this”的锁定。但这种情况永远不会发生,因为只有当一个线程成功向等待线程发送信号时才会发生这种情况,而这个信号只能通过执行unlock()方法来发送。

因此,上面的FairLock实现可能导致嵌套的监视器锁死。在“ 饥饿与公平 ”一文中描述了实现公平锁定更好的版本。

嵌套监视器锁死和死锁的区别

嵌套监视器锁定和死锁的结果几乎相同:所涉及的线程最终永远被阻塞彼此等待。

但这两种情况并不完全相同。正如死锁章节的内容中所解释的, 当两个线程以不同的顺序获取锁时会发生死锁,例如线程1锁定A,等待锁B,线程2锁定B,等待锁A。使用死锁预防章节中所述的方法, 可以通过始终以相同的顺序加锁的方式来避免死锁(顺序锁定)。但是,嵌套的监视器锁死已经是由两个线程在相同的顺序获取锁的情况下发生的。线程1锁定A和B,然后释放B并等待来自线程2的信号。线程2需要同时锁定A和B两者向线程1发送信号。因此,一个线程在等待一个信号,另一个发送信号的线程在等待锁被释放。

两者差异总结如下:

  • 在死锁中,两个线程等待彼此释放锁。
  • 在嵌套的监视器锁死中,线程1持有锁A,并等待来自线程2的信号。线程2需要锁A来向线程1发出信号。

Slipped Conditions

什么是 Slipped Conditions

Slipped conditions 意思是,从线程检查某个条件开始直到线程作用于这个条件的时间段内,该条件被另一个线程更改,因此第一个线程将发生的行为是错误的。这是一个简单的例子:

public class Lock {

    private boolean isLocked = true;

    public void lock() {
        synchronized(this) {
            while(isLocked) {
                try {
                    this.wait();
                } catch(InterruptedException e) {
                    //do nothing, keep waiting
                }
            }
        }

        synchronized(this) {
            isLocked = true;
        }
    }

    public synchronized void unlock() {
        isLocked = false;
        this.notify();
    }
}

注意lock()方法包含两个同步块。第一个块等待直到isLocked为false。第二个块设置isLocked为true,以此来锁住这个Lock实例避免其它线程通过lock()方法。

想象一下isLocked是false,并且两个线程同时调用lock()。如果进入第一个同步块的第一个线程在执行完第一个同步块之后被抢占,线程已经检查了isLocked并记录它为false。如果现在允许第二个线程执行,从而进入第一个同步块,则该线程也将 isLocked视为false。现在两个线程都读取了这个条件为false,然后两个线程都能进入第二个同步块,设置isLocked为true,然后继续执行。

这种情况是slipped conditions的一个例子。两个线程都测试条件,测试完后退出synchronized块,从而允许其他线程测试条件,但这可能发生在第一个线程更改条件之前。换句话说,条件从被某个线程检查到该条件被此线程改变期间,已经被其它线程改变过了。

为了避免slipped conditions,条件的测试和设置必须由执行它的线程原子地完成,这意味着没有其他线程可以在第一个线程测试和设置条件期间检测这个条件。

上例中的解决方案很简单。只需将isLocked = true;这行向上移动到第一个同步块中,位于while循环后即可:

public class Lock {
    private boolean isLocked = true;

    public void lock() {
        synchronized(this) {
            while(isLocked) {
                try{
                    this.wait();
                } catch(InterruptedException e) {
                    //do nothing, keep waiting
                }
            }  
            isLocked = true;
        }
    }

    public synchronized void unlock() {
        isLocked = false;
        this.notify();
    }
}

现在,isLocked条件的测试和设置是在同一个同步块内部原子地完成的。

一个更现实的例子

您可能理所当然地认为您永远不会像本文中所示的第一个实现那样实现Lock,因此说slipped conditions是一个理论上的问题。但第一个例子相当简单,以更好地传解释slipped conditions的概念。

一个更现实的例子是在实现公平锁期间,正如“ 饥饿与公平 ”一文中所讨论的那样。如果我们从嵌套监视器锁死中查看其中的实现,并尝试接触嵌套的监视器锁死问题,很容易得到一个使用slipped conditions的实现。首先,我将展示嵌套的监视器锁死中的示例:

//Fair Lock implementation with nested monitor lockout problem

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();
        synchronized(this) {
            waitingThreads.add(queueObject);
            while(isLocked || waitingThreads.get(0) != queueObject) {
                synchronized(queueObject) {
                    try {
                        queueObject.wait();
                    } catch(InterruptedException e) {
                        waitingThreads.remove(queueObject);
                        throw e;
                    }
                }
            }
            waitingThreads.remove(queueObject);
            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;
        if(waitingThreads.size() > 0) {
            QueueObject queueObject = waitingThread.get(0);
            synchronized(queueObject) {
                queueObject.notify();
            }
        }  
    }
}

public class QueueObject {}

注意synchronized(queueObject)queueObject.wait()调用嵌套在synchronized(this)块内,导致嵌套的监视器锁死问题。为避免此问题,synchronized(queueObject)必须将块移到synchronized(this)块外 :

//Fair Lock implementation with slipped conditions problem

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();
        synchronized(this) {
            waitingThreads.add(queueObject);
        }

        boolean mustWait = true;
        while(mustWait) {
            synchronized(this) {
                mustWait = isLocked || waitingThreads.get(0) != queueObject;
            }

            synchronized(queueObject) {
                if(mustWait) {
                    try {
                        queueObject.wait();
                    } catch(InterruptedException e) {
                        waitingThreads.remove(queueObject);
                        throw e;
                    }
                }
            }
        }

        synchronized(this) {
            waitingThreads.remove(queueObject);
            isLocked = true;
            lockingThread = Thread.currentThread();
        }
    }
}

注意:仅显示lock()方法,因为它是我更改的唯一方法。

注意lock()方法现在包含3个同步块。

  • 第一个synchronized(this) 块通过设置mustWait = isLocked || waitingThreads.get(0) != queueObject检查条件。
  • 第二个synchronized(queueObject)块检查线程是否要等待。在这个时候,另一个线程可能已经释放了锁,但暂时别想这个问题。让我们假设锁已释放,因此线程立即退出synchronized(queueObject)块。
  • 第三个synchronized(this)块仅在mustWait = false时执行。它将做设置isLocked条件为true等操作然后离开lock()方法。

    想象一下,如果两个线程在其他线程解锁后同时调用lock()会发生什么。首先线程1检查isLocked条件并发现它为false,然后线程2执行相同的操作。然后它们都不会等待,并且两者都将isLocked状态设置为true。这是slipped conditions的一个好的例子。

    为了实现锁的公平性原则,要求只有等待队列中的第一个线程能够获取锁,从mustWait = isLocked || waitingThreads.get(0) != queueObject;可以看出。所以,当两个线程都在检查mustWait时,只有一个线程可以得到true,另一个线程会等待,不会出现slipped condition问题,此处为原文章中的错误,联系原作者尚未有回应。此处留下这个错误的示例,也只是为了更好的说明slipped condition,同时后文也会用到此代码示例说明其他问题,所以选择不删除,读者需要自己注意,不要被带入错误的代码中。

解决Slipped Conditions问题

要从上面的示例中解决Slipped Conditions问题,必须将最后一个synchronized(this)块的内容向上移动到第一个synchronized块中。代码自然也必须稍微改变一下,以适应这一举动:

//Fair Lock implementation without nested monitor lockout problem,
//but with missed signals problem.

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();

    synchronized(this){
      waitingThreads.add(queueObject);
    }

    boolean mustWait = true;
    while(mustWait){

        synchronized(this){
            mustWait = isLocked || waitingThreads.get(0) != queueObject;
            if(!mustWait){
                waitingThreads.remove(queueObject);
                isLocked = true;
                lockingThread = Thread.currentThread();
                return;
            }
        }

        synchronized(queueObject){
            if(mustWait){
                try{
                    queueObject.wait();
                }catch(InterruptedException e){
                    waitingThreads.remove(queueObject);
                    throw e;
                }
            }
        }
    }
  }
}

注意现在局部变量mustWait在同一个同步代码块中测试和设置。另请注意,即使局部变量mustWaitsynchronized(this)代码块之外也被检测了,比如在while(mustWait)中,mustWait变量的值也永远不会在synchronized(this)之外更改。检测mustWait为false的线程也会自动设置内部条件isLocked,以使其他线程检测mustWait时为true。

synchronized(this)块中的return;语句不是必需的,这只是一个小优化。如果mustWait == false,则没有理由进入synchronized(queueObject)块并执行if(mustWait)子句。

细心的读者会注意到上述公平锁的实现仍然存在信号丢失问题。想象一下,当线程调用lock()时,FairLock实例被锁定 。通过第一个synchronized(this)块之后mustWait为true。然后想象调用lock()的线程被抢占,持有锁的线程调用unlock()。如果你看一下前面unlock()的实现,你会发现它调用了queueObject.notify()。但是,由于在lock()等待的线程还没有调用queueObject.wait(),所以调用 queueObject.notify()也就没用了,信号丢失了。当之前的线程继续在lock()中调用queueObject.wait(),它将保持阻塞状态,直到其他线程调用unlock(),但是这可能永远不会发生。

信号丢失问题是Starvation和Fairness章节中FairLock的实现使用doWait()doNotify()两种方法将QueueObject类转换为一个信号量的原因。这些方法在QueueObject内部存储和响应信号。这样,即使doNotify()doWait()之前被调用,也不会丢失信号。