這一章介紹基於狀態條件同步的一般場景,以及通過條件隊列來構建自定義同步機制的方法。最後介紹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實現的。