Jakob Jenkov 2018-04-04
嵌套监视器锁死是如何发生的?
嵌套监视器锁死(Nested Monitor Lockout)是一种与死锁类似的问题。它通常按如下方式发生:
- 线程 1 对对象 A 加锁;
- 线程 1 在持有 A 的锁的同时,又对对象 B 加锁;
- 线程 1 决定在继续执行前等待另一个线程发来的信号;
- 线程 1 调用
B.wait(),从而释放了对 B 的锁,但没有释放对 A 的锁; - 线程 2 需要按顺序先锁住 A、再锁住 B,才能向线程 1 发送信号;
- 但线程 2 无法获取 A 的锁,因为线程 1 仍然持有它;
- 结果,线程 2 无限期地阻塞,等待线程 1 释放 A 的锁;
- 同时,线程 1 也无限期地阻塞,等待线程 2 发来的信号;而这个信号只有在线程 2 成功获取 A 和 B 的锁之后才能发送。
这就形成了一个循环依赖:线程 1 永远不会释放 A 的锁(因为它在等信号),而线程 2 永远无法发送信号(因为它拿不到 A 的锁)。最终,两个线程都陷入永久阻塞状态——这就是所谓的“嵌套监视器锁死”。
这听起来可能很理论化,但请看下面这个存在嵌套监视器锁死问题的朴素 Lock 实现:
// 存在嵌套监视器锁死问题的锁实现
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,一切正常;但如果 isLocked 为 true,调用 lock() 的线程就会在 monitorObject.wait() 处挂起。
问题在于:monitorObject.wait() 只会释放 monitorObject 上的锁,而不会释放 this 上的锁。也就是说,挂起的线程仍然持有 this 的监视器锁。
当最初获得锁的线程试图通过调用 unlock() 来释放锁时,它会尝试进入 unlock() 方法中的 synchronized(this) 块,但由于挂起线程仍持有 this 的锁,该线程会被阻塞。
简而言之:
- 挂起的线程需要
unlock()成功执行才能退出lock()方法; - 但任何线程都无法成功执行
unlock(),因为挂起线程尚未释放this的锁。
结果就是:所有调用 lock() 或 unlock() 的线程都会无限期阻塞。这就是“嵌套监视器锁死”。
一个更现实的例子
你可能会说:“我绝不会写出上面那种锁!”确实,通常我们不会在一个内部监视器对象上调用 wait() 和 notify(),而是直接在 this 上操作。
然而,在某些设计场景中,这种结构确实可能出现。例如,当你试图实现一个公平锁(Fair 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();
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() 方法在两个嵌套的 synchronized 块中调用了 queueObject.wait() —— 外层同步于 this,内层同步于局部变量 queueObject。
当线程调用 queueObject.wait() 时,它只释放了 queueObject 的锁,并未释放 this 的锁。
同时,unlock() 方法被声明为 synchronized,等价于 synchronized(this)。这意味着:
- 如果某个线程正在
lock()中等待,它就一直持有this的锁; - 所有调用
unlock()的线程都会因无法进入synchronized(this)而永久阻塞; - 而只有成功执行
unlock()才能唤醒等待线程; - 但
unlock()又永远无法执行……
于是,这个“公平锁”反而导致了嵌套监视器锁死。
更好的公平锁实现,请参阅 《饥饿与公平性》 一文。
嵌套监视器锁死 vs 死锁
嵌套监视器锁死和死锁的结果非常相似:相关线程都永久阻塞,互相等待。
但二者成因不同:
死锁:通常发生在两个线程以不同顺序获取多个锁时。
例如:线程 1 锁 A 等 B,线程 2 锁 B 等 A。
解决方法:始终以相同顺序加锁(锁排序)。嵌套监视器锁死:恰恰发生在两个线程以相同顺序获取锁的情况下。
例如:线程 1 锁 A 和 B,然后释放 B 并等待信号;线程 2 需要同时持有 A 和 B 才能发信号。
但线程 1 仍持有 A,导致线程 2 无法获取 A,也就无法发信号;而线程 1 又在等这个信号……
总结区别:
| 类型 | 等待内容 |
|---|---|
| 死锁 | 两个线程互相等待对方释放锁 |
| 嵌套监视器锁死 | 线程 1 持有锁 A 并等待线程 2 的信号;线程 2 需要锁 A 才能发送该信号 |