Java 後端底層 ch05 執行緒與鎖:從 CPU 快取到虛擬執行緒
下一章→
CH 05 並發

執行緒與鎖:從 CPU 快取到虛擬執行緒

i++ 為什麼不是原子的可見性的根源是快取不是 Javavolatile 保證什麼執行緒池的反直覺順序死鎖診斷虛擬執行緒與釘住問題

先看一段每個人都覺得沒問題的程式碼:

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++ 不是原子的,以及並發需要的三個保證:原子性、可見性、有序性。
  • 可見性問題的真正根源是 CPU 快取——理解這一點之後,volatile 就不需要死記。
  • volatile 保證什麼、不保證什麼:最常見的誤用是以為它能取代鎖。
  • synchronized 的鎖升級,以及它跟 ReentrantLock 的取捨。
  • 執行緒池最反直覺的一件事:佇列滿了才會開新執行緒,不是執行緒滿了才排隊。
  • 死鎖的診斷,包含一種更陰險的:執行緒池自己把自己鎖死。
  • 虛擬執行緒(JDK 21):它解決了什麼,以及那個會讓效能不升反降的「釘住」陷阱。

這一章跟 ch03 連線池 關係緊密:執行緒池與連線池是同一種資源管理問題的兩面,而虛擬執行緒改變了其中一邊的前提、卻沒有改變另一邊——最後一張卡會講這個差異。

並發之所以難,是因為有三種完全不同的問題,而它們的解法也不同。先把三種問題分清楚,後面所有工具(volatile、synchronized、CAS)才知道各自在解哪一個。

問題一:原子性 —— i++ 其實是三條指令

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 → 三個都解決
• 原子類(CAS)→ 解決原子性與可見性

「為什麼 B 改了 A 看不到」——這件事跟 Java 沒什麼關係,是現代 CPU 的必然結果。

起點:記憶體太慢了

回想 ch01 講磁碟時的同一個邏輯,只是換了一層:

  • CPU 暫存器:< 1 個時脈週期
  • L1 快取:約 4 個週期
  • L3 快取:約 40 個週期
  • 主記憶體:約 200 個週期

如果每次讀寫變數都要去主記憶體,CPU 大部分時間都在等。所以每個核心都有自己的 L1/L2 快取。

問題就出在「自己的」

核心 0                        核心 1
┌──────────┐                 ┌──────────┐
│ L1 快取   │                 │ L1 快取   │
│ running  │                 │ running  │
│  = true  │                 │  = true  │
└────┬─────┘                 └────┬─────┘
     └──────────┬─────────────────┘
           主記憶體  running = true

核心 1 寫入 running = false
  → 先進「store buffer」,之後才寫回快取/主記憶體
  → 核心 0 的 L1 裡還是 true
  → 核心 0 上的執行緒繼續看到 true
更糟的是,JIT 還會火上加油。編譯器看到迴圈裡的 running 沒被這條執行緒改過,會直接把它提到迴圈外面只讀一次(迴圈不變量外提):
if (running) { while (true) { ... } }
——這下不管記憶體怎麼變,這條執行緒都不會再讀了。

JMM:Java 記憶體模型在做什麼

硬體的快取一致性協定(MESI)在各家 CPU 上行為不同。Java 要「一次編譯到處執行」,所以定義了一層抽象:Java Memory Model 規定「什麼情況下,一個執行緒的寫入必須讓另一個執行緒看得到」。

happens-before:唯一需要記住的規則

如果操作 A happens-before 操作 B,那麼 A 的結果對 B 一定可見。幾條實用的規則:

  • 程式順序規則:同一條執行緒內,前面的操作 happens-before 後面的
  • volatile 規則:對 volatile 變數的寫 happens-before 後續對它的讀
  • 鎖規則:unlock happens-before 後續對同一把鎖的 lock
  • 傳遞性:A hb B、B hb C,則 A hb C
✅ 「鎖規則」的推論很重要:離開 synchronized 區塊時,這條執行緒在區塊內的所有修改都會對下一個拿到鎖的執行緒可見——不只是被鎖保護的那個變數。這就是為什麼 synchronized 同時解決了原子性和可見性。

