1. 什麼是共享資源
你早就遇過共享資源。住家是家庭的共享資源。辦公室的冰箱是所有同事的共享資源。想必你已經理解這個概念。
在程式設計中,共享資源是可被多個執行緒同時存取的變數、物件或資料結構。可能包括:
- 用來統計已處理訂單數量的計數變數。
- 由某些執行緒填入、另一些執行緒處理的請求清單。
- 由多個執行緒寫入的開啟檔案。
- 由程式不同部分共用的資料庫連線。
在 Java 中,任何可能被多個執行緒存取的物件或變數,都可能成為「共享資源」。
共享資源範例:全域計數器
public class Counter {
public int value = 0;
}
如果多個執行緒會遞增這個計數器,它們都會存取同一個變數 value——這就是共享資源。
2. 同時存取帶來的問題
在單執行緒程式裡一切很單純:一條執行緒——一個執行者——它沿著程式碼走,就像火車沿著鐵軌。可是一旦多執行緒加入,便開始真正的「軍刀之舞」:執行緒可能在各種意想不到的地方互相干擾。
Race condition(競態條件)
Race condition 是指程式的結果取決於執行緒動作如何「交錯」。也就是說,同一個程式多次執行,結果可能不同——這不是 bug,而是多執行緒的「特性」。
經典例子:兩個執行緒遞增計數器
來模擬一個簡單情境:我們有一個共享計數器,兩個執行緒各自把它遞增一千次。
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // 危險位置!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("預期值: 2000");
System.out.println("實際值: " + counter.value);
}
}
畫面上會看到什麼?
有時是 2000,有時是 1985,有時是 1937……為什麼?因為操作 counter.value++ 不是原子性的!它由三個步驟組成:
- 讀取目前的 counter.value。
- 把它加 1。
- 再寫回去。
如果兩個執行緒同時讀到相同的值,兩者都會把它遞增,然後各自寫回——結果有一次遞增會「消失」。這就是 lost update——遺失更新。
物件的不一致狀態
若你有一個由多個欄位組成的複雜物件,而執行緒同時改變不同欄位,物件可能陷入「奇怪」或不一致的狀態。比如,帳戶金額減少了,但操作歷史沒有更新——客戶慌了,會計也傻眼。
3. 為什麼需要同步
同步 是一種對程式下達的指示:「停,這段程式碼同一時間只能由一個執行緒執行!其他人——請等一下!」就像廁所門上的「清潔中!請勿進入!」:當一個人裡面時,其他人會在外面等(並在心裡暗罵那位久不出來的人)。
資料完整性的保證
如果我們希望計數器每次都正確遞增,就必須禁止多個執行緒同時修改它的值。
範例:讓計數器遞增同步化
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
現在,如果兩個執行緒呼叫 increment(),同一時間只有其中一個能執行這個方法。另一個會等待,直到第一個結束。
示意:同步時發生了什麼
+-------------------+
| 執行緒 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| 執行緒 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| 執行緒 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| 執行緒 N |/
+-------------------+
所有執行緒都會排隊進入受保護的程式段。同一時間只有一個執行緒能在「臨界區」(synchronized 區塊)內。
4. 同步方法概覽
在 Java 中,同步不是單一作法,而是一整套「武器庫」,能保護共享資源免於同時存取。
關鍵字 synchronized
這是 Java 中最基本的同步工具。它可以用兩種方式:
同步方法
public synchronized void increment() {
value++;
}
同步區塊
public void increment() {
synchronized (this) {
value++;
}
}
這裡的 this 是用來加鎖的物件。當某個執行緒在執行這個區塊時,其他想進入對同一物件加鎖之相同區塊的執行緒會等待。
來自 java.util.concurrent 的專用類別
- Lock、ReentrantLock——比 synchronized 更彈性的替代方案。
- ReadWriteLock——將鎖區分為讀與寫。
- Semaphore——限制同時執行某段程式碼的執行緒數量。
- CountDownLatch、CyclicBarrier 等——用於協調執行緒的工作。
重要:今天我們只做基礎認識——關於這些類別稍後再談。
5. 實作範例:多執行緒計數器應用
假設我們要為某個服務統計使用者請求的次數。每個執行緒代表一位使用者,會遞增共享計數器。
未使用同步
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("預期值: 30000");
System.out.println("實際值: " + counter.value);
}
}
結果:幾乎總是小於 30000。有時還會小很多!為什麼?因為執行緒彼此在「打斷」對方。
加入同步:修正錯誤
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("預期值: 30000");
System.out.println("實際值: " + counter.value);
}
}
結果:總是 30000。太好了,同步有效!
6. 實用細節
視覺化:race condition 是怎麼發生的
我們用一個小表格示意兩個執行緒如何「遺失」一次遞增:
| 步驟 | 執行緒 1 | 執行緒 2 | value 的值 |
|---|---|---|---|
| 1 | 讀取 value=0 | 0 | |
| 2 | 讀取 value=0 | 0 | |
| 3 | 遞增為 1 | 0 | |
| 4 | 遞增為 1 | 0 | |
| 5 | 寫入 1 | 1 | |
| 6 | 寫入 1 | 1 |
何時需要同步
同步不是每次都需要。當變數只在自己的小世界中,且只有單一執行緒操作時——可以放心。但一旦與其他執行緒共享,就離不開同步。即使你覺得應該沒事——也別相信。競態條件很狡猾:它可能潛伏很久,卻會在最不適合的時刻突然爆發。
預告:還有哪些同步工具
今天我們只認識了最基本的工具——synchronized。接下來的課程我們會介紹:
- 物件監視器(monitor)如何運作,以及鎖的類型。
- 什麼是靜態同步方法(static + synchronized)。
- volatile 關鍵字如何運作,以及它的用途。
- 有哪些現代的同步類別(Lock、Semaphore 等)。
7. 操作共享資源的常見錯誤
錯誤 1:忽視多執行緒。
最常見的錯誤之一,就是沒有意識到某個變數可能被多個執行緒存取。即使現在程式是單執行緒,之後有人加入多執行緒——bug 會「無中生有」。
錯誤 2:同步不足或過度同步。
如果不對共享資源做同步——你會遇到 race condition 與不一致的資料。若把所有東西都同步,程式會被鎖拖垮而變慢。請盡量只同步真正需要的部分。
錯誤 3:對錯誤的物件加鎖。
如果在不同的物件上同步(例如在區域變數或字串常值上),並不能保護共享資源。所有執行緒都必須在同一個物件上同步。
錯誤 4:指望非原子操作具有原子性。
操作 i++ 不是原子!即使將變數宣告為 volatile,也不會讓遞增變成原子操作。此類操作需要同步。
錯誤 5:「我很幸運,反正在我這裡都能跑」。
Race condition 可能在你的電腦上沒有重現,但一定會在伺服器或使用者端出現。千萬別在多執行緒程式中寄望僥倖!
GO TO FULL VERSION