这一章介绍基于状态条件同步的一般场景,以及通过条件队列来构建自定义同步机制的方法。最后介绍JDK同步器的公共抽象—AQS的基本原理,以及它在Java并发库内的应用。
状态条件
一般来说,线程之间的同步都是围绕状态条件展开的,以”容量有限队列“为例,take操作必须在队列满足“nonempty“状态条件下才能执行;如果不满足条件,take操作要么失败,要么阻塞。构建基于状态、具备并发同步功能的类,最容易的方式是使用现有的同步装置,下使用CountDownLatch构建一个二元的同步装置ValueLatch,用来同步一个value的初始化。
@ThreadSafe
public class ValueLatch<T> {
@GuardedBy("this") private T value = null
private final CountDownLatch done = new CountDownLatch(1);
public boolean isSet() {
return (done.getCount() == 0);
}
public synchronized void setValue(T newValue) {
if (!isSet()) {
value = newValue;
done.countDown();
}
}
public T getValue() throws InterruptedException {
done.await();
synchronized (this) {
return value;
}
}
}
synchronized锁,也可被认为是一种特殊的0/1状态条件的同步器,所以上面的代码实际上用了两个同步器,CountDownLatch用来同步“初始化”,synchronized用来同步字段读写。
如果现有的同步器不满足需求,可以尝试使用条件队列、AbstractQueuedSynchronizer等更底层的同步装置,本章介绍实现“基于状态条件的同步器”的各种可选技术方案。
状态条件阻塞
在单线程程序中,类在执行操作时如果发现条件不满足,只能失败返回;但在一个并发程序中,还可以选择等待,其他线程会改变状态并使条件满足。”阻塞线程以等待条件满足“通常是一个更好的选择,否则使用者需要处理失败并进行重试;而如何进行重试对使用者来说是一个进退两难的问题。
使用基于状态的阻塞机制采用以下伪码所展示的工作模式:
1: acquire lock on object state
2: while (precondition does not hold) {
3: release lock
4: wait until precondition might hold
5: optionally fail if interrupted or timeout expires
6: reacquire lock
7: }
8: perform action
9: release lock
这段伪码展示了基于状态的阻塞的基本机制,值得仔细琢磨:
- line 1:获取保护状态的锁,因为需要读写状态,为了线程安全,需要先持有锁;
- line 2:while循环的条件是:条件尚未满足,之所有用循环而不是简单的if,是当线程从阻塞状态唤醒时,有可能有其他线程又修改了状态使得条件不满足;
- line 3:在线程挂起前释放锁,如果不释放锁,其他线程就无法修改状态以使条件发生变化;
- line 4:线程挂起直到条件满足,一般是其他线程修改状态后唤醒该线程;
- line 5:按选项,阻塞可能由于线程中断或超时而失败(直接返回失败,跳出循环);
- line 6:重新获取锁,因为要回到第二行;
- line 7:执行业务操作;
- line 8:释放锁,结束同步代码块。
Bounded Buffer示例
接下来用一个类似ArrayBlockingQueue的容量有限队列,展示各种同步实现方式。这个类有两个主要操作:take和put,在队列满的情况下,put操作需要阻塞;反之,在队列空的情况下,take操作要阻塞。
为了方便各种同步方式的实现,先编写了一个基类如下,由子类来编写可阻塞的put&take方法:
@ThreadSafe
public abstract class BaseBoundedBuffer<V> {
@GuardedBy("this") private final V[] buf;
@GuardedBy("this") private int tail;
@GuardedBy("this") private int head;
@GuardedBy("this") private int count;
protected BaseBoundedBuffer(int capacity) {
this.buf = (V[]) new Object[capacity];
}
protected synchronized final void doPut(V v) {
buf[tail] = v;
if (++tail == buf.length)
tail = 0;
++count;
}
protected synchronized final V doTake() {
V v = buf[head];
buf[head] = null;
if (++head == buf.length)
head = 0;
--count;
return v;
}
public synchronized final boolean isFull() {
return count == buf.length;
}
public synchronized final boolean isEmpty() {
return count == 0;
}
}
非阻塞版本
第一个实现版本是非阻塞的:
@ThreadSafe
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
public GrumpyBoundedBuffer(int size) { super(size); }
public synchronized void put(V v) throws BufferFullException {
if (isFull())
throw new BufferFullException();
doPut(v);
}
public synchronized V take() throws BufferEmptyException {
if (isEmpty())
throw new BufferEmptyException();
return doTake();
}
}
它使用与基类一致的锁策略(synchronized),实现非常简单,但是很难用。如果使用者需要实现等待状态的功能,大体只能按以下方式:
while (true) {
try {
V item = buffer.take();
// use item
break;
} catch (BufferEmptyException e) {
Thread.sleep(SLEEP_GRANULARITY);
}
}
在捕获BufferEmptyException之后,线程不知道该等待多久,SLEEP_GANULARITY过小会导致忙等待,浪费CPU;SLEEP_GANULARITY过大,则损害程序响应性。
条件队列(Condition Queue)
条件队列为”状态条件的等待、通知“建立了一个标准的模式,它之所以称之为条件队列,是因为它将等待某个条件的线程组织成一个队列。条件队列总是与一个锁关联,因为条件状态需要锁的保护;每个java对象可被视为一个条件队列,叫内置条件队列,它与监视器锁相关联。相关的接口是Object类的wait,notify,notifyAll等方法。
下面是使用内置条件队列实现的BoundedBuffer版本:
@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
// CONDITION PREDICATE: not-full (!isFull())
// CONDITION PREDICATE: not-empty (!isEmpty())
public BoundedBuffer(int size) { super(size); }
// BLOCKS-UNTIL: not-full
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait();
doPut(v);
notifyAll();
}
// BLOCKS-UNTIL: not-empty
public synchronized V take() throws InterruptedException {
while (isEmpty())
wait();
V v = doTake();
notifyAll();
return v;
}
}
上面的代码是一个生产环境可用的阻塞队列,put方法进入方法体之后已经持有内置锁,wait操作内部会暂时释放锁;doPut成功之后,ifEmpty谓词可能发生变化,因此调用notifyAll方法来通知状态队列内的所有等待线程恢复执行(重新计算条件谓词)。
条件谓词
示例代码中的isEmpty、isFull是”条件谓词“,用于判断某个状态条件是否满足。对开发者来说,清晰的条件谓词,是正确使用条件队列的关键,这也是内置条件队列经常令人迷惑的原因,因为诸如Object.wait、Object.notify这样的方法,完全没有表达出“状态”,“”条件谓词“”到底是什么。
使用条件队列,一定要通过文档或注释,讲清楚谓词逻辑,就像上面的BoundedBuffer一样。
waiting
Object.wait方法返回,并不代表条件谓词一定成立。首先,多个条件谓词可能共享一个条件队列(BoundedBuffer的isEmpty和isFull共享内置条件队列),等待条件A的线程可能由于条件B的状态改变而被唤醒,;其次,由于并发,在线程被唤醒到重新获得锁之间的间隙,其他线程可能已经修改了条件状态。
因此在使用条件队列的等待操作,要注意遵循以下规则:
- 有明确的条件谓词(判断对象状态是否满足条件);
- 在wait之前执行条件谓词,wait之后也立即执行条件谓词;
- 将上面两个操作组成一个循环体;
- 条件谓词涉及的状态变量,应当被对应的锁保护;
- 调用wait,notify,notifyAll的时候,必须持有锁;(Java编译器已经保证了这一点)
- 在条件谓词检查成功之后,不要立即释放锁,要等状态变量使用完之后再释放。
上面伪码和BoundedBuffer遵循了上面的规则,用while循环来检查条件谓词和wait。
notification
任何操作,只要修改状态使得条件谓词成立,必须调用条件队列的通知方法,内置条件队列是Object.notify或Object.notifyAll,来唤醒等待线程。BoundedBuffer的put和take都可能导致条件谓词发生变化,因此调用了Object.notifyAll。
notify和notifyAll方法区别在于,前者让JVM自动选择一个等待线程唤醒,后者唤醒所有等待线程。执行notify和notifyAll需要持有锁,而被唤醒的线程需要重新获得锁来执行,因此方法在调用notify和notifyAll之后要尽快释放锁。
BoundedBuffer不能使用notify,必须使用notifyAll,因为条件队列有两个条件谓词,如果条件谓词C1成立,却唤醒了等待条件谓词C2的线程,可能导致死锁。要使用notify必须满足两个条件:
- 一致的等待者:所有的wait线程都在等待同一个条件谓词,并且在条件满足后,执行相同的逻辑;
- 一进一出:一个通知只能满足一个等待线程。
绝大多数类都不满足以上这两个条件,因此流行的建议是一律使用notifyAll。不过使用notifyAll确实可能导致更多的上下文切换、锁竞争,这是安全性和可伸缩性发生矛盾的一个案例。
可以从另一个角度对通知方式进行优化:仅在条件谓词从false变为true的时候才发出通知,这叫做"conditional notification"。BoundedBuffer.put方法可以按这个思想进行优化如下。
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait()
boolean wasEmpty = isEmpty();
doPut(v);
if (wasEmpty)
notifyAll();
}
无论是使用notify,还是"conditional notification",都增加了编码、维护的难度,仅在测试证明确实需要优化时,才有必要采取这样的手段。
Condition对象
就像Lock类型是内置锁的一般化,Condition类是内置条件队列的一般化,提供了更丰富的功能。对于内置条件队列,每个锁只能有一个队列,如果有多个条件谓词,只能共享这个队列,影响性能。
Lock.newCondition方法创建与该锁关联的Condition对象,Condition的接口定义如下:
public interface Condition {
void await() throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
一个Lock可以创建多个关联的Condition对象,Condition队列的公平性由锁的公平性来决定,Condition的await,signal,signalAll方法分别相当于内置条件队列的wait,notify,notifyAll方法。
Condition也是Object,也支持wait,notify,notifyAll方法,使用时要注意。
下面用Condition来改进BoundedBuffer如下(省略了take方法):
@ThreadSafe
public class ConditionBoundedBuffer<T> {
protected final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: notFull (count < items.length)
private final Condition notFull = lock.newCondition();
// CONDITION PREDICATE: notEmpty (count > 0)
private final Condition notEmpty = lock.newCondition();
@GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
@GuardedBy("lock") private int tail, head, count;
// BLOCKS-UNTIL: notFull
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[tail] = x;
if (++tail == items.length)
tail = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
}
ConditionBoundedBuffer与BoundedBuffer的功能完全一致,它通过两个Condition对象分别对应两个条件谓词,满足了用signal取代signalAll的条件。此外,通过变量命名(notFull¬Empty),表达出Condition对象代表的条件谓词,比使用内置条件队列有更好的代码可读性、可维护性。
与Java监视器锁被推荐使用不同,内置条件队列由于其模糊性,不被推荐使用,建议用Condition来取代。
解密同步器(AQS)
ReentrantLock和Semaphore有很多的共同点,它们都起到类似”门禁“的作用,在同一时刻只允许有限数量的线程通过;而且二者都提供不可中断、可中断、限时的加锁(获取)操作,甚至都可以指定公平或不公平策略。这样一来,你或许认为Semaphore是基于ReentrantLock实现的,或者反过来ReentrantLock是基于二元Semaphore实现的;实际上,这都是完全可行的,下面的SemaphoreOnLock展示了如何通过ReentrantLock实现Semaphore。
@ThreadSafe
public class SemaphoreOnLock {
private final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: permitsAvailable (permits > 0)
private final Condition permitsAvailable = lock.newCondition();
@GuardedBy("lock") private int permits;
SemaphoreOnLock(int initialPermits) {
lock.lock();
try {
permits = initialPermits;
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: permitsAvailable
public void acquire() throws InterruptedException {
lock.lock();
try {
while (permits <= 0)
permitsAvailable.await();
--permits;
} finally {
lock.unlock();
}
}
public void release() {
lock.lock();
try {
++permits;
permitsAvailable.signal();
} finally {
lock.unlock();
}
}
}
不过真实的情况是:ReentrantLock和Semaphore都有一个叫做AbstractQueuedSynchronizer(AQS)公共基类。AQS是一个用来构建锁和同步器的框架,它有广泛的应用,CountDownLatch、ReentrantReadWriteLock也是基于AQS实现的;很多第三方库也基于AQS来实现满足特定需求的同步器。
AQS概述
AQS实现了同步器的通用功能,比如它内含一个FIFO阻塞线程队列,处理了线程阻塞和恢复的细节等;具体同步器实现只需要关注什么情况下线程通过、什么情况下阻塞、什么情况下唤醒阻塞的线程。AQS是一个高度抽象的类,直接理解它比较困难,我们可以通过一些具体的同步器来剖析它的工作方式。
基于AQS同步器的基本操作是”acquire“和”release“,线程调用acquire试图占据(或者通过)这个同步器,它是一个基于条件状态的操作,在条件不满足时可能会阻塞。 不同的同步器,acquire含义有所不同,对CountDownLatch来说,acquire成功的条件是”latch到达它的终结状态”;而对与FutureTask,acquire意味着“任务到达完成状态”。release是一个非阻塞操作,它修改状态使的阻塞于acquire操作之上的线程得以恢复运行。
AQS内部管理一个int类型的状态值,所有阻塞和同步都是围绕这个状态进行的。AQS提供了操作状态的方法:getState,setState和compareAndSetState。状态所代表的含义由具体同步器来决定,比如ReentrantLock的状态值代表当前线程加锁的次数,而Semaphore的状态值代表剩余的许可数量,FutureTask的状态则是任务的状态(未开始、运行中、完成、取消)。 当然同步器也可以管理额外的状态,比如ReentrantLock会维护当前持有锁的线程,以区分重入的加锁请求。
AQS也是基于状态条件的同步机制,只不它使用更底层的技术(CAS指令、volatile、线程操作)来实现的。本质上,我们只要拥有一个状态同步器,我们就可以用它实现各式各样的状态同步器。而AQS就可以被看做一个
元
状态同步器。
acquire操作
AQS的acquire操作的基本形式如下:
boolean acquire() throws InterruptedException {
while (state does not permit acquire) {
if (blocking acquisition requested) {
enqueue current thread if not already queued
block current thread
}
else
return failure
}
possibly update synchronization state
dequeue thread if it was queued
return success
}
上面的代码在形式上与条件队列的wait操作有点类似,包含的步骤如下:
- 判断当前的状态是否允许acquire成功,由于需要反复计算该判断,所以用while包裹
- 如果不符合条件
- 再看本次acquire是否可阻塞,可阻塞的话将当前线程加入AQS的阻塞队列,并挂起;当其他线程修改状态后,会唤醒该线程,回到while循环,重新竞争该同步器;
- 如果是非阻塞操作,失败返回
- 如果符合条件
- 可能需要修改状态,独占该同步器;
- 如果当前线程在阻塞队列里,删除它;
- 返回成功
acquire成功的条件状态是什么?是否某些状态下允许多个线程同时acquire成功?都由具体的同步器来决定,但它们的工作模式大体如此,我们应当记住这个模式并在学习具体同步器时不断对照这个模式。
release操作
AQS的release操作,表示线程要释放同步器,它不会导致阻塞:
void release() {
update synchronization state
if (new state may permit a blocked thread to acquire)
unblock one or more queued threads
}
release操作首先修改AQS的状态,如果状态变化使得某些阻塞在acquire操作上的线程有机会成功,那么唤醒这些线程。
继承AQS
AQS为子类提供了getState, setState和compareAndSetState方法来访问状态值,并定义了一些可重写的方法。
如果要实现一个完全互斥的(类似ReentrantLock)的同步器,需要覆盖以下几个方法:
protected boolean tryAcquire(int arg)
:尝试占据AQS,成功返回true,arg参数代表修改状态的量,它的含义由子类解释;protected boolean tryRelease(int arg)
:尝试释放AQS;
如果该是一个支持Condition的Lock,还需要实现:
protected boolean isHeldExclusively()
:该同步器是否被当前线程独占?
如果实现可共享的同步器,覆盖以下方法:
protected int tryAcquireShared(int arg)
:尝试以共享方式占据AQS,返回值负数代表失败,0代表成功且后续tryAcquireShared不会成功,>0代表成功且后续tryAcquireShared仍可能成功;这个特性使得AQS可支持有限共享的同步器(Semaphore);protected int tryReleaseShared(int arg)
:尝试以共享方式释放AQS;
线程调用AQS的acquireXXX和releaseXXX方法时,后者会调用上面这些方法,依据返回值来决定来执行同步逻辑,如阻塞、唤醒等。
基于AQS的SimpleLatch
现在基于AQS实现一个简单的同步器OneShotLatch(二元状态的CountdownLatch),它的初始状态是关闭,调用await方法会阻塞,直到其他线程调用signal打开它。
@ThreadSafe
public class OneShotLatch {
private final Sync sync = new Sync();
public void signal() {
sync.releaseShared(0);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(0);
}
private class Sync extends AbstractQueuedSynchronizer {
protected int tryAcquireShared(int ignored) {
// Succeed if latch is open (state == 1), else fail
return (getState() == 1) ? 1 : -1;
}
protected boolean tryReleaseShared(int ignored) {
setState(1); // Latch is now open
return true; // Other threads may now be able to acquire
}
}
}
OneShotLatch没有继承自AQS,而是通过一个内部类来继承AQS,这样做是值得推荐的:首先AQS提供的功能并不是所有的同步器都需要,将AQS封闭在同步器内,能够屏蔽不需要的功能,避免被误用;其次AQS的方法语义过于抽象,具体同步器可以重新定义更加契合其功能的操作方法给使用者。实际上,java所有的同步器都是用这样的模式来使用AQS的。
OneShotLatch用state1代表打开,state0代表关闭,默认就是关闭;OneShotLatch是可共享的,所以实现了tryAcquireShared和tryReleaseShared;tryAcquireShared只要state==1即成功,它不独占同步器,所以该操作不修改state;tryReleaseShared将state改为1,该方法总会成功,因为Latch无论打开多少次,都保持打开状态。
OneShotLatch的await调用了sync.acquireSharedInterruptibly(参数无意义),后者会调用tryAcquireShared,;signal调用sync.releaseShared(参数无意义),后者会调用tryReleaseShared。
Sync.tryAcquireShared的实现,并不需要担心并发场景导致程序错误,即使tryAcquireShared返回失败的一刻,另外一个线程修改了state(),同步器也不会出错。如果tryAcquireShared返回false,AQS会尝试将当前线程加入等待队列,创建一个阻塞线程Node;在插入队列之前,会再调用一次tryAcquireShared,此后,如果有其他线程调用了tryReleaseShared,AQS保证此线程Node会得到唤醒机会。有兴趣的同学可以看看AQS源码,它真正的复杂之处其实是内部的阻塞线程队列。
AQS in java.util.concurrent
这一部分我们来介绍一下,java.util.concurrent包里面的同步器是如何使用AQS来实现其功能的;不过我们不会深入它的源码细节,仅限于原理。
ReentrantLock
ReentrantLock仅支持互斥的工作方式,所以它内部实现了tryAcquire, tryRelease, 和isHeldExclusively(支持Condition);tryAcquire的工作方式类似以下伪代码:
protected boolean tryAcquire(int ignored) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, 1)) {
owner = current;
return true;
}
} else if (current == owner) {
setState(c+1);
return true;
}
return false;
}
AQS的状态为0时,表示没有线程占据这个锁,ReentrantLock通过compareAndSetState来加锁(compareAndSetState是一个原子操作,下一章介绍),加锁成功记住锁的拥有者(owner);如果状态为非0,有两种情况,一种是owner就是当前线程,表明这是一次重入加锁,将state+1即可(此处无并发风险,不需要使用原子操作);否则返回false表示加锁失败。
Semaphore
Semaphore是用可共享的方式工作,它用AQS的状态来表示当前剩余的许可数量,它内部实现tryAcquireShared类似以下伪代码:
protected int tryAcquireShared(int acquires) {
while (true) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0
|| compareAndSetState(available, remaining))
return remaining;
}
}
tryAcquireShared计算剩余许可数量与需求的差值,若不足(remaining<0)返回失败;否则使用原子compareAndSetState来更新状态值;如果compareAndSetState由于并发失败,那就重试(while)。
tryReleaseShared的实现类似以下伪代码,使用compareAndSetState增加状态值,直至成功为止:
protected boolean tryReleaseShared(int releases) {
while (true) {
int p = getState();
if (compareAndSetState(p, p + releases))
return true;
}
}
CountDownLatch
CountDownLatch的tryReleaseShared类似Semaphore,tryAcquireShared类似OneShotLatch,不再赘述。
总结
如果你想构建一个基于状态的同步机制,最好的方式是直接使用现有的类,如Semaphore、BlockingQueue或CountDownLatch;如果这些类不满足需求,那么可以考虑使用条件队列(Condition Queue);如果还不满足需求,可基于AbstractQueuedSynchronizer(AQS)构建自定义的同步器,AQS实现了一个高度抽象的基于状态的线程同步机制(阻塞、唤醒),java并发库内的同步器基本都是基于AQS实现的。