它做的兩件事

  1. 可見性——寫入時強制刷到主記憶體,讀取時強制從主記憶體讀(不用自己的快取)
  2. 禁止指令重排——在讀寫前後插入記憶體屏障(memory barrier)

它不做的那件事:原子性

private volatile int count = 0;

count++;   // ❌ 依然是錯的!

volatile 保證你讀到的是最新值,但 count++ 的「讀→加→寫」三步之間仍然可能被打斷。volatile 讓每一步都看得到最新值,但沒有讓三步變成一步。

這是實務上最常見的誤用:以為加了 volatile 就等於加了鎖。要原子性請用 AtomicInteger(CAS)或 synchronized。

正確用法一:狀態旗標(volatile 的經典場景)

private volatile boolean running = true;

public void stop() { running = false; }        // 單純的寫,不依賴舊值
public void run()  { while (running) { ... } } // 單純的讀

關鍵是寫入的值不依賴目前的值——這樣就沒有原子性問題,只剩可見性問題,正好是 volatile 的守備範圍。

正確用法二:雙重檢查鎖(DCL)

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;
    }
}
為什麼這裡的 volatile 絕對不能省?
new Singleton() 實際是三步:① 配置記憶體 ② 執行建構子 ③ 把參照指向記憶體。
如果 ②③ 被重排成 ③②,另一條執行緒在第一次檢查時會看到 instance != null(因為 ③ 做完了),直接拿走一個還沒初始化完成的物件——然後在某個看似無關的地方拋 NPE。

volatile 禁止這個重排。這是「有序性」在實務上最經典的案例。
💡 不過在 Java 裡,單例更簡單的寫法是靜態內部類(利用 JVM 的類別初始化鎖)或 enum。DCL 值得理解,但不一定要用。

很多人對 synchronized 的印象還停留在「很重、要避免」。那是 JDK 6 以前的事。

鎖資訊存在物件頭裡

每個 Java 物件的物件頭(Object Header)有一塊 Mark Word,記錄鎖狀態、持有者、雜湊碼、GC 年齡(就是 ch04 說的晉升年齡)。鎖不是額外的物件,就存在物件自己身上。

鎖升級:只升不降

  1. 無鎖
  2. 偏向鎖(Biased)——如果從頭到尾只有一條執行緒進來過,在 Mark Word 記下它的 ID,之後那條執行緒進出幾乎零成本。
    (JDK 15 起預設關閉、JDK 18 移除——因為現代應用的執行緒競爭普遍,維護偏向鎖撤銷的成本反而高於收益。)
  3. 輕量級鎖(Lightweight)——有第二條執行緒來競爭時升級。用 CAS 自旋:不掛起,原地轉幾圈等對方放開。適合持鎖時間很短的場景(省下執行緒切換的成本)。
  4. 重量級鎖(Heavyweight)——自旋幾次還拿不到就升級。執行緒真的被掛起,要經過作業系統的排程器,需要使用者態/核心態切換,成本高。
推論:synchronized 慢不慢,取決於競爭程度而不是關鍵字本身。沒有競爭時它幾乎免費;競爭激烈時才會升到重量級。所以「不要用 synchronized」是過時的建議,該做的是縮小同步區塊。

synchronized vs ReentrantLock

ReentrantLock 不是「比較快的 synchronized」,它是功能比較多的鎖:

  • 可以設超時:tryLock(3, TimeUnit.SECONDS) —— 拿不到就放棄,這是打破死鎖最實用的工具
  • 可以中斷:lockInterruptibly()
  • 可以是公平鎖:先到先得(但吞吐量較差,通常不用)
  • 多個條件變數:newCondition(),可以精準喚醒某一群等待者
// ReentrantLock 一定要用 try-finally,否則例外時鎖不會釋放
lock.lock();
try {
    // 臨界區
} finally {
    lock.unlock();   // ← 忘了寫就是永久死鎖
}
💡 選擇準則:預設用 synchronized(語法簡單、自動釋放、JVM 會優化);只有在需要超時、可中斷、公平性或多條件變數時才換 ReentrantLock。不過——虛擬執行緒改變了這個建議,見最後幾張卡。

鎖的本質是「悲觀」:假設一定會衝突,所以先擋住別人。CAS 是樂觀的:先做,做完再檢查有沒有被別人插隊,有的話重試。

CAS(Compare-And-Swap)

