Java 執行緒同步原理探析
現如今,伺服器效能日益增長,併發(concurrency)程式設計已經“深入人心”,但由於馮諾依式計算機“指令儲存,順序執行”的特性,使得編寫跨越時間維度的併發程式異常困難,所以現代程式語言都對併發程式設計提供了一定程度的支援,像 Golang 裡面的 Goroutines、Clojure 裡面的 STM(Software Transactional Memory)、Erlang 裡面的 Actor。
Java 對於併發程式設計的解決方案是多執行緒(Multi-threaded programming),而且 Java 中的執行緒 與 native 執行緒一一對應,多執行緒也是早期作業系統支援併發的方案之一(其他方案:多程式、IO多路複用)。
本文著重介紹 Java 中執行緒同步的原理、實現機制,更側重作業系統層面,部分原理參考 openjdk 原始碼。閱讀本文需要對 CyclicBarrier、CountDownLatch 有基本的使用經驗。
JUC
在 Java 1.5 版本中,引入 JUC 併發程式設計輔助包,很大程度上降低了併發程式設計的門檻,JUC 裡面主要包括:
- 執行緒排程的 Executors
- 緩衝任務的 Queues
- 超時相關的 TimeUnit
- 併發集合(如 ConcurrentHashMap)
- 執行緒同步類(Synchronizers,如 CountDownLatch )
個人認為其中最重要也是最核心的是執行緒同步這一塊,因為併發程式設計的難點就在於如何保證「共享區域(專業術語:臨界區,Critical Section)的訪問時序問題」。
AbstractQueuedSynchronizer
JUC 提供的同步類主要有如下幾種:
Semaphore
is a classic concurrency tool.CountDownLatch
is a very simple yet very common utility for blocking until a given number of signals, events, or conditions hold.- A
CyclicBarrier
is a resettable multiway synchronization point useful in some styles of parallel programming. - A
Phaser
provides a more flexible form of barrier that may be used to control phased computation among multiple threads. - An
Exchanger
allows two threads to exchange objects at a rendezvous(約會) point, and is useful in several pipeline designs.
通過閱讀其原始碼可以發現,其實現都基於 AbstractQueuedSynchronizer 這個抽象類(一般簡寫 AQS),正如其 javadoc 開頭所說:
Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state.
也就是說,AQS 通過維護內部的 FIFO 佇列和具備原子更新的整型 state 這兩個屬性來實現各種鎖機制,包括:是否公平,是否可重入,是否共享,是否可中斷(interrupt),並在這基礎上,提供了更方便實用的同步類,也就是一開始提及的 Latch、Barrier 等。
這裡暫時不去介紹 AQS 實現細節與如何基於 AQS 實現各種同步類(挖個坑),感興趣的可以移步美團的一篇文章《不可不說的Java“鎖”事》 第六部分“獨享鎖 VS 共享鎖”。
在學習 Java 執行緒同步這一塊時,對我來說困擾最大的是「執行緒喚醒」,試想一個已經 wait/sleep/block 的執行緒,是如何響應 interrupt 的呢?當呼叫 Object.wait() 或 lock.lock() 時,JVM 究竟做了什麼事情能夠在呼叫 Object.notify 或 lock.unlock 時重新啟用相應執行緒?
帶著上面的問題,我們從原始碼中尋找答案。
Java 如何實現堵塞、通知
wait/notify
public final native void wait(long timeout) throws InterruptedException; public final native void notify();
在 JDK 原始碼中,上述兩個方法均用 native 實現(即 cpp 程式碼),追蹤相關程式碼
// java.base/share/native/libjava/Object.c static JNINativeMethod methods[] = { {"hashCode", "()I", (void *)&JVM_IHashCode}, {"wait", "(J)V", (void *)&JVM_MonitorWait}, {"notify", "()V", (void *)&JVM_MonitorNotify}, {"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll}, {"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone}, };
通過上面的 cpp 程式碼,我們大概能猜出 JVM 是使用 monitor 來實現的 wait/notify 機制,至於這裡的 monitor 是何種機制,這裡暫時跳過,接著看 lock 相關實現
lock/unlock
LockSupport 是用來實現堵塞語義模型的基礎輔助類,主要有兩個方法:park 與 unpark。(在英文中,park 除了“公園”含義外,還有“停車”的意思)
// LockSupport.java public static void unpark(Thread thread) { if (thread != null) UNSAFE.unpark(thread); } public static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null); } // Unsafe.java /** * Unblocks the given thread blocked on {@code park}, or, if it is * not blocked, causes the subsequent call to {@code park} not to * block. Note: this operation is "unsafe" solely because the * caller must somehow ensure that the thread has not been * destroyed. Nothing special is usually required to ensure this * when called from Java (in which there will ordinarily be a live * reference to the thread) but this is not nearly-automatically * so when calling from native code. * * @param thread the thread to unpark. */ @HotSpotIntrinsicCandidate public native void unpark(Object thread); /** * Blocks current thread, returning when a balancing * {@code unpark} occurs, or a balancing {@code unpark} has * already occurred, or the thread is interrupted, or, if not * absolute and time is not zero, the given time nanoseconds have * elapsed, or if absolute, the given deadline in milliseconds * since Epoch has passed, or spuriously (i.e., returning for no * "reason"). Note: This operation is in the Unsafe class only * because {@code unpark} is, so it would be strange to place it * elsewhere. */ @HotSpotIntrinsicCandidate public native void park(boolean isAbsolute, long time); // hotspot/share/prims/unsafe.cpp UNSAFE_ENTRY(void, Unsafe_Park(JNIEnv *env, jobject unsafe, jboolean isAbsolute, jlong time)) { HOTSPOT_THREAD_PARK_BEGIN((uintptr_t) thread->parker(), (int) isAbsolute, time); EventThreadPark event; JavaThreadParkedState jtps(thread, time != 0); thread->parker()->park(isAbsolute != 0, time); if (event.should_commit()) { post_thread_park_event(&event, thread->current_park_blocker(), time); } HOTSPOT_THREAD_PARK_END((uintptr_t) thread->parker()); } UNSAFE_END
通過上述 unsafe.cpp 可以看到每個 thread 都會有一個 Parker 物件,所以我們需要檢視 parker 物件的定義
// hotspot/share/runtime/park.hpp class Parker : public os::PlatformParker ... public: // For simplicity of interface with Java, all forms of park (indefinite, // relative, and absolute) are multiplexed into one call. void park(bool isAbsolute, jlong time); void unpark(); // hotspot/os/posix/os_posix.hpp class PlatformParker : public CHeapObj<mtInternal> { protected: enum { REL_INDEX = 0, ABS_INDEX = 1 }; int _cur_index; // which cond is in use: -1, 0, 1 pthread_mutex_t _mutex[1]; pthread_cond_t _cond[2]; // one for relative times and one for absolute ... };
看到這裡大概就能知道 park 是使用 pthread_mutex_t
與 pthread_cond_t
實現。好了,到目前為止,就引出了 Java 中與堵塞相關的實現,不難想象,都是依賴底層作業系統的功能。
OS 支援的同步原語
Semaphore
併發程式設計領域的先鋒人物 Edsger Dijkstra(沒錯,也是最短路徑演算法的作者)在 1965 年首次提出了訊號量( Semaphores) 這一概念來解決執行緒同步的問題。訊號量是一種特殊的變數型別,為非負整數,只有兩個特殊操作PV:
- P(s) 如果 s!=0,將 s-1;否則將當前執行緒掛起,直到 s 變為非零
- V(s) 將 s+1,如果有執行緒堵塞在 P 操作等待 s 變成非零,那麼 V 操作會重啟這些執行緒中的任意一個
注:Dijkstra 為荷蘭人,名字 P 和 V 來源於荷蘭單詞 Proberen(測試)和Verhogen(增加),為方便理解,後文會用 Wait 與 Signal 來表示。
struct semaphore { int val; thread_list waiting; // List of threads waiting for semaphore } wait(semaphore Sem): // Wait until > 0 then decrement // 這裡用的是 while 而不是 if // 這是因為在 wait 過程中,其他執行緒還可能繼續呼叫 wait while (Sem.val <= 0) { add this thread to Sem.waiting; block(this thread); } Sem.val = Sem.val - 1; return; signal(semaphore Sem):// Increment value and wake up next thread Sem.val = Sem.val + 1; if (Sem.waiting is nonempty) { remove a thread T from Sem.waiting; wakeup(T); }
有兩點注意事項:
- wait 中的「測試和減 1 操作」,signal 中的「加 1 操作」需要保證原子性。一般來說是使用硬體支援的 read-modify-write 原語,比如 test-and-set/fetch-and-add/compare-and-swap,除了硬體支援外,還可以用 busy wait 的軟體方式來模擬。
- signal 中沒有定義重新啟動的執行緒順序,也即多個執行緒在等待同一訊號量時,無法預測重啟哪一個執行緒
使用場景
訊號量為控制併發程式的執行提供了強有力工具,這裡列舉兩個場景:
互斥
訊號量提供了了一種很方便的方法來保證對共享變數的互斥訪問,基本思想是
將每個共享變數(或一組相關的共享變數)與一個訊號量 s (初始化為1)聯絡起來,然後用 wait/signal 操作將相應的臨界區包圍起來。
二元訊號量也被稱為互斥鎖(mutex,mutual exclusve, 也稱為 binary semaphore),wait 操作相當於加鎖,signal 相當於解鎖。
一個被用作一組可用資源的計數器的訊號量稱為計數訊號量(counting semaphore)
排程共享資源
除了互斥外,訊號量的另一個重要作用是排程對共享資源的訪問,比較經典的案例是生產者消費者,虛擬碼如下:
emptySem = N fullSem = 0 // Producer while(whatever) { locally generate item wait(emptySem) fill empty buffer with item signal(fullSem) } // Consumer while(whatever) { wait(fullSem) get item from full buffer signal(emptySem) use item }
POSIX 實現
POSIX 標準中有定義訊號量相關的邏輯,在 semaphore.h 中,為 sem_t 型別,相關 API:
// Intialize: sem_init(&theSem, 0, initialVal); // Wait: sem_wait(&theSem); // Signal: sem_post(&theSem); // Get the current value of the semaphore: sem_getvalue(&theSem, &result);
訊號量主要有兩個缺點:
- Lack of structure,在設計大型系統時,很難保證 wait/signal 能以正確的順序成對出現,順序與成對缺一不可,否則就會出現死鎖!
- Global visiblity,一旦程式出現死鎖,整個程式都需要去檢查
解決上述兩個缺點的新方案是監控器(monitor)。
Monitors
C. A. R. Hoare 在 1974 年的論文 Monitors: an operating system structuring concept 首次提出了「監控器」概念,它提供了對訊號量互斥和排程能力的更高階別的抽象,使用起來更加方便,一般形式如下:
monitor1 . . . monitorM process1 . . . processN
我們可以認為監控器是這麼一個物件:
- 所有訪問同一監控器的執行緒通過條件變數(condition variables)間接通訊
- 某一個時刻,只能有一個執行緒訪問監控器
Condition variables
上面提到監控器通過條件變數(簡寫 cv)來協調執行緒間的通訊,那麼條件變數是什麼呢?它其實是一個 FIFO 的佇列,用來儲存那些因等待某些條件成立而被堵塞的執行緒,對於一個條件變數 c 來說,會關聯一個斷言(assertion) P。執行緒在等待 P 成立的過程中,該執行緒不會鎖住該監控器,這樣其他執行緒就能夠進入監控器,修改監控器狀態;在 P 成立時,其他執行緒會通知堵塞的執行緒,因此條件變數上主要有三個操作:
wait(cv, m)
等待 cv 成立,m 表示與監控器關聯的一 mutex 鎖signal(cv)
也稱為notify(cv)
用來通知 cv 成立,這時會喚醒等待的執行緒中的一個執行。根據喚醒策略,監控器分為兩類:Hoare vs. Mesa,後面會介紹broadcast(cv)
也稱為notifyAll(cv)
喚醒所有等待 cv 成立的執行緒
POSIX 實現
在 pthreads 中,條件變數的型別是 pthread_cond_t
,主要有如下幾個方法:
// initialize pthread_cond_init() pthread_cond_wait(&theCV, &someLock); pthread_cond_signal(&theCV); pthread_cond_broadcast(&theCV);
使用方式
在 pthreads 中,所有使用條件變數的地方都必須用一個 mutex 鎖起來,這是為什麼呢?看下面一個例子:
pthread_mutex_t myLock; pthread_cond_t myCV; int count = 0; // Thread A pthread_mutex_lock(&myLock); while(count < 0) { pthread_cond_wait(&myCV, &myLock); } pthread_mutex_unlock(&myLock); // Thread B pthread_mutex_lock(&myLock); count ++; while(count == 10) { pthread_cond_signal(&myCV); } pthread_mutex_unlock(&myLock);
如果沒有鎖,那麼
- 執行緒 A 可能會在其他執行緒將 count 賦值為10後繼續等待
- 執行緒 B 無法保證加一操作與測試 count 是否為零 的原子性
這裡的關鍵點是,在進行條件變數的 wait 時,會釋放該鎖,以保證其他執行緒能夠將之喚醒。不過需要注意的是,線上程 B 通知(signal) myCV 時,執行緒 A 無法立刻恢復執行,這是因為 myLock 這個鎖還被執行緒 B 持有,只有線上程 B unlock(&myLock)
後,執行緒 A 才可恢復。總結一下:
- wait 時會釋放鎖
- signal 會喚醒等待同一 cv 的執行緒
- 被喚醒的執行緒需要重新獲取鎖,然後才能從 wait 中返回
Hoare vs. Mesa 監控器語義
在上面條件變數中,我們提到 signal 在呼叫時,會去喚醒等待同一 cv 的執行緒,根據喚醒策略的不同,監控器也分為兩類:
- Hoare 監控器(1974),最早的監控器實現,在呼叫 signal 後,會立刻執行等待的執行緒,這時呼叫 signal 的執行緒會被堵塞(因為鎖被等待執行緒佔有了)
- Mesa 監控器(Xerox PARC, 1980),signal 會把等待的執行緒重新放回到監控的 ready 佇列中,同時呼叫 signal 的執行緒繼續執行。這種方式是現如今 pthreads/Java/C# 採用的
這兩類監控器的關鍵區別在於等待執行緒被喚醒時,需要重新檢查 P 是否成立。
監控器工作示意圖
上圖表示藍色的執行緒在呼叫監控器的 get 方式時,資料為空,因此開始等待 emptyFull 條件;緊接著,紅色執行緒呼叫監控器的 set 方法改變 emptyFull 條件,這時
- 按照 Hoare 思路,藍色執行緒會立刻執行,並且紅色執行緒堵塞
- 按照 Mesa 思路,紅色執行緒會繼續執行,藍色執行緒會重新與綠色執行緒競爭與監控器關聯的鎖
Java 中的監控器
在 Java 中,每個物件都是一個監控器(因此具備一個 lock 與 cv),呼叫物件 o 的 synchronized 方法 m 時,會首先去獲取 o 的鎖,除此之外,還可以呼叫 o 的 wait/notify/notify 方法進行併發控制
Big Picture
作業系統併發相關 API 概括圖
Interruptible
通過介紹作業系統支援的同步原語,我們知道了 park/unpark、wait/notify 其實就是利用訊號量( pthread_mutex_t
)、條件變數( pthread_cond_t
)實現的,其實監控器也可以用訊號量來實現。在檢視 AQS 中,發現有這麼一個屬性:
/** * The number of nanoseconds for which it is faster to spin * rather than to use timed park. A rough estimate suffices * to improve responsiveness with very short timeouts. */ static final long spinForTimeoutThreshold = 1000L;
也就是說,在小於 1000 納秒時,await 條件變數 P 時,會使用一個迴圈來代替條件變數的堵塞與喚醒,這是由於堵塞與喚醒本身的操作開銷可能就遠大於 await 的 timeout。相關程式碼:
// AQS 的 doAcquireNanos 方法節選 for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return true; } nanosTimeout = deadline - System.nanoTime(); if (nanosTimeout <= 0L) return false; if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); if (Thread.interrupted()) throw new InterruptedException(); }
在 JUC 提供的高階同步類中,acquire 對應 park,release 對應 unpark,interrupt 其實就是個布林的 flag 位,在 unpark 被喚醒時,檢查該 flag ,如果為 true,則會丟擲我們熟悉的 InterruptedException。
Selector.select()
響應中斷異常的邏輯有些特別,因為對於這類堵塞 IO 操作來說,沒有條件變數的堵塞喚醒機制,我們可以再看下 Thread.interrupt 的實現
public void interrupt() { if (this != Thread.currentThread()) checkAccess(); synchronized (blockerLock) { Interruptible b = blocker; if (b != null) { interrupt0(); // Just to set the interrupt flag b.interrupt(this); return; } } interrupt0(); }
OpenJDK 使用了這麼一個技巧來實現堵塞 IO 的中斷喚醒:在一個執行緒被堵塞時,會關聯一個 Interruptible 物件。
對於 Selector 來說,在開始時,會關聯這麼一個Interruptible 物件:
protected final void begin() { if (interruptor == null) { interruptor = new Interruptible() { public void interrupt(Thread target) { synchronized (closeLock) { if (closed) return; closed = true; interrupted = target; try { AbstractInterruptibleChannel.this.implCloseChannel(); } catch (IOException x) { } } }}; } blockedOn(interruptor); Thread me = Thread.currentThread(); if (me.isInterrupted()) interruptor.interrupt(me); }
當呼叫 interrupt 方式時,會關閉該 channel,這樣就會關閉掉這個堵塞執行緒,可見為了實現這個功能,代價也是比較大的。LockSupport.park 中採用了類似技巧。
總結
也許基於多執行緒的併發程式設計不是最好的(可能是最複雜的,Clojure 大法好 :-),但卻是最悠久的。
即便我們自己不去寫往往也需要閱讀別人的多執行緒程式碼,而且能夠寫出“正確”(who knows?)的多執行緒程式往往也是區分 senior 與 junior 程式設計師的標誌,希望這篇文章能幫助大家理解 Java 是如何實現執行緒控制,有疑問歡迎留言指出,謝謝!
參考
- https://carlmastrangelo.com/blog/javas-mysterious-interrupt
- Java的LockSupport.park()實現分析
- 課件 COMP3151/9151 Foundations of Concurrency Lecture 6 – Semaphores, Monitors, POSIX Threads, Java
- 課件 http://crystal.uta.edu/~ylei/cse6324/data/semaphore.pdf
- 課件 https://cs61.seas.harvard.edu/wiki/images/1/12/Lec19-Semaphores.pdf
- Mutexes and Semaphores Demystified
- https://book.douban.com/subject/1888733/
- https://en.wikipedia.org/wiki/Mutual_exclusion
- https://stackoverflow.com/questions/3513045/conditional-variable-vs-semaphore
- https://stackoverflow.com/questions/2332765/lock-mutex-semaphore-whats-the-difference
相關文章
- Java—執行緒同步Java執行緒
- 多執行緒同步的原理執行緒
- java執行緒學習5——執行緒同步之同步方法Java執行緒
- Java執行緒:執行緒的同步與鎖Java執行緒
- java 多執行緒 –同步Java執行緒
- java 多執行緒 --同步Java執行緒
- Java執行緒池二:執行緒池原理Java執行緒
- java多執行緒原理Java執行緒
- Java 執行緒池執行原理分析Java執行緒
- Java中的執行緒同步Java執行緒
- java多執行緒–同步鎖Java執行緒
- java基礎:執行緒同步Java執行緒
- #大學#Java多執行緒學習02(執行緒同步)Java執行緒
- Java多執行緒學習(3)執行緒同步與執行緒通訊Java執行緒
- Java併發(四)----執行緒執行原理Java執行緒
- java多執行緒:執行緒池原理、阻塞佇列Java執行緒佇列
- Java多執行緒—執行緒同步(單訊號量互斥)Java執行緒
- JAVA多執行緒詳解(3)執行緒同步和鎖Java執行緒
- Java多執行緒之執行緒同步【synchronized、Lock、volatitle】Java執行緒synchronized
- JVM 進行執行緒同步背後的原理JVM執行緒
- java synchronize - 執行緒同步機制Java執行緒
- c#執行緒-執行緒同步C#執行緒
- 執行緒同步及執行緒鎖執行緒
- 執行緒同步執行緒
- Java執行緒池核心原理剖析Java執行緒
- 圖解Java執行緒池原理圖解Java執行緒
- Java執行緒池原理及分析Java執行緒
- Java同步之執行緒池詳解Java執行緒
- Java執行緒狀態及同步鎖Java執行緒
- Java中的執行緒同步詳解Java執行緒
- 最全java多執行緒總結2--如何進行執行緒同步Java執行緒
- java 執行緒淺解03[執行緒同步以及經典死鎖]Java執行緒
- 多執行緒和多執行緒同步執行緒
- 執行緒與同步非同步執行緒非同步
- 執行緒的同步執行緒
- 理解執行緒同步執行緒
- 深入執行緒同步執行緒
- 多執行緒同步執行緒