Java 的同步化機制 (Synchronization in Java)
本系列文章內容參考自經典教材 Operating System Concepts, 10th Edition (Silberschatz, Galvin, Gagne)。本文對應章節:Section 7.4 Synchronization in Java。
Java 語言從誕生之初就對執行緒同步化(thread synchronization)提供了豐富的支援。Section 7.4 依序介紹四種主要機制:
| 機制 | 引入版本 | 核心工具 |
|---|---|---|
| Java Monitor | Java 1.0 | synchronized、wait()、notify() |
| Reentrant Lock | Java 1.5 | ReentrantLock、ReentrantReadWriteLock |
| Semaphore | Java 1.5 | Semaphore(acquire() / release()) |
| Condition Variable | Java 1.5 | Condition(await() / signal()) |
後三種機制在 Java 1.5 版本同步引入,補足了 Java Monitor 在靈活性與精確性上的不足。
7.4.1 Java Monitor
每個物件都是一個 Monitor
Java 最核心的同步化設計是:每一個 Java 物件,都內建一把鎖(lock)。這個設計讓任何物件都可以作為互斥存取的保護單位,不需要額外宣告鎖物件。
當一個 method 被宣告為 synchronized,呼叫這個 method 需要先擁有該物件的鎖(the lock for the object)。以解決 bounded-buffer problem 的 BoundedBuffer 類別為例:
public class BoundedBuffer<E> {
private static final int BUFFER_SIZE = 5;
private int count, in, out;
private E[] buffer;
public BoundedBuffer() {
count = 0; in = 0; out = 0;
buffer = (E[]) new Object[BUFFER_SIZE];
}
/* Producers call this method */
public synchronized void insert(E item) { /* 見下方 */ }
/* Consumers call this method */
public synchronized E remove() { /* 見下方 */ }
}
insert() 和 remove() 都宣告為 synchronized,這意味著同一時間,只有一個執行緒可以執行其中任一個方法。生產者和消費者透過競爭同一把物件 鎖來保證互斥。
Entry Set:競爭鎖時的等待區
考慮多個生產者執行緒同時想呼叫 insert() 的情況。物件的鎖在某一時刻只能由一個執行緒持有。那些拿不到鎖的執行緒不會無端消失,它們會被放進一個稱為 entry set(進入集合) 的等待區,並以 blocked 狀態等待鎖被釋放。
下圖展示了 entry set 的結構:多個執行緒在 entry set 中排隊,等待取得物件鎖之後才能進入 synchronized method。
entry set 的運作規則:
- 若鎖被佔用,呼叫 synchronized method 的執行緒被 blocked,放入 entry set
- 若鎖可用,執行緒直接成為鎖的 owner,進入方法執行
- 當鎖被釋放(method 結束)時,JVM 從 entry set 中任意選取一個執行緒,讓它成為新的 owner
這個機制保證了互斥:任意時刻最多只有一個執行緒在執行 synchronized method。
Java 規格(JVM Specification)並未規定 entry set 必須依特定順序排列,僅說明由 JVM「任意選取」。在實務上,大多數 JVM 實作會以 FIFO 順序排列,讓等待最久的執行緒優先取得鎖,以避免飢餓(starvation)。
wait():主動讓出鎖並進入等待集合
光有 entry set 還不夠。考慮以下情境:生產者執行緒持有鎖、進入了 insert() 方法,卻發現緩衝區已滿(count == BUFFER_SIZE),根本無法插入資料。
如果這個執行緒死守著鎖不放,消費者執行緒永遠無法進入 remove() 方法取出資料,系統陷入僵局。解決辦法是讓這個執行緒主動讓出鎖,等待緩衝區有空位再繼續。Java 的 wait() 方法正是為此而設計。
當一個執行緒在 synchronized method 中呼叫 wait() 時,依序發生三件事:
- 釋放鎖:執行緒放棄目前持有的物件鎖
- 狀態設為 blocked:執行緒暫停執行,不會繼續往下跑
- 進入 wait set:執行緒被放進物件的 wait set(等待集合)
wait set 和 entry set 共存於同一個物件,形成完整的 Monitor 模型。下圖呈現了兩個集合並存的樣貌:
理解這張圖的關鍵在於區分兩個集合的等待原因:
| 集合 | 執行緒狀態 | 等待原因 | 如何進入 | 如何離開 |
|---|---|---|---|---|
| Entry Set | blocked | 鎖被佔用,等待競爭 | 呼叫 synchronized method 時鎖已被持有 | JVM 選中此執行緒,取得鎖 |
| Wait Set | blocked | 條件不滿足,主動等待 | 在 synchronized method 中呼叫 wait() | 另一執行緒呼叫 notify(),移至 entry set |
這是 Java Monitor 與單純 mutex lock 的最大差異:Monitor 不僅能互斥,還能讓執行緒在條件未滿足時主動等待,避免無謂的忙等(busy-waiting)。
以下是執行緒在三種狀態之間完整的轉換關係:
notify():喚醒等待集合中的執行緒
當消費者從緩衝區取出 item 之後,需要通知在 wait set 中等待的生產者:現在有空位了,可以繼續插入。Java 的 notify() 負責傳遞這個訊號。
呼叫 notify() 時,依序發生三件事:
- 從 wait set 中任意選取一個執行緒 T
- 將 T 從 wait set 移動到 entry set
- 將 T 的狀態從 blocked 設為 runnable
T 此時重新回到競爭鎖的行列,但並不立即執行。它必須等到當前 owner 釋放鎖之後,才有機會從 wait() 的呼叫點繼續執行,並重新檢查條件。若 wait set 為空,notify() 的呼叫不產生任何效果。
notify() 呼叫後,被喚醒的執行緒 T 不會立即得到鎖,它只是從 wait set 移到 entry set,重新開始競爭。當前持有鎖的執行緒,在方法結束(或再次呼叫 wait())之前,始終持有鎖的所有權。
insert() 與 remove() 的完整實作
有了 wait() 和 notify() 的語意基礎,insert() 和 remove() 的完整實作如下:
/* Producers call this method */
public synchronized void insert(E item) {
while (count == BUFFER_SIZE) {
try { wait(); } catch (InterruptedException ie) { }
}
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++;
notify();
}
/* Consumers call this method */
public synchronized E remove() {
E item;
while (count == 0) {
try { wait(); } catch (InterruptedException ie) { }
}
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
notify();
return item;
}
while 而非 if 是確保正確性的關鍵,理由有兩個:
第一,競態條件(race condition):執行緒從 wait set 被移至 entry set 之後,在它再次取得鎖之前,其他執行緒可能已經改變了條件。例如,生產者被喚醒後,在它取得鎖之前,另一個生產者搶先插入資料把緩衝區再次填滿。while 確保執行緒醒來後重新驗證條件,而非盲目繼續執行。
第二,spurious wakeup(虛假喚醒):某些平台的 JVM 實作中,執行緒可能在沒有 notify() 的情況下被喚醒。while 確保這種情況下執行緒仍然正確地繼續等待,而非誤以為條件已成立。
一次完整的 Producer-Consumer 互動追蹤
以緩衝區已滿、消費者介入解救的情境,逐步追蹤每個執行緒的狀態變化:
這個流程有兩個容易忽略的細節。第一,消費者呼叫 notify() 後並未立即交出鎖,而是繼續執行到 remove() 方法結束才釋放。第二,生產者被喚醒後不是直接從 wait() 返回並繼續,而是先競爭鎖,取得後才從 wait() 的呼叫點恢復執行,並重新執行 while 的條件判斷。
Block Synchronization(塊級同步化)
鎖的作用域(scope of the lock) 定義為從取得鎖到釋放鎖之間的時間範圍。若一個 synchronized method 只有一小部分程式碼在存取共享資料,其鎖的作用域就過大,會不必要地阻塞其他執行緒。
Java 支援塊級同步化(block synchronization):不對整個方法加鎖,只對確實需要保護的程式碼區塊加鎖。以下範例中,someMethod() 本身不是 synchronized,只有操作共享資料的那一小段才需要鎖:
public void someMethod() {
/* non-critical section — 不需要鎖,可與其他執行緒並行 */
synchronized(this) {
/* critical section — 取得 this 物件的鎖 */
}
/* remainder section — 不需要鎖 */
}
縮小鎖的作用域,能降低執行緒間的鎖競爭,提升整體並行度(concurrency)。這是設計高效並行程式時的重要原則:鎖的持有時間應該越短越好。
7.4.2 Reentrant Lock
比 synchronized 更靈活的互斥機制
synchronized 的鎖是隱式(implicit)的:進入 synchronized method 自動取得鎖,離開方法自動釋放,不允許細粒度控制。Java 1.5 引入的 ReentrantLock 提供顯式(explicit)的鎖操作,允許更精細的並行策略。
ReentrantLock 實作了 Lock 介面,基本用法如下:
Lock key = new ReentrantLock();
key.lock();
try {
/* critical section */
} finally {
key.unlock();
}
「Reentrant(可重入)」的含義是:若一個執行緒已持有某個 ReentrantLock