學完本章你將能夠:

  • 用 |ExecutorService| 管理執行緒池,並正確關閉它。

  • 解讀 |ThreadPoolExecutor| 的七個參數,手動配置合適的池。

  • 用 |Future| 與 |CompletableFuture| 組合非同步工作。

  • 用原子類別與顯式鎖取代 |synchronized|,並理解 CAS 原理。

  • 選用適當的並行集合與同步輔助工具。

  • 說明虛擬執行緒的優勢與適用場景。

上一章我們用 |new Thread()| 手動建立執行緒。這在教學上很直觀,但在正式 系統中幾乎是禁忌。本章介紹 |java.util.concurrent|(簡稱 JUC)套件——由 並行大師 Doug Lea 設計的一整套高階工具,讓我們站在巨人的肩膀上寫並行程式。

本章的主線是「從低階原語走向高階抽象」:用執行緒池取代裸執行緒,用原子類別與 顯式鎖取代 |synchronized|,用並行集合取代手動同步,最後用 |CompletableFuture| 與虛擬執行緒優雅地表達非同步與高並行。

手動建立執行緒的問題

為每個任務 |new Thread()| 有三個致命問題。第一,建立成本高: 每個平台執行緒都對應一個作業系統執行緒,建立與銷毀都要進出核心,還要配置約 1 MB 的堆疊。第二,無上限:來一個請求就開一個執行緒,流量一大就可能 開出成千上萬個執行緒,直接把記憶體與 CPU 壓垮。第三,無法管理:沒有 統一的地方控制數量、監控狀態或優雅關閉。

執行緒池(thread pool)一次解決這三個問題:預先建立一批可重複使用的執行緒, 任務排隊等候,用固定數量的執行緒消化。這就是 Executor 框架的核心思想。

Executor 框架

|ExecutorService| 是執行緒池的核心介面。我們透過 |Executors| 工廠 類別取得預先配置好的實作。常見的三種工廠方法如下:

工廠方法 特性與適用場景
|newFixedThreadPool(n)| 固定 n 條執行緒,適合負載穩定的場景
|newCachedThreadPool()| 執行緒數依需求增減,適合大量短任務
|newSingleThreadExecutor()| 單一執行緒依序執行,保證任務順序

import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit;

/** * 用固定大小的執行緒池執行多個任務,並示範標準關閉流程。 */ public class ExecutorBasics

public static void main(String[] args) throws InterruptedException

// 建立固定 3 條執行緒的池 ExecutorService pool = Executors.newFixedThreadPool(3);

// 提交 6 個任務,只有 3 條執行緒輪流消化 for (int i = 1; i <= 6; i++) int taskId = i; // 供 Lambda 捕獲的實質不可變值 pool.submit(() -> String name = Thread.currentThread().getName(); System.out.println(name + " 執行任務 " + taskId); );

// 標準關閉流程(後述) pool.shutdown(); // 不再接受新任務 if (!pool.awaitTermination(5, TimeUnit.SECONDS)) pool.shutdownNow(); // 逾時則強制中斷 System.out.println("所有任務完成,池已關閉");

pool-1-thread-1 執行任務 1 pool-1-thread-2 執行任務 2 pool-1-thread-3 執行任務 3 pool-1-thread-1 執行任務 4 pool-1-thread-2 執行任務 5 pool-1-thread-3 執行任務 6 所有任務完成,池已關閉 (註:任務與執行緒的對應順序每次可能不同)

關閉執行緒池有固定模板:先 |shutdown()| 停止接收新任務並等既有任務跑完, 再用 |awaitTermination| 設定等待上限,若逾時未完成才用 |shutdownNow| 中斷剩餘任務。忘記關閉執行緒池,程式可能永遠不會結束,因為池中的 非守護執行緒會一直存活。

ThreadPoolExecutor 七參數

|Executors| 的工廠方法背後都是 |ThreadPoolExecutor|。理解它的七個 建構參數,才能真正掌握執行緒池的行為:

參數 意義
corePoolSize 核心執行緒數,即使閒置也保留
maximumPoolSize 最大執行緒數
keepAliveTime 超過核心數的執行緒閒置多久後回收
workQueue 任務等候佇列
threadFactory 建立執行緒的工廠(可命名、設守護)
handler 佇列與執行緒都滿時的拒絕策略
unit keepAliveTime 的時間單位

