1. 為什麼 i++ 在多執行緒中不可靠?
先從經典問題開始:我們有一個計數器變數,例如已處理的請求數或已下載的檔案數。我們希望多個執行緒一起遞增這個計數器。如果只是寫下 i++,會出什麼問題?
範例:遞增時的資料競爭
public class Counter {
public int count = 0;
public void increment() {
count++; // 非原子!
}
}
想像有兩個執行緒同時呼叫 increment()。兩個執行緒都讀到舊值、都把它加 1,然後都寫回去……寫成一樣的新值!結果就是有一次遞增「遺失」了。如果反覆多次,最後的結果會小於預期。
為什麼會這樣?
操作 i++ 其實包含三個步驟:
- 讀取變數的值(例如,5)。
- 將此值加上 1。
- 把新值寫回記憶體。
在多執行緒環境中,其他執行緒可能在這些步驟之間改變了變數。結果就是「資料競爭」(race condition)。
什麼是原子操作?
原子操作指的是要麼完整執行、要麼完全不執行,且沒有其他執行緒能在操作進行的中途「插入」。
在 Java 中有一組類別,為原生型別與參考提供這些操作。它們位於套件 java.util.concurrent.atomic。最常見的有:
- AtomicInteger — 原子的整數型別。
- AtomicLong — 原子的 long。
- AtomicBoolean — 原子的 boolean。
- AtomicReference<T> — 指向任意型別物件的原子參考。
2. AtomicInteger:執行緒安全的計數器
宣告與基本用法
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子遞增
}
public int get() {
return count.get();
}
}
這裡 incrementAndGet() 會把「遞增並回傳新值」當作一個不可分割的操作來執行。即使有 100 個執行緒同時呼叫這個方法,也不會遺失任何一次遞增。
常用方法:
| 方法 | 說明 |
|---|---|
|
取得目前值 |
|
設定值 |
|
加 1 並回傳新值 |
|
先回傳目前值,再加 1 |
|
加上 delta 並回傳新值 |
|
若目前值等於 expect,則設為 update(CAS) |
範例:多執行緒計數器
假設我們有個類別用來統計聊天中處理過的訊息數量。
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("訊息總數: " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
內部機制:AtomicInteger 如何運作?
在內部,AtomicInteger 使用處理器的特殊指令 — CAS(Compare-And-Swap,「比較並交換」)。這是一種原子操作:它會將變數的目前值與預期值做比較,若相符便寫入新值;如果在此期間其他執行緒改變了變數,操作就不會生效,並會重試。
工作流程:
1. 讀取目前值(例如,5)
2. 與預期值比較(5)
3. 若相符 — 寫入新值(6)
4. 若不相符 — 重新嘗試
以上過程非常快速且無鎖(lock‑free)。因此原子類別常常比 synchronized 更快,尤其在執行緒很多時。
3. AtomicReference:物件的原子參考
AtomicReference<T> 是可包裝任何物件的通用原子容器。它允許從多個執行緒安全地變更對該物件的參考。
範例:執行緒安全地更新參考
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
compareAndSet 的用法
最有趣的操作是 compareAndSet(expected, newValue)。它允許只在值自上次讀取以來沒有改變時才更新。
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("更新成功!");
} else {
System.out.println("已有人修改值,請再試一次。");
}
}
這是非阻塞演算法的基礎:從佇列與堆疊到快取,當你想避免不必要的鎖時尤其重要。
4. 在應用中的使用範例
範例 1:多執行緒訊息計數器
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... 處理訊息 ...
int count = messageCount.incrementAndGet();
System.out.println("新訊息: " + message + "。訊息總數: " + count);
}
}
範例 2:安全更新最後一則訊息的參考
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... 處理 ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
如果需要只在最後一則訊息沒有改變時才更新參考(避免同時更新導致的「丟失」),請使用 compareAndSet。
5. 限制與陷阱
原子類別並非萬靈藥:何時不適用?
原子變數非常適合簡單操作:遞增、設定值、比較與替換。但若需要同時更新多個變數,就無法保證原子性。比如你有兩個計數器,想把它們視為一個操作一併增加——此時需要 synchronized 或其他同步機制。
錯誤用法範例
// 非原子!
if (ref.get() == null) {
ref.set("Hello");
}
在 get() 與 set(...) 之間,其他執行緒可能已經改變了值,使得條件不再成立。遇到這種情況請使用 compareAndSet。
原子類別 ≠ 執行緒安全的物件
如果 AtomicReference 指向的物件本身不是執行緒安全的,那麼替換參考雖然是原子的,但修改該物件欄位並非如此。比如在 AtomicReference<List<String>> 中放的是一般的 ArrayList,清單本身不會因此變成執行緒安全。
6. 進階原子類別
在套件 java.util.concurrent.atomic 中還有其他實用類別:
- AtomicLong、AtomicBoolean — 用於 long 與 boolean。
- AtomicIntegerArray、AtomicReferenceArray — 對陣列進行原子操作。
- LongAdder、LongAccumulator — 適合高負載的計數器。
LongAdder 與 LongAccumulator
如果執行緒非常多,而一般的 AtomicInteger 變成「瓶頸」(所有執行緒都競爭同一個變數),請使用 LongAdder。它會把計數器拆分為多個內部儲存格,查詢值時再彙總,能在高競爭下帶來效能提升。
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. 使用原子變數時的常見錯誤
錯誤 1:期待複合操作具有原子性。
若需要對某個值執行多個步驟,原子類別並不能保證整體原子性——在步驟之間其他執行緒可能更改資料。對於複合操作,請使用 compareAndSet 或同步機制。
錯誤 2:忽視巢狀物件的執行緒安全。
即便 AtomicReference 中放著一般物件,只有替換參考是原子的;該物件的方法與欄位並不會因此變得執行緒安全。原子的是「參考的替換」。
錯誤 3:不必要地使用原子類別。
在單執行緒程式碼中,原子型別是多餘的,且因為附帶檢查會比一般變數稍慢。
錯誤 4:過早最佳化。
有時使用 synchronized 更簡單且更可靠,尤其當邏輯複雜且同時涉及多個變數時。並非所有情況都值得構建無鎖(lock‑free)方案。
錯誤 5:忽略 ABA 問題。
較少見但重要的情況:值從 A 變為 B 又變回 A —— compareAndSet 會「以為」什麼都沒改變。這類情境可以使用 AtomicStampedReference(或 AtomicMarkableReference)等特殊類別。
GO TO FULL VERSION