compareAndSet(expectedValue, newValue)

「如果現在的值還是 expectedValue,就改成 newValue;
  否則什麼都不做,回傳 false(代表被別人改過了)」

這整個「比較並交換」的動作由 CPU 指令保證是原子的(x86 的 CMPXCHG)
// AtomicInteger.incrementAndGet() 的核心邏輯
int prev, next;
do {
    prev = get();
    next = prev + 1;
} while (!compareAndSet(prev, next));   // 失敗就重試
跟 ch02 的樂觀鎖是同一個思想:資料庫用 WHERE version = ?,CAS 用 compareAndSet(expected, new)——都是「先做再驗證,衝突了就重試」。

ABA 問題

執行緒 A 讀到值 = 1
執行緒 B 把它改成 2,然後又改回 1
執行緒 A 執行 CAS(1, 3)  →  成功了!

但中間其實發生過事情——對某些場景(例如無鎖鏈結串列)這會出錯

解法:AtomicStampedReference,除了值還帶一個版本號,比對「值 + 版本」而不只是值。大多數業務場景不會遇到 ABA(只在意最終數值時無所謂),但寫無鎖資料結構時必須處理。

CAS 的代價:高競爭時自旋很浪費

失敗就重試,代表競爭越激烈、白跑的次數越多、CPU 燒得越兇。這是 CAS 跟鎖的根本取捨:

  • 低競爭:CAS 完勝(沒有執行緒切換成本)
  • 高競爭:鎖反而更好(掛起等待不燒 CPU)

LongAdder:高競爭下的解法

AtomicLong:所有執行緒搶同一個變數  →  高競爭時大量 CAS 失敗

LongAdder:拆成多個 Cell,每條執行緒各加各的
           要讀總和時才把所有 Cell 加起來
           → 把競爭「分散」掉
✅ 判斷準則:需要頻繁累加、但很少讀總值(例如計數器、統計指標)→ 用 LongAdder。需要每次都拿到精確的當前值 → 用 AtomicLong。

執行緒池是實務上最常用、也最常設錯的並發工具。它有七個參數,但真正決定行為的是任務提交時的判斷順序。

提交一個任務時,實際發生的事

① 目前執行緒數 < corePoolSize ?
   → 是:直接開一條新執行緒來跑(就算有閒著的也開)

② 否 → 丟進 workQueue 排隊

③ 佇列滿了 ?
   → 是:再開新執行緒,直到 maximumPoolSize

④ 執行緒數已達 maximumPoolSize 且佇列還是滿的 ?
   → 執行 拒絕策略(RejectedExecutionHandler)
反直覺的關鍵在第 ②③ 步:是「佇列滿了才開新執行緒」,不是「執行緒都忙了才排隊」。

所以如果你用了無界佇列(LinkedBlockingQueue 不指定容量),佇列永遠不會滿——maximumPoolSize 就完全沒有意義,池子永遠只有 corePoolSize 條執行緒,而任務會無限堆積直到 OOM。

為什麼不要用 Executors 的工廠方法

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()        // 拒絕策略
);

四種拒絕策略

  • AbortPolicy(預設)——丟 RejectedExecutionException。對線上服務通常是對的:快速失敗,讓上游知道。
  • CallerRunsPolicy——由呼叫者自己執行。天然的反壓(back-pressure):提交任務的執行緒被佔住,就沒空再提交,流量自然減緩。
  • DiscardPolicy——默默丟掉。危險,任務憑空消失且無感。
  • DiscardOldestPolicy——丟掉佇列裡最舊的。同樣危險。

池大小怎麼估

  • CPU 密集(純運算):核心數 + 1。開更多只會增加切換成本。
  • I/O 密集(等資料庫、等 API):核心數 × (1 + 等待時間/計算時間)。等待佔比越高,可以開越多。
💡 跟 ch03 對照:執行緒池大小可以比連線池大很多,因為執行緒不只用來等資料庫。但只要下游是資料庫,真正的並行上限仍然是連線池。

死鎖的四個必要條件(缺一不可)

  1. 互斥:資源同時只能被一個執行緒持有
  2. 持有並等待:拿著 A 的同時去要 B
  3. 不可剝奪:不能強制搶走別人手上的鎖
  4. 循環等待:A 等 B、B 等 A