《阿里巴巴 Java 開發手冊》明訂:不要用 |Executors| 的預設工廠 方法建立執行緒池。原因是 |newFixedThreadPool| 與 |newSingleThreadExecutor| 使用無界佇列,任務堆積時可能耗盡記憶體; |newCachedThreadPool| 則可能建立近乎無限的執行緒。正式系統應直接 用 |new ThreadPoolExecutor(...)|,明確指定有界佇列與拒絕策略,把資源 上限握在自己手裡。

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit;

/** * 手動建立執行緒池:有界佇列 + 明確的拒絕策略。 * CallerRunsPolicy 讓提交任務的執行緒自己跑,達到「減速」效果。 */ public class CustomPool

public static void main(String[] args)

ThreadPoolExecutor pool = new ThreadPoolExecutor( 2, // 核心數 4, // 最大數 30, TimeUnit.SECONDS, // 閒置回收時間 new ArrayBlockingQueue<>(10), // 有界佇列 new ThreadPoolExecutor.CallerRunsPolicy()); // 拒絕策略

for (int i = 1; i <= 5; i++) int id = i; pool.execute(() -> System.out.println( Thread.currentThread().getName() + " 處理 " + id)); pool.shutdown();

pool-1-thread-1 處理 1 pool-1-thread-2 處理 2 pool-1-thread-1 處理 3 pool-1-thread-1 處理 4 pool-1-thread-2 處理 5 (註:實際排程順序可能不同)

Callable 與 Future

|Runnable| 無法回傳結果也不能拋出受檢例外。若任務需要回傳值,改用 |Callable<V>|,它的 |call| 方法回傳 |V| 並可拋例外。提交 |Callable| 會得到一個 |Future<V>|,代表「未來才會有的結果」。

import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future;

/** * 提交 Callable 取得 Future,get() 會阻塞直到結果就緒。 */ public class FutureDemo

public static void main(String[] args) throws InterruptedException, ExecutionException

ExecutorService pool = Executors.newSingleThreadExecutor();

// 提交一個會回傳整數的任務 Future<Integer> future = pool.submit(() -> Thread.sleep(100); // 模擬耗時運算 return 6 * 7; );

System.out.println("任務已提交,主執行緒可先做別的事"); // get() 阻塞等待結果;亦可用 get(timeout, unit) 設定逾時 Integer result = future.get(); System.out.println("運算結果:" + result);

pool.shutdown();

任務已提交,主執行緒可先做別的事 運算結果:42

|Future| 的能力有限:|get| 只能阻塞式等待,也無法把多個 |Future| 優雅地串接或合併。這些缺點正是 |CompletableFuture| 要 解決的。

CompletableFuture 非同步管線

|CompletableFuture| 讓我們用宣告式的方式編排非同步流程:用 |supplyAsync| 啟動有回傳值的任務、|runAsync| 啟動無回傳的任務;用 |thenApply| 轉換結果、|thenAccept| 消費結果、|thenCompose| 串接另一個非同步任務、|thenCombine| 合併兩個任務的結果。

import java.util.concurrent.CompletableFuture;

/** * 示範 thenApply(轉換)、thenCompose(串接依賴)、 * thenCombine(合併兩個獨立任務)。 */ public class CompletableFutureBasics

public static void main(String[] args) throws Exception

// supplyAsync 在預設池啟動任務,thenApply 對結果做轉換 CompletableFuture<Integer> f1 = CompletableFuture .supplyAsync(() -> 10) .thenApply(n -> n * 2); // 10 → 20

// thenCompose:前一步結果決定下一個非同步任務(扁平化) CompletableFuture<Integer> f2 = f1.thenCompose( n -> CompletableFuture.supplyAsync(() -> n + 5)); // 25

// thenCombine:合併兩個獨立任務的結果 CompletableFuture<Integer> other = CompletableFuture.supplyAsync(() -> 100); CompletableFuture<Integer> combined = f2.thenCombine(other, (a, b) -> a + b); // 125

System.out.println("最終結果:" + combined.get());

最終結果:125

例外處理與自訂執行緒池

非同步流程的例外不能用傳統 |try-catch| 攔截,要用 |exceptionally| (只在出錯時提供替代值)或 |handle|(無論成敗都處理)。此外,正式系統 應傳入自訂的執行緒池,避免佔用所有 |CompletableFuture| 共用的 預設 |ForkJoinPool.commonPool()|。

import java.util.concurrent.CompletableFuture;

/** * exceptionally 在上游拋例外時提供替代值,讓管線不中斷。 */ public class CompletableFutureError

public static void main(String[] args) throws Exception

CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> if (true) throw new IllegalStateException("模擬失敗"); return 1; ) .exceptionally(ex -> // 攔截例外並回退 System.out.println("捕獲:" + ex.getMessage()); return -1; // 提供預設值 );

System.out.println("結果:" + future.get());

捕獲:java.lang.IllegalStateException: 模擬失敗 結果:-1

非同步管線實戰

|CompletableFuture| 的真正價值在於「並行取得多個資料來源再合併」。假設 要同時查詢使用者基本資料與訂單資料,兩者各需 200 毫秒。若序列執行要 400 毫秒, 並行執行只需約 200 毫秒。

import java.util.concurrent.CompletableFuture;

/** * 並行查詢兩個資料來源再合併,總耗時接近「較慢的那個」而非兩者之和。 */ public class AsyncPipeline

/** 模擬耗時查詢,睡眠指定毫秒後回傳結果 */ static String query(String name, long ms) try Thread.sleep(ms); catch (InterruptedException e) Thread.currentThread().interrupt(); return name + "資料";

public static void main(String[] args) throws Exception

long start = System.currentTimeMillis();

// 兩個查詢同時啟動 CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> query("使用者", 200)); CompletableFuture<String> order = CompletableFuture.supplyAsync(() -> query("訂單", 200));

// 合併兩者結果 String merged = user.thenCombine(order, (u, o) -> u + " + " + o).get();

long cost = System.currentTimeMillis() - start; System.out.println("合併結果:" + merged); System.out.println("總耗時約:" + cost + " 毫秒(並行,非 400)");

合併結果:使用者資料 + 訂單資料 總耗時約:210 毫秒(並行,非 400)

原子類別

上一章的計數器競態,除了 |synchronized|,更輕量的解法是原子類別。 |AtomicInteger| 的 |incrementAndGet| 是硬體層級的原子操作,無需 加鎖就能保證正確:

import java.util.concurrent.atomic.AtomicInteger;

/** * 用 AtomicInteger 解決計數器競態,無需 synchronized。 */ public class AtomicCounter

static final AtomicInteger count = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException

Runnable task = () -> for (int i = 0; i < 100_000; i++) count.incrementAndGet(); // 原子的「加一並取值」 ;

Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join();

System.out.println("預期:200000,實際:" + count.get());

預期:200000,實際:200000

原子類別背後是 CAS(Compare-And-Swap,比較並交換):更新時同時提供「舊值」與 「新值」,硬體確保「只有當目前值等於舊值時,才寫入新值」,整個過程不可分割。 CAS 是「樂觀鎖」,失敗就重試,不會像 |synchronized| 那樣阻塞執行緒。 但它有著名的 ABA 問題:值從 A 變 B 再變回 A,CAS 會誤以為沒變過。若在意 過程,可用 |AtomicStampedReference| 加上版本戳記。極高並行的計數場景, |LongAdder| 比 |AtomicLong| 更快,因為它把熱點分散到多個 單元(cell)分別累加,最後才求和,大幅降低 CAS 競爭。

顯式鎖

|ReentrantLock| 是比 |synchronized| 更靈活的顯式鎖:它支援可中斷 的取鎖、帶逾時的 |tryLock|、以及公平鎖。代價是必須手動解鎖, 且 |unlock| 一定要放在 |finally| 中,否則例外發生時鎖將永遠不釋放。

import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock;

/** * tryLock 帶逾時:取不到鎖就放棄,從根本上避免無限等待造成的死結。 */ public class TryLockDemo

static final ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) throws InterruptedException

