同步器的解剖
即使许多同步器(锁,信号量,阻塞队列等)在功能上有所不同,它们的内部设计通常也没有太大不同。换句话说,它们在内部由相同(或者相似)的基本部分组成。在设计同步器时,了解这些基本部分会很有帮助。本文正是这些部分的重点。
注意:本文的内容是M.Sc.的部分结果。 Hyman,Toke Johansen和Lars Bjrn于2004年春季在哥本哈根IT大学的一个学生项目。在这个项目中,我们问道格·李(Doug Lea)是否知道类似的工作。有趣的是,在Java 5并发实用程序的开发过程中,他独立于该项目也得出了类似的结论。
大多数(如果不是全部)同步器的目的是保护代码的某些区域(关键部分)免受线程的并发访问。为此,同步器中通常需要以下部分:
- 状态
- 访问条件
- 状态变更
- 通知策略
- 测试和设定方法
- 设定方法
并非所有同步器都具有所有这些部分,而那些同步器可能不具有与此处所述完全相同的部分。通常,我们通常可以找到其中一个或者多个部分。
状态
访问条件使用同步器的状态来确定是否可以授予线程访问权限。在"锁定"状态下,状态保持为"布尔"状态,表示"锁定"是否被锁定。在有界信号灯中,内部状态保存在一个计数器(int)和一个上限(int)中,该状态指示当前的" takes"数量和" takes"的最大数量。在阻塞队列中,状态保存在队列中元素的"列表"中,以及最大队列大小(int)成员(如果有)。
这是来自" Lock"和" BoundedSemaphore"的两个代码片段。状态代码以粗体标记。
public class Lock{ //state is kept here private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } isLocked = true; } ... }
public class BoundedSemaphore { //state is kept here 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.signal++; this.notify(); } ... }
访问条件
访问条件是确定是否可以允许调用test-and-set-state方法的线程设置状态的条件。访问条件通常基于同步器的状态。通常在while循环中检查访问条件,以防止虚假唤醒。评估访问条件时,它是true还是false。
在Lock中,访问条件只是检查isLocked
成员变量的值。在绑定信号灯中,实际上有两种访问条件,具体取决于我们是要"获取"还是"释放"信号灯。如果线程尝试使用信号量,则将对" signals"变量进行上限检查。如果线程尝试释放信号量,则将signals
变量与0进行检查。
这是Lock
和BoundedSemaphore
的两个代码段,访问条件以粗体标出。请注意,如何始终在while循环内检查条件。
public class Lock{ private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ //access condition 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{ //access condition while(this.signals == bound) wait(); this.signals++; this.notify(); } public synchronized void release() throws InterruptedException{ //access condition while(this.signals == 0) wait(); this.signals--; this.notify(); } }
状态变更
一旦线程获得对关键部分的访问权,它就必须更改同步器的状态,以(可能)阻止其他线程进入该同步器。换句话说,状态需要反映一个事实,即线程现在正在关键部分内部执行。这将影响其他尝试获得访问权限的线程的访问条件。
在锁中,状态更改是代码设置" isLocked = true"。在信号量中,或者是代码" signals--",或者是" signals ++"。
以下是两个代码片段,其中状态更改代码以粗体标出:
public class Lock{ private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } //state change isLocked = true; } public synchronized void unlock(){ //state change 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(); //state change this.signals++; this.notify(); } public synchronized void release() throws InterruptedException{ while(this.signals == 0) wait(); //state change this.signals--; this.notify(); } }
通知策略
一旦线程更改了同步器的状态,有时可能需要将状态更改通知其他正在等待的线程。也许此状态更改可能会使其他线程的访问条件变为true。
通知策略通常分为三类。
- 通知所有等待的线程。
- 通知1个随机的N个等待线程。
- 通知1个特定的N个等待线程。
通知所有正在等待的线程非常容易。所有等待线程在同一对象上调用wait()
。一旦线程想要通知等待线程,它将在对象上调用notifyAll()
,等待线程称为wait()
。
通知一个随机的等待线程也很容易。只需在等待线程已调用wait()
的对象上进行通知线程调用notify()
。调用notify
不能保证将通知哪个等待线程。因此,术语"随机等待线程"。
有时我们可能需要通知特定的线程而不是随机的等待线程。例如,如果我们需要保证以特定顺序通知正在等待的线程,可以是它们调用同步器的顺序,也可以是某些优先顺序。为了达到这个目的,每个等待线程必须在其自己的单独对象上调用wait()
。当通知线程要通知特定的等待线程时,它将在该特定线程已调用" wait()"的对象上调用" notify()"。在"饥饿与公平"一文中可以找到一个例子。
以下是带有粗体标记的通知策略(通知1个随机等待线程)的代码段:
public class Lock{ private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ while(isLocked){ //wait strategy - related to notification strategy wait(); } isLocked = true; } public synchronized void unlock(){ isLocked = false; notify(); //notification strategy } }
测试和设定方法
同步器通常有两种类型的方法,其中" test-and-set"是第一种类型(" set"是另一种类型)。测试并设置意味着调用此方法的线程将根据访问条件测试同步器的内部状态。如果满足条件,则线程将设置同步器的内部状态以反映该线程已获得访问权限。
状态转换通常会导致其他尝试获取访问权限的线程的访问条件变为假,但可能并非总是如此。例如,在读写锁中,获得读访问权限的线程将更新读写锁的状态以反映此状态,但是只要没有线程请求写访问权限,其他请求读访问权限的线程也将被授予访问权限。
必须以原子方式执行测试设置操作,这意味着在测试和状态设置之间不允许在测试设置方法中执行其他线程。
测试设置方法的程序流程通常类似于以下内容:
- 必要时在测试前设置状态
- 根据访问条件测试状态
- 如果不符合访问条件,请等待
- 如果满足访问条件,请设置状态,并在必要时通知等待线程
下面显示的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方法仅设置同步器的内部状态,而无需先对其进行测试。 set方法的一个典型示例是Lock类的unlock()方法。持有锁的线程可以始终将其解锁,而无需测试"锁"是否已解锁。
设置方法的程序流通常遵循以下原则:
- 设定内部状态
- 通知等待线程
这是一个示例unlock()方法:
public class Lock{ private boolean isLocked = false; public synchronized void unlock(){ isLocked = false; notify(); } }