Jakob Jenkov 2014-10-01
即使许多同步器(如锁、信号量、阻塞队列等)在功能上各不相同,它们的内部设计往往并没有那么大的差异。换句话说,它们通常由相同(或相似)的基本部分组成。了解这些基本组成部分,对于设计同步器将大有裨益。本文将深入探讨这些组成部分。
大多数同步器的目的,都是保护代码中的某个区域(临界区)免受多个线程的并发访问。为此,同步器通常需要以下组成部分:
- 状态(State)
- 访问条件(Access Condition)
- 状态变更(State Changes)
- 通知策略(Notification Strategy)
- 测试并设置方法(Test and Set Method)
- 设置方法(Set Method)
并非所有同步器都包含上述全部部分,即使包含,其具体实现也可能与本文描述略有不同。但通常你总能找到其中的一个或多个部分。
状态(State)
同步器的状态用于访问条件判断线程是否可以被授予访问权限。
- 在 Lock(锁) 中,状态以一个
boolean变量表示,说明该锁当前是否已被锁定。 - 在 有界信号量(Bounded Semaphore) 中,内部状态由一个计数器(
int signals)和一个上限值(int bound)组成,分别表示当前“获取”次数和最大允许“获取”次数。 - 在 阻塞队列(Blocking Queue) 中,状态由队列中元素的
List和最大队列容量(int,如果有的话)共同表示。
以下是 Lock 和 BoundedSemaphore 的代码片段,其中状态部分已加粗:
public class Lock {
// 状态保存在这里
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
...
}
public class BoundedSemaphore {
// 状态保存在这里
private int signals = 0;
private int bound = 0;
public BoundedSemaphore(int upperBound) {
this.bound = upperBound;
}
public synchronized void take() throws InterruptedException {
while (this.signals == bound)
wait();
this.signals++;
this.notify();
}
...
}
访问条件(Access Condition)
访问条件决定了调用“测试并设置状态”方法的线程是否被允许修改状态。访问条件通常基于同步器的状态进行判断,并且通常在一个 while 循环中进行检查,以防止虚假唤醒(Spurious Wakeups)。访问条件的评估结果要么为 true,要么为 false。
- 在 Lock 中,访问条件就是简单地检查
isLocked成员变量的值。 - 在 有界信号量(Bounded Semaphore) 中,实际上存在两个访问条件,取决于线程是尝试“获取”还是“释放”信号量:
- 如果尝试获取,则将
signals与上限bound比较; - 如果尝试释放,则将
signals与 0 比较。
- 如果尝试获取,则将
以下是 Lock 和 BoundedSemaphore 的代码片段,其中访问条件已加粗。注意,这些条件始终在 while 循环内进行检查。
public class Lock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
// 访问条件
while (isLocked) {
wait();
}
isLocked = true;
}
...
}
public class BoundedSemaphore {
private int signals = 0;
private int bound = 0;
public BoundedSemaphore(int upperBound) {
this.bound = upperBound;
}
public synchronized void take() throws InterruptedException {
// 访问条件
while (this.signals == bound)
wait();
this.signals++;
this.notify();
}
public synchronized void release() throws InterruptedException {
// 访问条件
while (this.signals == 0)
wait();
this.signals--;
this.notify();
}
}
状态变更(State Changes)
一旦线程获得对临界区的访问权限,它就必须更改同步器的状态,以(可能)阻止其他线程进入。换句话说,状态必须反映出当前有线程正在临界区内执行。这会影响其他试图获取访问权限的线程的访问条件。
- 在 Lock 中,状态变更是将
isLocked = true。 - 在信号量中,状态变更是
signals--或signals++。
以下是加粗标出状态变更部分的代码片段:
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();
}
}
public class BoundedSemaphore {
private int signals = 0;
private int bound = 0;
public BoundedSemaphore(int upperBound) {
this.bound = upperBound;
}
public synchronized void take() throws InterruptedException {
while (this.signals == bound)
wait();
// 状态变更
this.signals++;
this.notify();
}
public synchronized void release() throws InterruptedException {
while (this.signals == 0)
wait();
// 状态变更
this.signals--;
this.notify();
}
}
通知策略(Notification Strategy)
当一个线程更改了同步器的状态后,有时需要通知其他等待中的线程该状态已发生变化。因为这一变化可能会使其他线程的访问条件变为 true。
通知策略通常分为三类:
通知所有等待线程
所有等待线程都在同一个对象上调用wait()。通知线程只需在该对象上调用notifyAll()。通知 N 个等待线程中的一个随机线程
通知线程在等待线程调用wait()的对象上调用notify()。由于notify()不保证唤醒哪一个线程,因此称为“随机”。通知 N 个等待线程中的某一个特定线程
例如,若需确保按特定顺序(如调用顺序或优先级顺序)唤醒线程,则每个等待线程必须在自己独立的对象上调用wait()。通知线程则在目标线程对应的对象上调用notify()。相关示例可参见 饥饿与公平性(Starvation and Fairness)。
以下代码片段展示了“通知一个随机等待线程”的策略(已加粗):
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(); // 通知策略
}
}
测试并设置方法(Test and Set Method)
同步器通常包含两种类型的方法,其中第一种就是“测试并设置”方法(另一种是设置方法)。
“测试并设置”意味着:调用该方法的线程首先根据访问条件测试同步器的内部状态;如果条件满足,则设置内部状态,以反映该线程已获得访问权限。
这种状态转换通常会导致其他线程的访问条件变为 false,但并非总是如此。例如,在 读写锁(Read-Write Lock) 中,一个线程获得读访问权限后,会更新读写锁的状态,但只要没有线程请求写访问,其他请求读访问的线程仍可被授予访问权限。
关键点:测试与设置操作必须是原子的,即在测试和设置之间不能有其他线程插入执行。
“测试并设置”方法的典型流程如下:
- (如有必要)在测试前先设置状态;
- 将状态与访问条件进行比较;
- 若访问条件不满足,则等待;
- 若访问条件满足,则设置状态,并在必要时通知等待线程。
以下 ReadWriteLock 类中的 lockWrite() 方法就是一个“测试并设置”方法的示例。调用 lockWrite() 的线程首先在测试前设置状态(writeRequests++),然后在 canGrantWriteAccess() 方法中测试内部状态是否满足访问条件。如果测试成功,则在方法退出前再次设置状态。注意,此方法并未通知等待线程。
public class ReadWriteLock {
private Map<Thread, Integer> readingThreads = new HashMap<Thread, Integer>();
private int writeAccesses = 0;
private int writeRequests = 0;
private Thread writingThread = null;
...
public synchronized void lockWrite() throws InterruptedException {
writeRequests++;
Thread callingThread = Thread.currentThread();
while (!canGrantWriteAccess(callingThread)) {
wait();
}
writeRequests--;
writeAccesses++;
writingThread = callingThread;
}
...
}
下面的 BoundedSemaphore 类包含两个“测试并设置”方法:take() 和 release()。这两个方法都会测试并设置内部状态。
public class BoundedSemaphore {
private int signals = 0;
private int bound = 0;
public BoundedSemaphore(int upperBound) {
this.bound = upperBound;
}
public synchronized void take() throws InterruptedException {
while (this.signals == bound)
wait();
this.signals++;
this.notify();
}
public synchronized void release() throws InterruptedException {
while (this.signals == 0)
wait();
this.signals--;
this.notify();
}
}
设置方法(Set Method)
设置方法是同步器通常包含的第二种方法类型。与“测试并设置”不同,设置方法直接修改同步器的内部状态,而无需事先测试。典型的例子是 Lock 类中的 unlock() 方法:持有锁的线程总是可以直接解锁,而无需检查锁是否已被解锁。
设置方法的典型流程如下:
- 设置内部状态;
- 通知等待线程。
以下是一个 unlock() 方法的示例:
public class Lock {
private boolean isLocked = false;
public synchronized void unlock() {
isLocked = false;
notify();
}
}