打破任何一個就不會死鎖——實務上最容易打破的是第 4 個(統一鎖順序)和第 2 個(tryLock 加超時)。

典型的循環等待

// 轉帳:A 轉給 B,同時 B 轉給 A
synchronized (accountA) {
    synchronized (accountB) { ... }     // 執行緒 1
}

synchronized (accountB) {
    synchronized (accountA) { ... }     // 執行緒 2  ← 順序相反
}

修法:統一鎖順序——例如永遠先鎖 ID 較小的帳戶,循環就不可能形成。

徵狀與診斷

  • 徵狀:某些請求永遠不返回(不是變慢,是完全卡住);執行緒數持續上升;CPU 使用率很低(大家都在等,沒人在跑)
# 抓執行緒堆疊
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();     // ← 永遠等不到
});
  1. 父任務佔用了唯一的那條執行緒
  2. 子任務被丟進佇列,但沒有執行緒可以執行它
  3. 父任務在等子任務,子任務在等執行緒——完美的死鎖
這種死鎖 jstack 不會標示成 deadlock——因為它不是「鎖」的循環等待,而是「資源」的循環等待,JVM 偵測不到。你只會看到執行緒卡在 Future.get(),佇列裡堆著永遠不會被執行的任務。

規則:不要在執行緒池的任務裡,提交任務到同一個池並等待結果。真的需要的話用兩個獨立的池。

JDK 21 正式推出的虛擬執行緒(Virtual Threads),改變的是「執行緒是稀缺資源」這個前提。

平台執行緒為什麼稀缺

  • 每條對應一個作業系統執行緒
  • 預設堆疊 512KB~1MB(ch04 講過,這塊不受 -Xmx 管)
  • 建立、銷毀、切換都要經過核心排程器
  • 幾千條就是實務上限

所以才需要執行緒池——因為執行緒太貴,必須重複使用。

虛擬執行緒做了什麼

虛擬執行緒由 JVM 排程,掛在少量「載體執行緒」(carrier thread)上跑。

關鍵:當虛擬執行緒遇到阻塞 I/O 時
  → JVM 把它的堆疊「搬到 heap 上」,釋放載體執行緒
  → 載體執行緒立刻去跑別的虛擬執行緒
  → I/O 完成後,虛擬執行緒再被掛回某條載體執行緒繼續

結果:阻塞的是虛擬執行緒,不是作業系統執行緒
  • 建立成本極低,可以開幾百萬條
  • 寫的還是同步阻塞的程式碼(不用改成 reactive 那種回呼地獄)
  • 不需要池化——用 Executors.newVirtualThreadPerTaskExecutor(),每個任務一條

陷阱一:釘住(Pinning)

如果虛擬執行緒在 synchronized 區塊裡阻塞,它無法把堆疊搬走——載體執行緒被「釘住」了。

後果:那條作業系統執行緒被佔住不放,虛擬執行緒的好處完全消失。如果所有載體執行緒都被釘住,整個應用會卡死——而且因為載體執行緒池預設只有 CPU 核心數那麼大,這比你想像的容易發生。

修法:把可能阻塞的 synchronized 換成 ReentrantLock(它有支援虛擬執行緒的讓出機制)。
診斷:-Djdk.tracePinnedThreads=full 會印出被釘住的堆疊。

這也讓上面那條「預設用 synchronized」的建議需要修正:在虛擬執行緒的環境下,鎖住 I/O 的區塊要優先選 ReentrantLock。

陷阱二:以為它能加速 CPU 密集的工作

不能。虛擬執行緒省下的是「等待時佔住 OS 執行緒」的浪費。如果你的任務是純運算,它從頭到尾都在用 CPU,沒有等待可以省——反而多了排程開銷。CPU 密集請繼續用固定大小的平台執行緒池。

對連線池的影響(重點)

🚨 虛擬執行緒讓「執行緒不再稀缺」,但資料庫連線依然稀缺。
如果你把執行緒放開成幾十萬條,而連線池還是 10——結果只是幾十萬條虛擬執行緒同時卡在等連線。

更危險的是:原本平台執行緒池(例如 200 條)本身就是一道天然的限流閘門,換成虛擬執行緒之後那道閘門消失了,所有請求會一路湧到下游。ch03 的三層上限對齊在這裡變得更重要,而不是更不重要。

