Jakob Jenkov 2014-06-23
锁是一种线程同步机制,类似于 synchronized 块,但锁可以比 Java 的 synchronized 块更复杂。锁(以及其他更高级的同步机制)是使用 synchronized 块实现的,因此我们并不能完全摆脱 synchronized 关键字。
从 Java 5 开始,java.util.concurrent.locks 包中包含了多个锁的实现,因此你可能不需要自己实现锁。但你仍然需要知道如何使用它们,并且了解其背后的实现原理仍然是有益的。
一个简单的锁(A Simple Lock)
我们先来看一段使用 synchronized 块的 Java 代码:
public class Counter {
private int count = 0;
public int inc() {
synchronized(this) {
return ++count;
}
}
}
注意 inc() 方法中的 synchronized(this) 块。该块确保同一时间只有一个线程能执行 return ++count。虽然同步块中的代码可以更复杂,但这里简单的 ++count 已足以说明问题。
也可以用 Lock 替代 synchronized 块来重写 Counter 类:
public class Counter {
private Lock lock = new Lock();
private int count = 0;
public int inc() {
lock.lock();
int newCount = ++count;
lock.unlock();
return newCount;
}
}
lock() 方法会锁定 Lock 实例,使得所有调用 lock() 的线程都被阻塞,直到执行了 unlock()。
下面是一个简单的 Lock 实现:
public class Lock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
public synchronized void unlock() {
isLocked = false;
notify();
}
}
注意 while(isLocked) 循环,也称为“自旋锁”(spin lock)。自旋锁以及 wait() 和 notify() 方法在 线程通信(Thread Signaling) 一文中会有更详细的介绍。
当 isLocked 为 true 时,调用 lock() 的线程会在 wait() 调用中被挂起等待。如果线程在未收到 notify() 调用的情况下意外地从 wait() 返回(即发生 虚假唤醒(Spurious Wakeup)),线程会重新检查 isLocked 条件,以判断是否安全继续执行,而不是假设被唤醒就意味着可以继续。如果 isLocked 为 false,线程将退出 while(isLocked) 循环,并将 isLocked 重新设为 true,从而为其他调用 lock() 的线程锁定该 Lock 实例。
当线程完成临界区(critical section,即 lock() 和 unlock() 之间的代码)的操作后,会调用 unlock()。执行 unlock() 会将 isLocked 重置为 false,并通知(唤醒)在 lock() 方法中 wait() 调用里等待的某个线程(如果有的话)。
锁的可重入性(Lock Reentrance)
Java 中的 synchronized 块是可重入的(reentrant)。这意味着,如果一个 Java 线程进入了一个同步代码块,从而获得了该同步块所关联监视器对象的锁,那么该线程可以进入其他同样基于该监视器对象同步的代码块。
示例如下:
public class Reentrant {
public synchronized void outer() {
inner();
}
public synchronized void inner() {
// do something
}
}
注意 outer() 和 inner() 都声明为 synchronized,这在 Java 中等价于 synchronized(this) 块。如果一个线程调用了 outer(),那么从 outer() 内部调用 inner() 不会有任何问题,因为两个方法(或块)都是在同一个监视器对象(this)上同步的。如果一个线程已经持有了某个监视器对象的锁,它就有权访问所有在该监视器对象上同步的代码块。这被称为可重入性(reentrance)——线程可以重新进入它已持有锁的任何代码块。
然而,即使 synchronized 块是可重入的,前面展示的 Lock 类却不是可重入的。如果我们像下面这样重写 Reentrant 类,那么调用 outer() 的线程将在 inner() 方法中的 lock.lock() 处被阻塞:
public class Reentrant2 {
Lock lock = new Lock();
public void outer() {
lock.lock();
inner();
lock.unlock();
}
public void inner() {
lock.lock();
// do something
lock.unlock();
}
}
调用 outer() 的线程首先会锁定 Lock 实例,然后调用 inner()。在 inner() 方法内部,该线程会再次尝试锁定同一个 Lock 实例。这将失败(即线程会被阻塞),因为在 outer() 方法中该 Lock 实例已经被锁定了。
之所以第二次调用 lock() 会被阻塞(在没有先调用 unlock() 的情况下),从 lock() 的实现中可以看出原因:
public class Lock {
boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
...
}
决定线程是否允许退出 lock() 方法的条件就是 while 循环中的判断。当前的条件是:只有当 isLocked 为 false 时才允许退出,而不管是谁锁定了它。
要使 Lock 类支持可重入,我们需要做一点小改动:
public class Lock {
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;
public synchronized void lock() throws InterruptedException {
Thread callingThread = Thread.currentThread();
while (isLocked && lockedBy != callingThread) {
wait();
}
isLocked = true;
lockedCount++;
lockedBy = callingThread;
}
public synchronized void unlock() {
if (Thread.currentThread() == this.lockedBy) {
lockedCount--;
if (lockedCount == 0) {
isLocked = false;
notify();
}
}
}
...
}
注意现在 while 循环(自旋锁)也考虑了锁定 Lock 实例的线程。如果锁未被占用(isLocked == false)或者当前调用线程就是持有该锁的线程,那么 while 循环就不会执行,调用 lock() 的线程将被允许退出该方法。
此外,我们还需要记录同一个线程对锁的加锁次数。否则,一次 unlock() 调用就会释放锁,即使该锁已被同一线程多次加锁。我们不希望锁被释放,除非持有它的线程执行了与 lock() 调用次数相等的 unlock() 调用。
经过上述修改,Lock 类现在就支持可重入了。
锁的公平性(Lock Fairness)
Java 的 synchronized 块不保证试图进入它的线程获得访问权限的顺序。因此,如果有多个线程持续竞争同一个 synchronized 块的访问权,就存在某些线程永远无法获得访问权的风险——访问权总是被授予其他线程。这种情况称为饥饿(starvation)。
为了避免饥饿,锁应当具备公平性(fairness)。由于本文展示的 Lock 实现在内部使用了 synchronized 块,因此它们不保证公平性。关于饥饿和公平性的更多讨论,请参见 饥饿与公平性(Starvation and Fairness)。
在 finally 子句中调用 unlock()(Calling unlock() From a finally-clause)
当使用 Lock 保护临界区,且临界区代码可能抛出异常时,必须在 finally 子句中调用 unlock() 方法。这样做可以确保 Lock 被正确释放,以便其他线程可以获取它。
示例如下:
lock.lock();
try {
// 执行临界区代码,可能会抛出异常
} finally {
lock.unlock();
}
这个小结构确保了即使临界区代码抛出异常,Lock 也会被解锁。如果 unlock() 没有放在 finally 子句中,而临界区又抛出了异常,那么 Lock 将永远保持锁定状态,导致所有在该 Lock 实例上调用 lock() 的线程无限期地阻塞。