學完本章你將能夠:
分辨程序與執行緒,說明為什麼需要多執行緒。
用三種方式建立執行緒,並理解執行緒的六種狀態。
正確處理中斷機制,避免吞掉 |InterruptedException|。
重現競態條件,並用 |synchronized| 與 |volatile| 解決 原子性與可見性問題。
用 |wait|/|notify| 實作生產者消費者模型。
辨識並預防死結,盤點常見類別的執行緒安全等級。
現代 CPU 早已是多核心的天下,一支只會用單一執行緒的程式,等於買了八線道高速 公路卻只開一條車道。多執行緒讓程式能同時處理多件事:一邊下載檔案、一邊更新 進度條,或讓伺服器同時服務上千個使用者。但並行也是 Java 中最容易出錯、最難 除錯的領域——本章與下一章將帶你把這塊硬骨頭啃下來。
我們先建立正確的心智模型:多個執行緒共享同一塊記憶體(堆積),這既是它們高效 協作的原因,也是所有並行 bug 的根源。理解「共享」帶來的問題,是掌握並行的 第一步。
程序與執行緒
程序(process)是「執行中的程式」,作業系統為它配置獨立的記憶體空間;執行緒 (thread)則是程序內的執行單位,同一程序的多個執行緒共享該程序的記憶體。
| 比較項目 | 程序(process) | 執行緒(thread) |
|---|---|---|
| 記憶體 | 各自獨立 | 共享所屬程序的記憶體 |
| 建立成本 | 高 | 相對低 |
| 通訊方式 | 需 IPC(較複雜) | 直接讀寫共享變數 |
| 隔離性 | 強(一個崩潰不影響他者) | 弱(一個出錯可能拖垮整程序) |
我們追求多執行緒,通常是為了三個目標:回應性(UI 執行緒不被耗時工作 卡住)、吞吐量(同時處理更多請求)、多核心利用(讓運算真正 平行以縮短總時間)。
建立執行緒的三種方式
方式一:繼承 Thread
第一種方式是繼承 |Thread| 類別並覆寫 |run| 方法。呼叫 |start|(不是 |run|!)才會真正開啟新執行緒。
/** * 繼承 Thread 並覆寫 run。 * 缺點:Java 單一繼承,繼承了 Thread 就無法再繼承其他類別。 */ public class ThreadBySubclass extends Thread
@Override public void run() // 這段程式碼會在新執行緒中執行 System.out.println("執行緒名稱:" + getName());
public static void main(String[] args) ThreadBySubclass t = new ThreadBySubclass(); t.start(); // 啟動新執行緒(呼叫 run() 只是普通方法呼叫)
執行緒名稱:Thread-0
方式二:實作 Runnable(建議)
第二種方式是實作 |Runnable| 介面,再把它交給 |Thread|。這是業界 建議的做法,因為它把「要做的事」(|Runnable|)與「執行的機制」 (|Thread|)分離,符合關注點分離原則,也保留了繼承其他類別的彈性。
/** * 實作 Runnable,再交給 Thread 執行。 * Runnable 是函數式介面,可直接用 Lambda(呼應第 1 章)。 */ public class ThreadByRunnable
public static void main(String[] args)
// 方式二:具名或匿名實作 Runnable Runnable task = () -> System.out.println( "在 " + Thread.currentThread().getName() + " 中執行");
Thread t1 = new Thread(task, "worker-1"); t1.start();
// 方式三:直接把 Lambda 傳給 Thread(最精簡) new Thread(() -> System.out.println("Lambda 執行緒"), "worker-2").start();
在 worker-1 中執行 Lambda 執行緒 (註:兩行輸出的順序可能不同)
| 方式 | 優點 | 缺點 |
|---|---|---|
| 繼承 Thread | 寫法直覺 | 佔用唯一的繼承名額 |
| 實作 Runnable | 關注點分離、可再繼承 | 需多包一層 Thread |
| Lambda | 最精簡 | 僅適合簡短工作 |
啟動執行緒一定要呼叫 |start()| 而非 |run()|。直接呼叫 |run()| 只是在目前執行緒裡執行那段程式碼,根本沒有開新執行緒, 是初學者最常見的錯誤。此外,同一個 |Thread| 物件不能 |start()| 兩次,否則會拋出 |IllegalThreadStateException|。
執行緒的生命週期與狀態
一個執行緒從建立到結束,會歷經六種狀態(定義於 |Thread.State| 列舉):
| 狀態 | 意義 |
|---|---|
| |NEW| | 已建立但尚未 |start| |
| |RUNNABLE| | 可執行(正在執行或等待 CPU 排程) |
| |BLOCKED| | 等待進入 |synchronized| 區塊的鎖 |
| |WAITING| | 無限期等待(如 |wait|、|join| 無逾時) |
| |TIMED_WAITING| | 有限期等待(如 |sleep|、有逾時的 |wait|) |
| |TERMINATED| | 執行完畢或因例外結束 |
用 |getState| 可以觀察狀態變化:
/** * 觀察執行緒在不同時點的狀態。 */ public class ThreadStateDemo
public static void main(String[] args) throws InterruptedException
Thread t = new Thread(() -> try Thread.sleep(100); // 進入 TIMED_WAITING catch (InterruptedException e) Thread.currentThread().interrupt(); );
System.out.println("start 前:" + t.getState()); // NEW t.start(); Thread.sleep(20); // 讓 t 有機會進入 sleep System.out.println("sleep 中:" + t.getState()); // TIMED_WAITING t.join(); // 等 t 結束 System.out.println("結束後:" + t.getState()); // TERMINATED
start 前:NEW sleep 中:TIMED_WAITING 結束後:TERMINATED
執行緒的常用方法
|sleep| 讓目前執行緒暫停一段時間,它是靜態方法,且不會 釋放持有的鎖;|join| 讓目前執行緒等待另一執行緒結束;|yield| 提示 排程器讓出 CPU(僅建議,不保證);|setDaemon| 把執行緒設為守護執行緒。
/** * 用 join 確保主執行緒在工作執行緒完成後才繼續。 */ public class JoinDemo
public static void main(String[] args) throws InterruptedException
Thread worker = new Thread(() -> for (int i = 1; i <= 3; i++) System.out.println("工作進度:" + i); try Thread.sleep(50); catch (InterruptedException e) Thread.currentThread().interrupt(); );
worker.start(); worker.join(); // 主執行緒在此等待 worker 結束 System.out.println("工作已完成,主執行緒繼續");
工作進度:1 工作進度:2 工作進度:3 工作已完成,主執行緒繼續
守護執行緒(daemon thread)是「背景服務」型的執行緒,當所有非守護執行緒都結束 時,JVM 不會等它,會直接關閉。適合用於心跳、監控等背景工作。
/** * 守護執行緒示範:背景心跳。 * 主執行緒結束後,JVM 不等守護執行緒即關閉。 */ public class DaemonDemo
public static void main(String[] args) throws InterruptedException
Thread heartbeat = new Thread(() -> while (true) // 看似無限迴圈 System.out.println("心跳…"); try Thread.sleep(30); catch (InterruptedException e) break; );
heartbeat.setDaemon(true); // 必須在 start 前設定 heartbeat.start(); Thread.sleep(100); // 主執行緒只活這麼久 System.out.println("主執行緒結束,JVM 即將關閉");
心跳… 心跳… 心跳… 主執行緒結束,JVM 即將關閉 (註:心跳次數視時間而定,主執行緒一結束心跳就停)
中斷機制
Java 沒有「強制終止」執行緒的安全方法(|stop| 已被棄用)。取而代之的是 「中斷」這套協作式機制:一個執行緒對另一個呼叫 |interrupt|, 只是「設定一個中斷旗標」,被中斷者要自己檢查旗標並決定如何回應。
/** * 中斷是「請求」而非「命令」:被中斷者自行檢查旗標並優雅收尾。 */ public class InterruptDemo
public static void main(String[] args) throws InterruptedException
Thread worker = new Thread(() -> // 用中斷旗標作為迴圈的結束條件 while (!Thread.currentThread().isInterrupted()) System.out.println("工作中…"); try Thread.sleep(50); catch (InterruptedException e) // sleep 被中斷會清除旗標,這裡要「恢復」旗標 Thread.currentThread().interrupt(); System.out.println("收到中斷,準備收尾"); System.out.println("已優雅結束"); );
worker.start(); Thread.sleep(160); worker.interrupt(); // 送出中斷請求 worker.join();
工作中… 工作中… 工作中… 收到中斷,準備收尾 已優雅結束
最常見的中斷處理錯誤是「吞掉」|InterruptedException|——只寫一個空的 |catch| 區塊,或僅印出堆疊而不做任何事。這會讓中斷訊號憑空消失,導致 上層無法得知該停止。正確做法只有兩種:往外拋出讓呼叫端處理,或 呼叫 |Thread.currentThread().interrupt()| 恢復中斷旗標,讓後續 程式碼仍能偵測到中斷。
競態條件
當多個執行緒同時讀寫同一份資料,且最終結果取決於執行的先後順序時,就發生了 「競態條件」(race condition)。最經典的例子是兩個執行緒同時對計數器累加:
/** * 兩個執行緒各對 count 累加十萬次,預期二十萬。 * 但 count++ 並非原子操作,結果幾乎必定小於二十萬。 */ public class RaceCondition
static int count = 0; // 共享的可變狀態
public static void main(String[] args) throws InterruptedException
Runnable task = () -> for (int i = 0; i < 100_000; i++) count++; // 讀取、加一、寫回:三個步驟 ;
Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("預期:200000,實際:" + count);
預期:200000,實際:147623 (註:實際值每次執行都不同,但幾乎不可能剛好等於 200000)
問題出在 |count++| 看似一行,實際是「讀取目前值、加一、寫回」三個步驟。 若執行緒 A 讀到 100 的同時執行緒 B 也讀到 100,兩者各自加一寫回 101,本應是 102 卻只增加了一次——這就是「遺失更新」。這種「先讀、再改、後寫」的操作稱為 read-modify-write,是並行問題的溫床。
synchronized:互斥同步
解決競態條件的根本手段是「同步」:確保同一時刻只有一個執行緒能進入臨界區。 |synchronized| 關鍵字提供了這種互斥能力,它可以修飾方法或程式碼區塊。
/** * 用 synchronized 保護 count++,讓它成為不可分割的原子操作。 */ public class SyncCounter
static int count = 0; static final Object lock = new Object(); // 專用的鎖物件
public static void main(String[] args) throws InterruptedException
Runnable task = () -> for (int i = 0; i < 100_000; i++) // 同步區塊:同一時刻只有一個執行緒能進入 synchronized (lock) count++; // 現在是原子操作了 ;
Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("預期:200000,實際:" + count);
預期:200000,實際:200000
關於 |synchronized| 有幾個要點:修飾實體方法時鎖的是 |this|;修飾靜態方法時鎖的是該類別的 |Class| 物件;用 同步區塊時可以指定任意物件當鎖。每個 Java 物件內部都有一個「監視器 鎖」(monitor),這也是為什麼任何物件都能拿來當鎖。
臨界區要盡量小。把 |synchronized| 的範圍縮到「真正需要互斥」的那幾行, 而非鎖住整個方法,可以大幅減少執行緒互相等待的時間,提升並行效能。這也是 偏好「同步區塊」而非「同步方法」的主要理由。
volatile:可見性
除了原子性,並行還有另一個隱形陷阱:可見性。每個執行緒可能把變數快取 在自己的暫存器或 CPU 快取中,導致一個執行緒的修改,另一個執行緒遲遲看不到。 下面的旗標迴圈就可能永遠停不下來:
/** * 若 running 沒有 volatile,工作執行緒可能看不到主執行緒的修改, * 而陷入無法結束的迴圈。加上 volatile 保證可見性。 */ public class VolatileDemo
// 拿掉 volatile 這個程式可能永遠不會停止 static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException
Thread worker = new Thread(() -> long n = 0; while (running) // 讀取共享旗標 n++; System.out.println("已停止,累計:" + n); );
worker.start(); Thread.sleep(50); running = false; // 主執行緒改變旗標 System.out.println("已送出停止指令"); worker.join();
已送出停止指令 已停止,累計:183472910 (註:累計值每次不同;若拿掉 volatile,worker 可能永不停止)
|volatile| 只保證可見性與禁止指令重排,不保證 原子性。因此 |volatile int count; count++;| 依然有競態條件,因為 |count++| 仍是三步驟。下表釐清兩者分工:
| 保證 | |volatile| | |synchronized| |
|---|---|---|
| 可見性 | 是 | 是 |
| 原子性 | 否 | 是 |
| 互斥 | 否 | 是 |
一句話總結選用原則:只需要「一個執行緒寫、其他執行緒讀」的旗標,用 |volatile|;只要牽涉「先讀再改後寫」的複合操作,就得用 |synchronized| (或下一章的原子類別)。
wait/notify 與生產者消費者
有時執行緒需要「等待某個條件成立」再繼續,例如消費者要等生產者放入資料。 |wait|/|notify|/|notifyAll| 提供這種協調能力,但使用前提是 必須先持有該物件的鎖(即在 |synchronized| 區塊內呼叫)。
import java.util.LinkedList; import java.util.Queue;
/** * 生產者消費者:以固定容量的緩衝區協調兩端。 * wait 會釋放鎖並等待,notify/notifyAll 喚醒等待者。 */ public class ProducerConsumer
static final Queue<Integer> buffer = new LinkedList<>(); static final int CAPACITY = 3; static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException
Thread producer = new Thread(() -> for (int i = 1; i <= 5; i++) synchronized (lock) // 用 while 而非 if 檢查條件,防止「虛假喚醒」 while (buffer.size() == CAPACITY) waitQuietly(); buffer.add(i); System.out.println("生產:" + i); lock.notifyAll(); // 喚醒可能在等的消費者 );
Thread consumer = new Thread(() -> for (int i = 1; i <= 5; i++) synchronized (lock) while (buffer.isEmpty()) waitQuietly(); System.out.println(" 消費:" + buffer.poll()); lock.notifyAll(); // 喚醒可能在等的生產者 );
producer.start(); consumer.start(); producer.join(); consumer.join();
/** 封裝 wait 與中斷處理,讓主邏輯乾淨 */ static void waitQuietly() try lock.wait(); // 釋放鎖並等待被喚醒 catch (InterruptedException e) Thread.currentThread().interrupt();
生產:1 生產:2 消費:1 生產:3 消費:2 (註:實際交錯順序每次不同,但生產與消費總數皆為 5)
為什麼檢查條件要用 |while| 而不是 |if|?因為存在「虛假喚醒」 (spurious wakeup):執行緒可能在沒有人 |notify| 的情況下被喚醒。用 |while| 可以在被喚醒後重新檢查條件是否真的成立,若否就繼續等待。 這是撰寫 |wait|/|notify| 程式的鐵律。
死結
死結(deadlock)是指兩個以上的執行緒互相持有對方需要的鎖,彼此無限等待。下面 是一個穩定可重現的死結:|t1| 先鎖 A 再求 B,|t2| 先鎖 B 再求 A。
/** * 兩個執行緒以相反順序取得兩把鎖,造成死結。 * 此程式會卡住不會結束(需手動終止)。 */ public class DeadlockDemo
static final Object lockA = new Object(); static final Object lockB = new Object();
public static void main(String[] args)
Thread t1 = new Thread(() -> synchronized (lockA) // 先拿 A System.out.println("t1 取得 A,欲取 B"); sleep(50); synchronized (lockB) // 再求 B(被 t2 佔用) System.out.println("t1 取得 B"); );
Thread t2 = new Thread(() -> synchronized (lockB) // 先拿 B System.out.println("t2 取得 B,欲取 A"); sleep(50); synchronized (lockA) // 再求 A(被 t1 佔用) System.out.println("t2 取得 A"); );
t1.start(); t2.start();
static void sleep(long ms) try Thread.sleep(ms); catch (InterruptedException e) Thread.currentThread().interrupt();
t1 取得 A,欲取 B t2 取得 B,欲取 A (程式卡死,兩執行緒互相等待對方的鎖,永不結束)
死結需同時滿足四個條件(Coffman 條件):互斥(資源不可共享)、 持有並等待(持有資源又等待其他資源)、不可搶奪(不能強制 奪取他人的資源)、循環等待(形成等待環路)。只要打破任一條件即可預防。 最實用的策略是打破「循環等待」:規定所有執行緒都以固定的順序取得多把 鎖(例如永遠先 A 後 B)。下一章的 |tryLock| 帶逾時,則是打破「持有並 等待」的另一利器。
執行緒安全等級盤點
最後盤點幾個常見類別的執行緒安全性,這些是實務踩坑的高發區:
不可變物件天生安全:|String|、|Integer| 等一旦 建立就不能改變,可放心在多執行緒間共享。
|StringBuilder| 與 |StringBuffer|:前者不同步、 效能好,適合單執行緒;後者方法皆 |synchronized|、執行緒安全但 較慢。單執行緒一律用 |StringBuilder|。
|SimpleDateFormat| 的坑:它不是執行緒安全的, 多執行緒共用同一個實體會解析出錯亂的結果。解法是每次 new、用 |ThreadLocal|,或改用 Java 8 的 |DateTimeFormatter| (不可變且執行緒安全)。
把 |SimpleDateFormat| 宣告成 |static| 共用是經典地雷。它內部持有 可變狀態,多執行緒同時 |format| 或 |parse| 會產生錯誤日期甚至 拋出例外,而且問題偶發、極難重現。現代程式應改用 |java.time.format.DateTimeFormatter|。
本章小結
程序有獨立記憶體,執行緒共享程序記憶體;共享是高效協作的來源,也是 並行 bug 的根源。
建立執行緒有繼承 |Thread|、實作 |Runnable|、Lambda 三種 方式,建議用 |Runnable| 以分離關注點;啟動要用 |start|。
執行緒有 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED 六種狀態。
中斷是協作式機制;切勿吞掉 |InterruptedException|,應往外拋或 恢復中斷旗標。
競態條件源於 read-modify-write 非原子;|synchronized| 提供互斥 與原子性。
|volatile| 保證可見性但不保證原子性;旗標用 |volatile|, 複合操作用 |synchronized|。
|wait|/|notify| 須在持有鎖時呼叫,條件檢查要用 |while| 防虛假喚醒。
死結需滿足四條件;固定加鎖順序是最實用的預防策略。
|SimpleDateFormat| 非執行緒安全,應改用 |DateTimeFormatter|。
本章練習
觀念題:為什麼呼叫 |run()| 與 |start()| 的結果不同? 直接呼叫 |run()| 會發生什麼事? (提示:想想是哪個執行緒在執行那段程式碼。)
觀念題:|volatile| 與 |synchronized| 各自保證什麼?為什麼 |volatile int count; count++;| 仍有競態條件? (提示:拆解 |count++| 的三個步驟。)
實作題:用兩個執行緒交替印出 1 到 10(一個印奇數、一個印偶數,且必須 按 1、2、3… 的順序)。 (提示:用 |wait|/|notify| 與一個共享的「輪到誰」旗標。)
實作題:重現本章的競態條件範例,分別用 |synchronized| 方法與 |synchronized| 區塊修正,並比較兩者臨界區大小的差異。 (提示:區塊可只鎖住 |count++| 那一行。)
實作題:把死結範例改寫成不會死結的版本。 (提示:讓兩個執行緒都以相同順序取得 |lockA|、|lockB|。)
實作題:寫一個守護執行緒,每 200 毫秒印出目前記憶體使用量,主執行緒 工作 1 秒後結束,觀察守護執行緒是否隨之停止。 (提示:|setDaemon(true)| 必須在 |start| 之前。)