寫得出正確的並發程式碼是一回事,需不需要寫是另一回事。實務上大多數業務程式碼根本不該出現鎖。

優先順序:能不用鎖就不用

  1. 無共享狀態——把可變狀態留在方法區域變數裡。Spring 的 @Service 是單例,成員變數放可變狀態就是 bug,這是最常見的並發錯誤來源。
  2. 不可變物件——欄位全部 final,天生執行緒安全,不需要任何同步。
  3. 併發容器——ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue。它們內部已經處理好,而且比你自己加鎖快。
  4. 原子類——AtomicInteger、LongAdder。
  5. 鎖——以上都不行時才用,而且盡量縮小範圍。
順帶提醒:Collections.synchronizedMap() 不等於 ConcurrentHashMap。前者是把每個方法都加上同一把鎖(全表鎖),高並發下效能很差;後者用分段的 CAS + synchronized,只鎖到桶的層級。

最常見的致命誤用:多實例部署下用 JVM 鎖

@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));              // 建立
        }
    }
}
單機測試完全正常,上了 Kubernetes 跑 3 個 Pod 就開始出現重複訂單。
因為三個 JVM 各有各的鎖物件,彼此完全不知道對方在做什麼——鎖形同虛設。

正確做法(由簡到繁):
① 資料庫唯一索引——最簡單也最可靠,讓資料庫幫你擋,捕捉 DuplicateKeyException
② 樂觀鎖——JPA 的 @Version(回到 ch02)
③ 悲觀鎖——SELECT ... FOR UPDATE,範圍要小
④ 分散式鎖——Redis(Redisson)或 ZooKeeper,但要處理鎖過期、續期、可重入等一堆邊界情況,是最後手段

一句話收尾

🚨 寫鎖之前先問:這個狀態真的需要共享嗎?如果需要,它應該共享在 JVM 裡還是資料庫裡?絕大多數情況答案是後者——而資料庫已經有一整套成熟的並發控制機制(ch02),不需要你在 JVM 裡重新發明一次。
並發工具對照:各自解決哪個問題
工具原子性可見性有序性適用場景
volatile❌ 不保證✅✅狀態旗標、DCL 單例——寫入值不依賴舊值時
synchronized✅✅✅預設選擇;無競爭時幾乎免費,競爭才升級為重量級鎖
ReentrantLock✅✅✅需要超時/可中斷/公平/多條件變數;虛擬執行緒環境優先選它
Atomic 類(CAS)✅✅✅低競爭的計數器;高競爭時自旋燒 CPU
LongAdder✅✅✅高頻累加、低頻讀總值(統計指標);分段累加把競爭分散
執行緒池:提交任務的判斷順序與拒絕策略
階段條件行為最容易踩的坑
① 開核心執行緒執行緒數 < corePoolSize直接開新執行緒(就算有閒著的也開)—
② 排隊核心滿了丟進 workQueue用無界佇列 → 永遠不會滿 → maximumPoolSize 完全失效
③ 開非核心執行緒佇列滿了再開新執行緒直到 maximumPoolSize反直覺:是佇列滿了才開,不是執行緒忙了才排隊
④ 拒絕執行緒達上限且佇列仍滿執行 RejectedExecutionHandlerDiscardPolicy 會讓任務無聲消失;線上服務建議 Abort 或 CallerRuns
平台執行緒 vs 虛擬執行緒
面向平台執行緒虛擬執行緒(JDK 21+)
對應什麼一條作業系統執行緒JVM 排程,掛在少量載體執行緒上
成本堆疊 512KB~1MB,幾千條就是上限極低,可以開幾百萬條
阻塞時佔住整條 OS 執行緒堆疊搬到 heap,釋放載體執行緒去跑別的
要不要池化要,這就是執行緒池存在的理由不要,每個任務一條即可
適合CPU 密集(純運算)I/O 密集;CPU 密集反而更差(多了排程開銷)
主要陷阱池參數設錯、無界佇列 OOMsynchronized 內阻塞會釘住載體執行緒,要換 ReentrantLock

練習題 點選選項查看解析

