Basic Of Concurrency(十二: Nested Monitor Lockout)

685 阅读6分钟

Nested Monitor Lockout的发生

Nested Monitor Lockout问题类似于死锁,一个Nested Monitor Lockout的发生如下:

线程1 持有A对象锁进入同步块
线程1 持有B对象锁进入同步块(当成功持有A对象锁后)
线程1 调用B.wait()释放B对象锁,但不释放A对象锁
线程1 等待另一个线程(调用B.notify())发送信号以继续执行释放锁A

线程2 需要同时持有A对象锁和B对象锁才能给线程1发送信号
线程2 无法获取A对象锁,因为A对象锁已经被线程1持有
线程2 无限期的等待线程1释放A对象锁
线程1 无限期的等待线程2发送信号,以唤醒继续执行;因为线程2只有在持有A对象锁的情况才有办法给线程1发送唤醒信号

上面的描述看起来有点抽象,接下来我们来看一下一个简陋SimpleLock的实现:

public class SimpleLock {
    private class Monitor {

    }

    private final Monitor monitor = new Monitor();
    private boolean isLocked = false;

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

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

可以注意到在lock()方法中的第一个synchronized构造块中传入的是"this"。第二个synchronized构造块中传入的是成员变量monitorObject。当isLocked为false并不会有任何问题,线程不会调用到monitor.wait()。但当isLocked为true时,则线程会调用到while()循环内部的monitor.wait()进入等待状态。

这里的问题在于调用完monitor.wait()方法后,线程只释放了monitorObject对象锁,并没有释放“this”即当前SimpleLock实例对象锁。SimpleLock实例对象锁仍然被第一个线程持有。

当线程需要给lock()方法中的monitor.wait()发送信号时,它需要尝试获取this即当前SimpleLock实例对象锁来进入synchronized(this)同步代码块。此时它会无限期的等待着,因为SimpleLock实例对象锁一直被第一个线程持有且永远不会释放。因为第一个线程释放SimpleLock实例对象锁需要另一个线程给它发送唤醒信号来退出wait()方法和退出synchronized(this)同步代码块。

简而言之就是第一个线程在lock()方法的同步代码块中等待着另一个线程发送信号好让它退出同步代码块。另一个发送信号的线程则需要第一个线程退出lock()方法的同步代码块好让它进入unLock()方法中的同步代码块来发送信号给第一个线程。

最终的结果是无论哪个线程调用lock()和unLock()方法都会无限期的等待下去。这种问题我们称之为Nested Monitor Lockout。

一个更加真实的例子

你也许会觉得你永远不会像上文提及的例子那样来实现一个Lock,即不会在一个使用对象锁的同步代码块中调用wait()和notify(),但事实上这是真实发生的。当你设计的代码类似于上文提及实例中所遇到的情况时,问题就会发生了。如,在上一篇文章中实现一个FairLock的时候。当你希望每个线程都去调用队列中与之一一对应的对象的wait()方法,且在往后的时间里去调用该对象的notify()以此来唤醒对应的线程的时候。

一个公平锁的简单实现:

public class FairLock {
    private class QueueObject {
    }

    private boolean isLocked = false;
    private Thread lockingThread;
    private List<QueueObject> waitingThreads = new ArrayList<>();

    public void lock() throws InterruptedException {
        // 为每一个线程创建与之一一对应的对象锁
        QueueObject queueObject = new QueueObject();
        // 线程获得当前对象实例锁进入同步代码块
        synchronized (this) {
            // 将当前线程对应的对象锁添加到等待队列中
            waitingThreads.add(queueObject);
            // 判断当前FairLock是否为锁住对象,且判断等待队列中队头是否为当前线程对应的对象锁
            while (isLocked || waitingThreads.get(0) != queueObject) {
                // 获得当前线程对应的对象锁进入同步代码块
                synchronized (queueObject) {
                    try {
                        // 阻塞当前线程进入等待状态
                        queueObject.wait();
                    } catch (InterruptedException e) {
                        // 若线程意外唤醒,则从等待队列中移除
                        waitingThreads.remove(queueObject);
                        throw e;
                    }
                }
            }
            // 当前FairLock,当前线程是第一个进入该同步代码块的线程
            // 移除本线程在等待队列中对应的对象锁
            waitingThreads.remove(queueObject);
            // 取得FairLock锁
            isLocked = true;
            lockingThread = Thread.currentThread();
        }
    }

    public synchronized void unLock() {
        // 检查异常情况,当前线程并非持有FairLock线程
        if (lockingThread != null && lockingThread != Thread.currentThread()) {
            throw new IllegalMonitorStateException("Calling thread has not locked this lock");
        }
        // 释放锁
        isLocked = false;
        lockingThread = null;
        // 等待队列中有其他线程在等待取得FairLock
        if (waitingThreads.size() > 0) {
            // 取得队列头部线程与之对应的对象锁,
            final QueueObject queueObject = waitingThreads.get(0);
            // 取得该对象锁
            synchronized (queueObject) {
                // 唤醒对象锁对应的线程
                queueObject.notify();
            }
        }
    }
}

第一眼感觉这个实现没什么问题,但我们可以注意到处在lock()方法两个synchronized()代码块中调用queueObject.wait()的部分;实际上线程在执行完queueObject.wait()后仅会释放synchronized(queueObject)所持有的queueObjct对象锁,并不会释放synchronized(this)所持有的"this"即FairLock实例对象锁。

同样需要注意的是unLock()方法签名中声明有synchronized关键字,等同于synchronized(this)代码块。这意味着如果一个线程在lock()方法中的synchronized(this)代码块无限期的等待下去,那么其他线程将会被无限期的阻塞。调用unLock()方法的线程也会无限期的阻塞等待其他线程释放synchronized(this)所持有的锁以进入unLock()方法。一旦线程无法进入unLock()方法就无法给持有synchronized(this)对象锁的线程发送信号让它退出等待状态(退出wait()方法)从而退出synchronized(this)代码块。

可见,上文FairLock的实现能够带来Nested Monitor Lockout问题。一个改进的FairLock实现已经在饥饿与公平中提及。

Nested Monitor Lockout vs 死锁

从结果看Nested Monitor Lockout和死锁的情况十分类似:线程都会终结于互相等待彼此到永远。

当然它们也不是那么的相似。在线程死锁与预防中我们提到死锁会在两个线程以不同顺序获取相同的锁的情况下发生。线程1持有锁A,尝试获取锁B。线程2持有锁B,尝试获取锁A。同样在线程死锁与预防中,我们提到可以让线程在获取相同锁的时候以相同顺序的方式进行,以此来解决死锁问题。但实际上,Nested Monitor Lockout问题就是按照顺序来获取相同的锁。线程1持有锁A和锁B并且等待线程2发送信号。线程2需要获取锁A和锁B来给线程1发送信号。所以情况变成了一个线程在等待另一个线程发送信号,另一个线程则在等待它释放锁。

以下对两种情况进行描述:

在死锁中,两个线程互相等待对方释放自己所需要的锁。

在Nested Monitor Lockout中,线程1持有锁A,等待线程2发送信号。线程2需要锁A来给线程1发送信号。

该系列博文为笔者复习基础所著译文或理解后的产物,复习原文来自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial

上一篇: 饥饿与公平
下一篇: Slipped Conditions