1. 認識競態條件(race condition)
讓我們回顧一下 競態條件(race condition)——當程式的結果取決於執行緒取得共享資料或資源的順序。若執行順序改變,結果就會變得不可預測。這很像你和朋友同時編輯同一份文件:誰比較快寫入,誰就「贏」,而最後的文字可能非常奇怪。
在 Java(以及任何支援多執行緒的語言)中,當多個執行緒同時讀取與/或修改同一個變數且缺乏適當同步時,就會出現 race condition。
為什麼會產生 race condition?
Java 執行緒是並行運作的。如果兩個執行緒同時操作同一個變數(例如遞增共享計數器),它們可能會彼此「踩到」。即使某個操作看起來是原子的(例如 counter++),其實並不是!
counter++ 如何運作?
遞增操作包含幾個步驟:
- 從記憶體讀取變數的目前值。
- 將此值加一。
- 把新值寫回記憶體。
如果此時另一個執行緒也在做 counter++,它們可能都讀到相同的值、都把它加一並各自寫回相同的結果——最後就有一次遞增被「吃掉」了。
2. 競態條件示例:計數器遞增
我們來寫個簡單的程式,啟動多個執行緒,每個都把共享計數器加上 1。看起來如果我們啟動了 1000 個執行緒,最終的計數應該是 1000。來驗證看看!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // 危險的操作!
});
threadArray[i].start();
}
// 等待所有執行緒結束
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("預期: " + threads);
System.out.println("實際: " + counter);
}
}
預期輸出:
預期: 1000
實際: 843
每次執行的數值可能不同:有時 900、有時 700,有時甚至 1000——但非常罕見。
為什麼會這樣?
執行緒同時讀取 counter 的值,將其遞增,然後寫回。如果兩個執行緒讀到了同一個值、都將它加一並都寫回——就會有一次遞增遺失。結果最終值總是小於預期。
3. 另一個例子:沒有同步的銀行
想像我們有個銀行帳戶,兩個執行緒同時提領現金。
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// 模擬耗時工作
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("預期: 0 或 100");
System.out.println("實際餘額: " + account.getBalance());
}
}
有時兩個執行緒都會看到帳上有 100,於是兩邊都成功提款。結果餘額會變成 -100!(現實生活不會這樣,但在程式裡很容易發生。)
4. 一些有用的細節
競態條件的後果
競態條件不只是「奇怪」的結果。對程式設計師來說,它是真正的頭痛,因為:
- 錯誤不一定會出現。 有時程式運作正確,有時則不然。一切取決於執行緒「趕上」動作的時序。
- 測試不保證成功。 你可以多次執行程式——一切都看似正常,但某次卻突然壞掉。
- 錯誤很難捕捉。 行為取決於處理器速度、系統負載、以及其他正在執行的程式。
- 可能引發嚴重故障: 資料遺失、不正確的計算、應用程式當機。
真實範例
- 金融應用: 餘額計算錯誤、重複扣款。
- 伺服器: 訊息遺失、請求處理不正確。
- 遊戲: 角色「瞬移」、分數計算錯誤。
為什麼測試無法避免 race condition?
競態條件是典型的「Heisenbug」(當你試圖捕捉它時就會消失的 bug)。即使你把測試跑上一千次都沒看到錯誤——也不代表它不存在!一切取決於作業系統如何排程執行緒。有時一切順利,有時執行緒會「撞在一起」而出現問題。
如何避免 race condition?
- 同步: 使用 synchronized 關鍵字修飾方法或程式碼區塊,讓同一時間只有一個執行緒可以修改共享資料。
- 原子操作: 使用 java.util.concurrent.atomic 套件中的類別(例如 AtomicInteger),在不需顯式同步的情況下提供安全操作。
- 不可變性: 如果物件不可被修改,就不會產生競態條件。
同步的範例
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
現在,如果多個執行緒呼叫 increment(),在任一時刻都只有一個執行緒能執行此方法。
5. 在執行緒中處理共享變數的常見錯誤
錯誤 1:對簡單操作安全性的天真自信。
許多人以為 counter++ 就是一個操作,不會出事。其實它由三個步驟組成,而在這些步驟之間,其他執行緒可能會「插進來」。
錯誤 2:使用普通變數在執行緒間交換資料。
如果多個執行緒在沒有同步的情況下,對同一個變數進行讀寫——恭喜,你遇到競態條件了!
錯誤 3:以為錯誤會一直重現。
競態條件可能只偶爾出現,這使它特別狡猾。別指望測試都通過就代表一切沒問題。
錯誤 4:在使用集合時忽略同步。
像 ArrayList 這樣的普通集合並非執行緒安全。如果多個執行緒同時新增或刪除元素——可能發生故障,甚至導致程式崩潰。
錯誤 5:試圖用延遲來「修」競態條件。
例如透過 Thread.sleep(10) 或其他「魔法」暫停。這種作法無法解決問題,只是把它掩蓋起來。真正的解法是同步或原子操作。
GO TO FULL VERSION