0 / 10
01 / 10
為什麼 volatile int count 上的 count++ 依然不是執行緒安全的?
A 因為 volatile 只對 long 和 double 有效
B 因為 volatile 保證可見性與有序性,但 count++ 的「讀→加→寫」三步之間仍可能被打斷
C 因為 volatile 只在單核 CPU 上有效
D 它其實是安全的,這是常見誤解
解析
volatile 讓每一步都讀寫到最新值,但沒有把三步變成一步。要原子性必須用 AtomicInteger(CAS)或 synchronized。「以為加了 volatile 就等於加了鎖」是實務上最常見的誤用。
02 / 10
可見性問題(一條執行緒改了、另一條看不到)的根本原因是什麼?
A JVM 的 bug
B 每個 CPU 核心有自己的 L1/L2 快取,寫入先進 store buffer,其他核心的快取裡還是舊值;JIT 還可能把變數提到迴圈外只讀一次
C 垃圾回收移動了物件位置
D 執行緒排程器的時間片切換太快
解析
這跟 Java 沒什麼關係,是現代 CPU 的必然結果——主記憶體約 200 個時脈週期,所以每個核心都有自己的快取。JMM 就是為了在各家 CPU 行為不同的情況下,統一定義「什麼時候寫入必須讓別人看得到」。
03 / 10
雙重檢查鎖(DCL)單例裡,instance 為什麼一定要加 volatile?
A 為了讓多執行緒都能讀到 instance
B 因為 new Singleton() 的「配置記憶體→執行建構子→指向記憶體」可能被重排,別的執行緒會拿到還沒初始化完成的物件
C 為了避免 instance 被 GC 回收
D 不需要,加了只是習慣
解析
如果「執行建構子」和「指向記憶體」被重排,另一條執行緒在第一次檢查時會看到 instance != null,直接拿走一個半成品物件,然後在某個看似無關的地方拋 NPE。這是「有序性」在實務上最經典的案例。
04 / 10
關於 synchronized 的效能,下列敘述何者正確?
A 它永遠是重量級鎖,應該一律避免
B 它會依競爭程度升級:無鎖→偏向→輕量級(CAS 自旋)→重量級(執行緒掛起),沒有競爭時幾乎免費
C 它比 ReentrantLock 慢,因為沒有優化
D 它只在單核 CPU 上有效率
解析
「不要用 synchronized」是 JDK 6 以前的過時建議。鎖資訊存在物件頭的 Mark Word 裡,會依競爭程度升級(只升不降)。synchronized 慢不慢取決於競爭程度而不是關鍵字本身,該做的是縮小同步區塊。
05 / 10
Executors.newFixedThreadPool(10) 為什麼不建議用在正式環境?
A 因為固定 10 條執行緒太少
B 它使用無界 LinkedBlockingQueue,佇列永遠不會滿,任務會無限堆積直到 OOM
C 因為它不支援拒絕策略
D 因為它不能設定執行緒名稱
解析
由於佇列永遠不滿,maximumPoolSize 完全失效,而且任務無限堆積會 OOM。newCachedThreadPool 則是另一個極端(maximumPoolSize = Integer.MAX_VALUE,執行緒開到 OOM)。正確做法是自己 new ThreadPoolExecutor 並指定有界佇列。
06 / 10
執行緒池收到新任務時,核心執行緒已滿、佇列還沒滿,會發生什麼?
A 立刻開新執行緒直到 maximumPoolSize
B 把任務放進佇列排隊
C 執行拒絕策略
D 隨機選擇排隊或開新執行緒
解析
這是執行緒池最反直覺的地方:順序是「核心執行緒 → 佇列 → 非核心執行緒 → 拒絕」。也就是佇列滿了才會開新執行緒,不是執行緒忙了才排隊。理解這個順序才知道為什麼無界佇列會讓 maximumPoolSize 失效。
07 / 10
在只有一條執行緒的池裡,任務中又提交任務到同一個池並 get() 等待,會發生什麼?jstack 看得出來嗎?
A 正常執行,JVM 會自動增加執行緒
B 死鎖:父任務佔住唯一執行緒,子任務在佇列裡沒人執行;jstack 不會標示為 deadlock,因為那是資源等待不是鎖的循環
C 會拋出 RejectedExecutionException
D 子任務會在呼叫者執行緒上執行
解析
父任務等子任務、子任務等執行緒,形成完美死鎖。但 JVM 的死鎖偵測只認得「鎖」的循環等待,所以 jstack 不會印出 Found one Java-level deadlock,你只會看到執行緒卡在 Future.get()。規則:不要在池的任務裡提交任務到同一個池並等待。
08 / 10
虛擬執行緒的「釘住(pinning)」是什麼?怎麼避免?
A 虛擬執行緒被綁定到特定 CPU 核心;用 taskset 調整
B 虛擬執行緒在 synchronized 區塊裡阻塞時無法搬走堆疊,佔住載體執行緒;把可能阻塞的 synchronized 換成 ReentrantLock
C 虛擬執行緒的堆疊被釘在 heap 上無法回收;調大 -Xmx
D 虛擬執行緒無法被 GC;手動呼叫 Thread.yield()
解析
被釘住時虛擬執行緒的好處完全消失,而且載體執行緒池預設只有 CPU 核心數那麼大,所有載體都被釘住的話整個應用會卡死。診斷用 -Djdk.tracePinnedThreads=full。這也讓「預設用 synchronized」的建議在虛擬執行緒環境下需要修正。
09 / 10
改用虛擬執行緒之後,資料庫連線池可以放心調大嗎?
A 可以,虛擬執行緒能處理更多並發
B 不行。執行緒不再稀缺但連線依然稀缺;而且原本的平台執行緒池本身是一道限流閘門,換掉之後所有請求會一路湧到下游,三層上限對齊變得更重要
C 不用管,虛擬執行緒會自動限制連線數
D 可以,因為虛擬執行緒不會佔用連線
解析
虛擬執行緒只改變了「執行緒」這一層的前提,資料庫端的瓶頸(CPU、I/O、鎖競爭)完全沒變。把執行緒放開成幾十萬條的結果只是幾十萬條同時卡在等連線。更危險的是失去了原本平台執行緒池提供的天然限流。
10 / 10
@Service 單例裡用 private final Object lock 加 synchronized 做重複訂單檢查,部署 3 個 Pod 後出現重複訂單。為什麼?
A synchronized 用錯了,應該用 ReentrantLock
B 三個 JVM 各有各的鎖物件,彼此不知道對方在做什麼,JVM 鎖在多實例部署下形同虛設
C 因為 @Service 不是單例
D 因為沒有加 volatile
解析
這是最常見的致命誤用:單機測試完全正常,上了多副本就出事。正確做法由簡到繁是:① 資料庫唯一索引(最可靠)② 樂觀鎖 @Version ③ SELECT FOR UPDATE ④ 分散式鎖(最後手段,要處理鎖過期、續期等一堆邊界情況)。