Runnable task = () -> try // 最多等 500 毫秒,取不到就放棄 if (lock.tryLock(500, TimeUnit.MILLISECONDS)) try System.out.println( Thread.currentThread().getName() + " 取得鎖"); Thread.sleep(300); // 持有鎖一段時間 finally lock.unlock(); // 解鎖務必放 finally else System.out.println( Thread.currentThread().getName() + " 放棄取鎖"); catch (InterruptedException e) Thread.currentThread().interrupt(); ;

new Thread(task, "A").start(); new Thread(task, "B").start();

A 取得鎖 B 放棄取鎖 (註:誰先取得鎖不固定;後者因逾時而放棄)

比較 |synchronized| |ReentrantLock|
解鎖方式 自動 手動(須 |finally|)
可中斷取鎖 否 是
帶逾時取鎖 否 是(|tryLock|)
公平性選擇 否 可設公平鎖

當讀操作遠多於寫操作時,|ReentrantReadWriteLock| 允許多個讀執行緒同時 持有讀鎖,只有寫時才互斥,能大幅提升並行度。

並行集合

|HashMap| 非執行緒安全,而老舊的 |Hashtable| 與 |Collections.synchronizedMap| 對每個方法整包加鎖、效能差。首選是 |ConcurrentHashMap|,它用更細緻的鎖與 CAS,並提供 |computeIfAbsent|、 |merge| 等原子複合操作:

import java.util.concurrent.ConcurrentHashMap;

/** * 用 ConcurrentHashMap 的 merge 做執行緒安全的計數。 * merge 把「取值、相加、寫回」合併為一個原子操作。 */ public class ConcurrentMapDemo

public static void main(String[] args) throws InterruptedException

ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();

Runnable task = () -> for (int i = 0; i < 1000; i++) // 若不存在則設為 1,否則把舊值加 1(原子) counter.merge("hits", 1, Integer::sum); ;

Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join();

System.out.println("hits(預期 2000):" + counter.get("hits"));

hits(預期 2000):2000

另外,|CopyOnWriteArrayList| 適合「讀多寫極少」的場景(如監聽器清單), 每次寫入都複製整個陣列,因此讀取完全無鎖。而 |BlockingQueue| 是實作生產者 消費者的利器——用它重寫上一章的範例,程式碼大幅精簡,因為 |put| 在滿時 自動阻塞、|take| 在空時自動阻塞,不必再手寫 |wait|/|notify|:

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue;

/** * 用 BlockingQueue 重寫生產者消費者:阻塞語意內建,無需手動同步。 */ public class BlockingQueueDemo

public static void main(String[] args) throws InterruptedException

BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(3);

Thread producer = new Thread(() -> try for (int i = 1; i <= 5; i++) queue.put(i); // 佇列滿則自動阻塞 System.out.println("生產:" + i); catch (InterruptedException e) Thread.currentThread().interrupt(); );

Thread consumer = new Thread(() -> try for (int i = 1; i <= 5; i++) Integer v = queue.take(); // 佇列空則自動阻塞 System.out.println(" 消費:" + v); catch (InterruptedException e) Thread.currentThread().interrupt(); );

producer.start(); consumer.start(); producer.join(); consumer.join();

生產:1 生產:2 消費:1 生產:3 消費:2 (註:交錯順序每次不同,總量皆為 5)

同步輔助工具

JUC 還提供幾個常用的協調工具。|CountDownLatch| 讓一個執行緒等待多個工作 全部完成(倒數計數歸零才放行);|Semaphore| 是「號誌」,限制同時存取資源 的執行緒數量(限流);|CyclicBarrier| 讓一組執行緒互相等待,全部到達 「柵欄」才一起繼續。

import java.util.concurrent.CountDownLatch;

/** * 主執行緒用 CountDownLatch 等待 3 個子任務全部完成後才彙總。 */ public class CountDownLatchDemo

public static void main(String[] args) throws InterruptedException

int workers = 3; CountDownLatch latch = new CountDownLatch(workers);

for (int i = 1; i <= workers; i++) int id = i; new Thread(() -> try Thread.sleep(id * 50L); // 模擬不同耗時 System.out.println("子任務 " + id + " 完成"); catch (InterruptedException e) Thread.currentThread().interrupt(); finally latch.countDown(); // 完成即計數減一 ).start();

latch.await(); // 阻塞直到歸零 System.out.println("所有子任務完成,開始彙總");

子任務 1 完成 子任務 2 完成 子任務 3 完成 所有子任務完成,開始彙總

虛擬執行緒(Java 21)

Java 21 正式推出的虛擬執行緒(virtual thread)是並行程式設計的重大 變革。傳統的平台執行緒(platform thread)一對一對應作業系統執行緒,昂貴而 有限;虛擬執行緒則由 JVM 管理,極其輕量,可以輕鬆建立數百萬個。當虛擬執行緒 遇到阻塞的 I/O,JVM 會把它從底層的載體執行緒上「卸載」,讓載體去跑別的虛擬 執行緒,因此少數幾個作業系統執行緒就能支撐海量並行。

import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit;

/** * 用虛擬執行緒池執行大量 I/O 密集任務。 * 每個任務一條虛擬執行緒,不必也不該池化平台執行緒。 */ public class VirtualThreadDemo public static void main(String[] args) throws InterruptedException // 每個任務配一條虛擬執行緒的 ExecutorService try (ExecutorService vpool = Executors.newVirtualThreadPerTaskExecutor()) for (int i = 1; i <= 5; i++) int id = i; vpool.submit(() -> try Thread.sleep(100); // 模擬 I/O 等待 catch (InterruptedException e) Thread.currentThread().interrupt(); System.out.println("虛擬執行緒完成任務 " + id); ); // try-with-resources 自動等待所有任務並關閉 System.out.println("全部完成");

虛擬執行緒完成任務 3 虛擬執行緒完成任務 1 虛擬執行緒完成任務 5 虛擬執行緒完成任務 2 虛擬執行緒完成任務 4 全部完成 (註:完成順序不固定)

虛擬執行緒適合I/O 密集的工作(大量網路呼叫、資料庫查詢),因為它們 大部分時間在等待,卸載機制能讓少量載體執行緒服務海量任務。但它不適合 CPU 密集的工作——純運算不會阻塞、無從卸載,此時該用與核心數相當的平台執行緒 池。還有一個觀念要更新:虛擬執行緒不需要池化。池化是為了重用昂貴的 平台執行緒,而虛擬執行緒建立成本極低,「每個任務開一條、用完即丟」才是正確 用法,故用 |newVirtualThreadPerTaskExecutor|。

本章小結

  • 手動 |new Thread()| 成本高、無上限、難管理;執行緒池預建並重用 執行緒來解決。

  • |ExecutorService| 用 |shutdown| + |awaitTermination| + |shutdownNow| 標準流程關閉。

  • 正式系統應手動 |new ThreadPoolExecutor|,用有界佇列與明確的 拒絕策略,勿依賴 |Executors| 預設工廠。

  • |Callable|+|Future| 可回傳結果;|CompletableFuture| 用 |thenApply|/|thenCompose|/|thenCombine| 編排非同步 管線,用 |exceptionally|/|handle| 處理例外。

  • 原子類別以 CAS 免鎖更新;注意 ABA 問題,高並行計數用 |LongAdder|。

  • |ReentrantLock| 支援可中斷、帶逾時、公平鎖,|unlock| 必須 放 |finally|;讀多寫少用 |ReadWriteLock|。

  • |ConcurrentHashMap| 的 |merge|/|computeIfAbsent| 是 原子複合操作;|BlockingQueue| 大幅簡化生產者消費者。

  • 虛擬執行緒輕量、適合 I/O 密集、不需池化;CPU 密集仍用平台執行緒池。

本章練習

  1. 觀念題:為什麼《阿里巴巴 Java 開發手冊》不建議用 |Executors| 的 預設工廠方法?可能造成什麼後果? (提示:想想無界佇列與無限執行緒。)

  2. 觀念題:|thenApply| 與 |thenCompose| 差在哪裡?什麼時候 必須用 |thenCompose|? (提示:回傳的是「值」還是「另一個 |CompletableFuture|」。)

  3. 實作題:用 |CompletableFuture| 並行呼叫三個「查詢」方法(各睡 不同時間),全部完成後印出合併結果,並量測總耗時。 (提示:|CompletableFuture.allOf| 搭配各自的 |join|。)

  4. 實作題:用 |AtomicInteger| 與 |synchronized| 兩種方式各 實作一次計數器,並比較高並行下的效能差異。 (提示:用 |System.nanoTime| 量測。)

  5. 實作題:用 |Semaphore| 限制同時只有 2 個執行緒能存取某資源, 觀察第 3 個執行緒如何等待。 (提示:|acquire| 與 |release| 成對出現。)

  6. 實作題:用虛擬執行緒同時發出 100 個「模擬 I/O」任務(各睡 100 毫秒), 觀察總耗時遠小於序列執行的 10 秒。 (提示:|newVirtualThreadPerTaskExecutor|。)