先看一段每個人都覺得沒問題的程式碼:
private int count = 0;
public void increment() {
count++; // 兩條執行緒各跑 10000 次
}
跑完之後 count 應該是 20000。實際上你會拿到一個小於 20000 的隨機數字。
更麻煩的是下面這段——它在你的開發機上永遠正常,上了正式環境的多核伺服器才偶爾出事:
private boolean running = true;
public void stop() { running = false; }
public void run() {
while (running) { /* 幹活 */ } // 可能永遠停不下來
}
這兩個問題的根源都不在 Java,而在 CPU 的設計方式。這一章要做的就是把這條因果鏈接起來:
i++ 不是原子的,以及並發需要的三個保證:原子性、可見性、有序性。volatile 就不需要死記。volatile 保證什麼、不保證什麼:最常見的誤用是以為它能取代鎖。synchronized 的鎖升級,以及它跟 ReentrantLock 的取捨。這一章跟 ch03 連線池 關係緊密:執行緒池與連線池是同一種資源管理問題的兩面,而虛擬執行緒改變了其中一邊的前提、卻沒有改變另一邊——最後一張卡會講這個差異。
並發之所以難,是因為有三種完全不同的問題,而它們的解法也不同。先把三種問題分清楚,後面所有工具(volatile、synchronized、CAS)才知道各自在解哪一個。
count++ 編譯後大致是:
① 讀取 count 的值到暫存器 (getfield)
② 暫存器的值 +1 (iadd)
③ 寫回 count (putfield)執行緒 A 讀到 5 ─┐
執行緒 B 讀到 5 ─┤ 兩邊都算出 6
執行緒 A 寫回 6 ─┤
執行緒 B 寫回 6 ─┘ → 加了兩次,結果只加了 1這叫競態條件(Race Condition)。中間任何一步被打斷,結果就錯了。
private boolean running = true;
// 執行緒 A
while (running) { /* 幹活 */ }
// 執行緒 B
running = false; // A 可能永遠看不到這個改動B 明明已經改成 false,A 卻繼續跑。而且這個問題在單核機器或開發環境很難重現,上了多核正式環境才出事。原因在下一張卡。
編譯器和 CPU 都會為了效能重新排列指令順序。只要「在單一執行緒看起來結果一樣」(as-if-serial),它們就可以自由重排。
// 你寫的
instance = new Singleton();
// 實際可能的順序
① 配置記憶體
③ 把 instance 指向那塊記憶體 ← 重排到前面了
② 執行建構子初始化
→ 另一條執行緒可能拿到「還沒初始化完成」的物件volatile → 解決可見性與有序性,不解決原子性synchronized / Lock → 三個都解決「為什麼 B 改了 A 看不到」——這件事跟 Java 沒什麼關係,是現代 CPU 的必然結果。
回想 ch01 講磁碟時的同一個邏輯,只是換了一層:
如果每次讀寫變數都要去主記憶體,CPU 大部分時間都在等。所以每個核心都有自己的 L1/L2 快取。
核心 0 核心 1
┌──────────┐ ┌──────────┐
│ L1 快取 │ │ L1 快取 │
│ running │ │ running │
│ = true │ │ = true │
└────┬─────┘ └────┬─────┘
└──────────┬─────────────────┘
主記憶體 running = true
核心 1 寫入 running = false
→ 先進「store buffer」,之後才寫回快取/主記憶體
→ 核心 0 的 L1 裡還是 true
→ 核心 0 上的執行緒繼續看到 truerunning 沒被這條執行緒改過,會直接把它提到迴圈外面只讀一次(迴圈不變量外提):if (running) { while (true) { ... } }硬體的快取一致性協定(MESI)在各家 CPU 上行為不同。Java 要「一次編譯到處執行」,所以定義了一層抽象:Java Memory Model 規定「什麼情況下,一個執行緒的寫入必須讓另一個執行緒看得到」。
如果操作 A happens-before 操作 B,那麼 A 的結果對 B 一定可見。幾條實用的規則:
unlock happens-before 後續對同一把鎖的 lockprivate volatile int count = 0;
count++; // ❌ 依然是錯的!volatile 保證你讀到的是最新值,但 count++ 的「讀→加→寫」三步之間仍然可能被打斷。volatile 讓每一步都看得到最新值,但沒有讓三步變成一步。
AtomicInteger(CAS)或 synchronized。private volatile boolean running = true;
public void stop() { running = false; } // 單純的寫,不依賴舊值
public void run() { while (running) { ... } } // 單純的讀關鍵是寫入的值不依賴目前的值——這樣就沒有原子性問題,只剩可見性問題,正好是 volatile 的守備範圍。
public class Singleton {
private static volatile Singleton instance; // ← volatile 不能省
public static Singleton getInstance() {
if (instance == null) { // 第一次檢查(不加鎖,快)
synchronized (Singleton.class) {
if (instance == null) { // 第二次檢查(加鎖後再確認)
instance = new Singleton();
}
}
}
return instance;
}
}new Singleton() 實際是三步:① 配置記憶體 ② 執行建構子 ③ 把參照指向記憶體。instance != null(因為 ③ 做完了),直接拿走一個還沒初始化完成的物件——然後在某個看似無關的地方拋 NPE。很多人對 synchronized 的印象還停留在「很重、要避免」。那是 JDK 6 以前的事。
每個 Java 物件的物件頭(Object Header)有一塊 Mark Word,記錄鎖狀態、持有者、雜湊碼、GC 年齡(就是 ch04 說的晉升年齡)。鎖不是額外的物件,就存在物件自己身上。
ReentrantLock 不是「比較快的 synchronized」,它是功能比較多的鎖:
tryLock(3, TimeUnit.SECONDS) —— 拿不到就放棄,這是打破死鎖最實用的工具lockInterruptibly()newCondition(),可以精準喚醒某一群等待者// ReentrantLock 一定要用 try-finally,否則例外時鎖不會釋放
lock.lock();
try {
// 臨界區
} finally {
lock.unlock(); // ← 忘了寫就是永久死鎖
}synchronized(語法簡單、自動釋放、JVM 會優化);只有在需要超時、可中斷、公平性或多條件變數時才換 ReentrantLock。不過——虛擬執行緒改變了這個建議,見最後幾張卡。鎖的本質是「悲觀」:假設一定會衝突,所以先擋住別人。CAS 是樂觀的:先做,做完再檢查有沒有被別人插隊,有的話重試。
compareAndSet(expectedValue, newValue)
「如果現在的值還是 expectedValue,就改成 newValue;
否則什麼都不做,回傳 false(代表被別人改過了)」
這整個「比較並交換」的動作由 CPU 指令保證是原子的(x86 的 CMPXCHG)// AtomicInteger.incrementAndGet() 的核心邏輯
int prev, next;
do {
prev = get();
next = prev + 1;
} while (!compareAndSet(prev, next)); // 失敗就重試執行緒 A 讀到值 = 1
執行緒 B 把它改成 2,然後又改回 1
執行緒 A 執行 CAS(1, 3) → 成功了!
但中間其實發生過事情——對某些場景(例如無鎖鏈結串列)這會出錯解法:AtomicStampedReference,除了值還帶一個版本號,比對「值 + 版本」而不只是值。大多數業務場景不會遇到 ABA(只在意最終數值時無所謂),但寫無鎖資料結構時必須處理。
失敗就重試,代表競爭越激烈、白跑的次數越多、CPU 燒得越兇。這是 CAS 跟鎖的根本取捨:
AtomicLong:所有執行緒搶同一個變數 → 高競爭時大量 CAS 失敗
LongAdder:拆成多個 Cell,每條執行緒各加各的
要讀總和時才把所有 Cell 加起來
→ 把競爭「分散」掉LongAdder。需要每次都拿到精確的當前值 → 用 AtomicLong。執行緒池是實務上最常用、也最常設錯的並發工具。它有七個參數,但真正決定行為的是任務提交時的判斷順序。
① 目前執行緒數 < corePoolSize ?
→ 是:直接開一條新執行緒來跑(就算有閒著的也開)
② 否 → 丟進 workQueue 排隊
③ 佇列滿了 ?
→ 是:再開新執行緒,直到 maximumPoolSize
④ 執行緒數已達 maximumPoolSize 且佇列還是滿的 ?
→ 執行 拒絕策略(RejectedExecutionHandler)LinkedBlockingQueue 不指定容量),佇列永遠不會滿——maximumPoolSize 就完全沒有意義,池子永遠只有 corePoolSize 條執行緒,而任務會無限堆積直到 OOM。Executors.newFixedThreadPool(10)
→ 用無界 LinkedBlockingQueue → 任務堆積到 OOM
Executors.newCachedThreadPool()
→ maximumPoolSize = Integer.MAX_VALUE → 執行緒開到 OOM
Executors.newSingleThreadExecutor()
→ 同樣是無界佇列正確做法:自己 new ThreadPoolExecutor,明確指定每個參數。
new ThreadPoolExecutor(
8, // corePoolSize
16, // maximumPoolSize
60L, TimeUnit.SECONDS, // 非核心執行緒的閒置存活時間
new ArrayBlockingQueue<>(200), // ← 有界!
new ThreadFactoryBuilder()
.setNameFormat("order-worker-%d").build(), // ← 命名,出事時 jstack 看得懂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒絕策略
);RejectedExecutionException。對線上服務通常是對的:快速失敗,讓上游知道。核心數 + 1。開更多只會增加切換成本。核心數 × (1 + 等待時間/計算時間)。等待佔比越高,可以開越多。打破任何一個就不會死鎖——實務上最容易打破的是第 4 個(統一鎖順序)和第 2 個(tryLock 加超時)。
// 轉帳:A 轉給 B,同時 B 轉給 A
synchronized (accountA) {
synchronized (accountB) { ... } // 執行緒 1
}
synchronized (accountB) {
synchronized (accountA) { ... } // 執行緒 2 ← 順序相反
}修法:統一鎖順序——例如永遠先鎖 ID 較小的帳戶,循環就不可能形成。
# 抓執行緒堆疊
jstack <pid> > thread.txt
# JVM 會自動偵測並印出
"Found one Java-level deadlock:"
"Thread-1" waiting to lock monitor 0x... which is held by "Thread-2"
"Thread-2" waiting to lock monitor 0x... which is held by "Thread-1"pool-1-thread-7 什麼都看不出來,order-worker-7 一眼就知道是哪個池出事。ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {
// 父任務:提交一個子任務,然後等它完成
Future<String> child = pool.submit(() -> "done");
return child.get(); // ← 永遠等不到
});jstack 不會標示成 deadlock——因為它不是「鎖」的循環等待,而是「資源」的循環等待,JVM 偵測不到。你只會看到執行緒卡在 Future.get(),佇列裡堆著永遠不會被執行的任務。JDK 21 正式推出的虛擬執行緒(Virtual Threads),改變的是「執行緒是稀缺資源」這個前提。
-Xmx 管)所以才需要執行緒池——因為執行緒太貴,必須重複使用。
虛擬執行緒由 JVM 排程,掛在少量「載體執行緒」(carrier thread)上跑。
關鍵:當虛擬執行緒遇到阻塞 I/O 時
→ JVM 把它的堆疊「搬到 heap 上」,釋放載體執行緒
→ 載體執行緒立刻去跑別的虛擬執行緒
→ I/O 完成後,虛擬執行緒再被掛回某條載體執行緒繼續
結果:阻塞的是虛擬執行緒,不是作業系統執行緒Executors.newVirtualThreadPerTaskExecutor(),每個任務一條synchronized 區塊裡阻塞,它無法把堆疊搬走——載體執行緒被「釘住」了。synchronized 換成 ReentrantLock(它有支援虛擬執行緒的讓出機制)。-Djdk.tracePinnedThreads=full 會印出被釘住的堆疊。不能。虛擬執行緒省下的是「等待時佔住 OS 執行緒」的浪費。如果你的任務是純運算,它從頭到尾都在用 CPU,沒有等待可以省——反而多了排程開銷。CPU 密集請繼續用固定大小的平台執行緒池。
寫得出正確的並發程式碼是一回事,需不需要寫是另一回事。實務上大多數業務程式碼根本不該出現鎖。
@Service 是單例,成員變數放可變狀態就是 bug,這是最常見的並發錯誤來源。final,天生執行緒安全,不需要任何同步。ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue。它們內部已經處理好,而且比你自己加鎖快。AtomicInteger、LongAdder。Collections.synchronizedMap() 不等於 ConcurrentHashMap。前者是把每個方法都加上同一把鎖(全表鎖),高並發下效能很差;後者用分段的 CAS + synchronized,只鎖到桶的層級。@Service
public class OrderService {
// ❌ 這把鎖只在「這個 JVM 內」有效
private final Object lock = new Object();
public void createOrder(String userId) {
synchronized (lock) {
if (orderRepo.existsByUserId(userId)) return; // 檢查
orderRepo.save(new Order(userId)); // 建立
}
}
}DuplicateKeyException@Version(回到 ch02)SELECT ... FOR UPDATE,範圍要小| 工具 | 原子性 | 可見性 | 有序性 | 適用場景 |
|---|---|---|---|---|
| volatile | ❌ 不保證 | ✅ | ✅ | 狀態旗標、DCL 單例——寫入值不依賴舊值時 |
| synchronized | ✅ | ✅ | ✅ | 預設選擇;無競爭時幾乎免費,競爭才升級為重量級鎖 |
| ReentrantLock | ✅ | ✅ | ✅ | 需要超時/可中斷/公平/多條件變數;虛擬執行緒環境優先選它 |
| Atomic 類(CAS) | ✅ | ✅ | ✅ | 低競爭的計數器;高競爭時自旋燒 CPU |
| LongAdder | ✅ | ✅ | ✅ | 高頻累加、低頻讀總值(統計指標);分段累加把競爭分散 |
| 階段 | 條件 | 行為 | 最容易踩的坑 |
|---|---|---|---|
| ① 開核心執行緒 | 執行緒數 < corePoolSize | 直接開新執行緒(就算有閒著的也開) | — |
| ② 排隊 | 核心滿了 | 丟進 workQueue | 用無界佇列 → 永遠不會滿 → maximumPoolSize 完全失效 |
| ③ 開非核心執行緒 | 佇列滿了 | 再開新執行緒直到 maximumPoolSize | 反直覺:是佇列滿了才開,不是執行緒忙了才排隊 |
| ④ 拒絕 | 執行緒達上限且佇列仍滿 | 執行 RejectedExecutionHandler | DiscardPolicy 會讓任務無聲消失;線上服務建議 Abort 或 CallerRuns |
| 面向 | 平台執行緒 | 虛擬執行緒(JDK 21+) |
|---|---|---|
| 對應什麼 | 一條作業系統執行緒 | JVM 排程,掛在少量載體執行緒上 |
| 成本 | 堆疊 512KB~1MB,幾千條就是上限 | 極低,可以開幾百萬條 |
| 阻塞時 | 佔住整條 OS 執行緒 | 堆疊搬到 heap,釋放載體執行緒去跑別的 |
| 要不要池化 | 要,這就是執行緒池存在的理由 | 不要,每個任務一條即可 |
| 適合 | CPU 密集(純運算) | I/O 密集;CPU 密集反而更差(多了排程開銷) |
| 主要陷阱 | 池參數設錯、無界佇列 OOM | synchronized 內阻塞會釘住載體執行緒,要換 ReentrantLock |
點擊卡片翻面查看答案,共 16 張。