點擊卡片翻面查看答案,共 16 張。

QUESTION
並發的三個保證分別是什麼?各由誰解決?
點擊翻面
ANSWER
原子性(i++ 是三條指令)、可見性(改了別人看不到)、有序性(指令重排)。volatile 解決可見性+有序性但不解決原子性;synchronized/Lock 三個都解決;CAS 原子類解決原子性+可見性。
點擊翻回
QUESTION
可見性問題的根源是什麼?
點擊翻面
ANSWER
每個 CPU 核心有自己的 L1/L2 快取(主記憶體約 200 個時脈週期,太慢)。寫入先進 store buffer,其他核心讀到的還是舊值。JIT 還可能把迴圈裡的變數提到外面只讀一次。
點擊翻回
QUESTION
happens-before 的「鎖規則」推論?
點擊翻面
ANSWER
unlock happens-before 後續對同一把鎖的 lock。推論:離開 synchronized 區塊時,區塊內的所有修改都對下一個拿到鎖的執行緒可見——不只是被鎖保護的那個變數。
點擊翻回
QUESTION
volatile 的兩個正確用法?共同前提是什麼?
點擊翻面
ANSWER
① 狀態旗標(running = false)② DCL 單例的 instance。共同前提:寫入的值不依賴目前的值,所以沒有原子性問題,只剩可見性問題。
點擊翻回
QUESTION
DCL 單例的 volatile 為什麼不能省?
點擊翻面
ANSWER
new Singleton() 是三步:配置記憶體→執行建構子→指向記憶體。②③ 若被重排,別的執行緒會在第一次檢查看到非 null,拿走一個還沒初始化完的物件,然後在別處拋 NPE。
點擊翻回
QUESTION
synchronized 的鎖升級路徑?
點擊翻面
ANSWER
無鎖 → 偏向鎖(JDK 15 起關閉、18 移除)→ 輕量級鎖(CAS 自旋,適合持鎖時間短)→ 重量級鎖(執行緒掛起,要經過 OS 排程)。只升不降,鎖資訊存在物件頭的 Mark Word。
點擊翻回
QUESTION
什麼時候該選 ReentrantLock 而不是 synchronized?
點擊翻面
ANSWER
需要超時 tryLock(3, SECONDS)、可中斷、公平鎖、多條件變數時。另外在虛擬執行緒環境下,可能阻塞的區塊要優先選它(避免釘住)。記得一定要 try-finally 解鎖。
點擊翻回
QUESTION
CAS 是什麼?跟資料庫樂觀鎖的關係?
點擊翻面
ANSWER
compareAndSet(expected, new)——值還是 expected 才改,否則回傳 false 重試,由 CPU 指令保證原子。跟 ch02 的 WHERE version = ? 是同一個思想:先做再驗證,衝突就重試。
點擊翻回
QUESTION
AtomicLong 和 LongAdder 怎麼選?
點擊翻面
ANSWER
高頻累加、低頻讀總值(計數器、統計指標)→ LongAdder,它拆成多個 Cell 把競爭分散掉。每次都要精確當前值 → AtomicLong。CAS 在高競爭下自旋會燒 CPU。
點擊翻回
QUESTION
★ 執行緒池提交任務的順序?
點擊翻面
ANSWER
① 執行緒數 < core → 開新執行緒 ② 否則進佇列 ③ 佇列滿了才開到 max ④ 都滿了才拒絕。反直覺的是②③:佇列滿了才開新執行緒,不是執行緒忙了才排隊。
點擊翻回
QUESTION
為什麼不能用 Executors.newFixedThreadPool()?
點擊翻面
ANSWER
它用無界 LinkedBlockingQueue,佇列永遠不滿 → maximumPoolSize 失效、任務堆積到 OOM。newCachedThreadPool 則是 max = Integer.MAX_VALUE,執行緒開到 OOM。要自己 new ThreadPoolExecutor 並用有界佇列。
點擊翻回
QUESTION
四種拒絕策略怎麼選?
點擊翻面
ANSWER
AbortPolicy(預設,快速失敗,線上服務通常對);CallerRunsPolicy(呼叫者自己跑,天然反壓);DiscardPolicy / DiscardOldestPolicy 都危險——任務會無聲消失。
點擊翻回
QUESTION
執行緒池死鎖的變體,為什麼 jstack 抓不到?
點擊翻面
ANSWER
父任務佔住唯一執行緒、子任務在佇列裡沒人執行、父任務等子任務。JVM 的死鎖偵測只認得「鎖」的循環等待,這是「資源」的循環等待,所以不會印 Found one Java-level deadlock,只看到卡在 Future.get()。
點擊翻回
QUESTION
虛擬執行緒解決了什麼?兩個陷阱是什麼?
點擊翻面
ANSWER
阻塞時把堆疊搬到 heap、釋放載體執行緒,所以可以開幾百萬條、不需要池化。陷阱:① synchronized 內阻塞會釘住載體執行緒(換 ReentrantLock,用 -Djdk.tracePinnedThreads=full 診斷)② CPU 密集反而更差。
點擊翻回
QUESTION
虛擬執行緒對連線池的影響?
點擊翻面
ANSWER
執行緒不再稀缺,但連線依然稀缺——幾十萬條虛擬執行緒只會同時卡在等連線。更危險的是原本平台執行緒池是一道天然限流閘門,換掉之後請求會一路湧到下游,ch03 的三層上限對齊變得更重要。
點擊翻回
QUESTION
為什麼 JVM 鎖在多實例部署下形同虛設?該怎麼做?
點擊翻面
ANSWER
三個 Pod 就是三個 JVM,各有各的鎖物件。正確做法由簡到繁:① 資料庫唯一索引(最可靠)② @Version 樂觀鎖 ③ SELECT FOR UPDATE ④ 分散式鎖(最後手段)。先問:這個狀態該共享在 JVM 裡還是資料庫裡?
點擊翻回