更新紀錄(1 筆,最後更新 2026年09月13日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
- 移除了殘留在文章原始檔末尾的一行多餘結尾標籤(</content>)。渲染器原本就會忽略它,頁面上沒有任何變化。
- 初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22175952)
以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。
Go Komura(2026)。〈多執行緒實務最佳實踐 Java 篇 ── 虛擬執行緒時代的定石〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/multithreading-best-practices-java/
- DOI(已登錄存檔)
- 10.5281/zenodo.22175952
- DOI(上次登錄版本)
- 10.5281/zenodo.22175953
「想用 Java 把業務系統的批次作業平行化」「Spring 寫的 Web 應用程式,共用快取偶爾會壞掉」「接手了一個滿是 new Thread 的老舊 Swing 應用程式」。同樣是多執行緒的諮詢,選擇執行手段的問題、保護共享資料的問題,以及 UI 與停止處理的問題,必須分開來思考。
Java 從 JDK 1.0 起就把多執行緒內建於語言之中,並在 java.util.concurrent 裡備齊了成熟的工具。JDK 21 也讓虛擬執行緒正式化。正因為工具豐富,依「要讓什麼並行執行」「要共享什麼」「要如何停止」的順序去選擇,才是設計的重點。1
本文是多執行緒實務系列的 Java 篇。以撰寫業務系統、批次作業、伺服器應用程式的開發者為對象,以 LTS 的 JDK 21 以後版本為主要對象,整理依據截至 2026 年 8 月一手資料的原則與注意事項。只讀本篇也能讀懂。把同樣的原則用其他語言展開的,還有「.NET 篇」「C++ 篇」「C 語言篇」。
1. 先講結論 ── 執行、共享、停止要分開設計
即使選用了虛擬執行緒,共享資料的競爭與停止處理也不會自動獲得解決。在業務程式碼中不直接建立執行緒,把任務的執行交給 ExecutorService,然後依下列順序設計。2
| 要決定的事 | 基本方針 | 詳讀的章節 |
|---|---|---|
| 用什麼執行 | I/O 等待用每個任務一支的虛擬執行緒,CPU 計算用核心數量級的平台執行緒 | 第 2 章 |
| 要接受到什麼程度 | 對外部服務的同時數用 Semaphore,會積壓的工作要設容量或投入的上限 |
第 2、3 章 |
| 要共享什麼 | 拆成部分結果,使用不可變資料、並行集合與佇列 | 第 3 章 |
| 要如何保護狀態 | 單一值的原子式更新用 Atomic 系列,複合狀態用專用鎖。不要指望 volatile 提供原子性 |
第 4 章 |
| 要如何停止 | 把以中斷實現的協調式停止,與附期限的完成確認組合起來 | 第 5、6 章 |
| UI 與調查如何處理 | UI 交給專用執行緒。傾印要依平台執行緒與虛擬執行緒分別選用 | 第 7、8 章 |
若要從頭讀起,就照這張表的順序推進。如果現在正在追查「停不下來」,可以從第 5、6 章讀起;正在追查「卡住了」,則可以從第 8 章讀起。JDK 21~23 與 24 以後不同的釘選注意事項整理在 2.4 節,預覽功能與正式功能的區分則整理在第 9 章。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 28 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 選擇執行手段 ── 把 I/O 等待與 CPU 計算分開
2.1. 把工作交給 ExecutorService
在 Java 中同樣,基本原則是不要把 new Thread 散落在業務程式碼裡。把以 Runnable / Callable 表示的「工作」,和執行緒數量、佇列這類「執行方式」分開,執行緒的建立、重複使用與銷毀都交給 ExecutorService。2
flowchart TB
S["有想要並行執行的任務"] --> Q1{"任務以什麼為主?"}
Q1 -->|"以 I/O 等待為主<br/>HTTP 呼叫、資料庫、檔案"| VT["虛擬執行緒<br/>Executors.newVirtualThreadPerTaskExecutor()<br/>一個任務一支。不放進池裡"]
Q1 -->|"會用到 CPU 的計算"| PT["平台執行緒的固定池<br/>Executors.newFixedThreadPool(核心數量級)<br/>或 parallel stream"]
VT --> LIMIT["對外部服務的同時數限制<br/>不用池,而用 Semaphore"]
圖1:I/O 等待選虛擬執行緒,CPU 計算選傳統型的池。同時存取數的限制要另行設計。
虛擬執行緒不是「更快的執行緒」。它不是用來提升計算本身的執行速度,而是把等待時間長的工作大量並行處理、藉此提高吞吐量的工具。要把 CPU 用滿的計算,仍然照以往使用核心數量級的平台執行緒固定池或 parallel stream。3
2.2. 虛擬執行緒不放進池裡,想限量的資源用 Semaphore 保護
虛擬執行緒是與 OS 執行緒切離的輕量執行緒。在 JDK 有支援的 I/O、鎖、sleep 等阻塞操作期間,它可以放開 OS 執行緒,因此能在一個 JVM 中實現足以容納數百萬支的並行性。不過,並不是在所有阻塞下都能放開。釘選的條件在 2.4 節另外說明。3
用法是一個任務對應一支虛擬執行緒。把它當成廉價的用完即丟,不要設計成把虛擬執行緒放進 newFixedThreadPool 反覆使用。應使用 Executors.newVirtualThreadPerTaskExecutor()。13
另一方面,「對外部 API 最多同時連線 10 條」這類限制仍然必要。這個上限不是用虛擬執行緒的池大小表示,而是用 Semaphore 表示。不把虛擬執行緒放進池裡,和可以無限制存取外部服務,是兩回事。3
在虛擬執行緒中運行的是一般的同步程式碼。設計思想是不必改寫成 .NET 的 async/await 那種形式,而是讓「一個請求一支執行緒」的直白程式碼原樣大量運行。也可以寫成 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) 這種形式,但結束時的 close() 會等到什麼程度,請在 6.3 節確認。12
2.3. 平台執行緒的池,還要考慮等待佇列的上限
「不放進池裡」說的是虛擬執行緒。Java 從 JDK 5(2004 年)起就有成熟的執行緒池,現在仍依用途分別選用。
ThreadPoolExecutor 是可以細部設定執行緒數、佇列、拒絕策略等的通用池。ForkJoinPool 是工作竊取型,它的 commonPool() 被用作 parallel stream 與 CompletableFuture 的預設非同步執行去處。週期性執行則有 ScheduledThreadPoolExecutor。在 CPU 計算這類需要重複使用平台執行緒的場合,它們仍然是重要的工具。
不過,Executors.newFixedThreadPool 限制的是執行緒數量,等待佇列則是無上限的。在投入量持續超過處理量的常駐服務中,排隊等待的任務及其資料會不斷消耗記憶體。請為 ThreadPoolExecutor 設定有容量上限的佇列與拒絕策略,或是在投入端放上 Semaphore 之類的入場限制,讓系統能夠施加反壓。3.6 節的佇列也適用同樣的原則。
池是為了重複使用建立與維持成本都很高的 OS 執行緒而做的最佳化。虛擬執行緒夠輕量,開發者已經沒有理由再去重複使用它本身。這並不代表池變得沒有效率。
事實上,在虛擬執行緒的底層,JDK 的排程器也使用工作竊取的 ForkJoinPool,預設運作與可用處理器數量相當的承載執行緒(OS 執行緒)。用少量 OS 執行緒處理大量並行工作的格局是一樣的,只是這份管理改由 JVM 承擔。可以這樣理解:.NET 的非同步 I/O 不會在等待期間持續占用執行緒,Java 走的是同一個方向,只是保留了同步程式碼的形式。1
2.4. 釘選要依 JDK 版本與阻塞位置分開思考
虛擬執行緒無法離開承載執行緒、OS 執行緒也跟著一直等待的狀態,稱為釘選。頻繁、長時間的釘選,會損害虛擬執行緒在規模上的優勢。3
| 阻塞的位置 | JDK 21~23 | JDK 24 以後 |
|---|---|---|
synchronized 區塊或方法內 |
會發生釘選。對伴隨頻繁、長時間阻塞的位置,考慮改用 ReentrantLock |
JEP 491 已解除這項限制。只以因應釘選為由的機械式改寫並無必要 |
| 執行原生程式碼(JNI)或 foreign function 期間 | 有時無法放開承載執行緒 | 這與 synchronized 的改善無關,原生邊界上的釘選依然存在 |
JDK 24 的 JEP 491 解除的,是源自監視器實作的 synchronized 釘選。若大量放上經由 JNI 呼叫驅動程式或裝置 API、會長時間阻塞的處理,承載執行緒被耗盡的問題依然存在。請不要認為「既然是 JDK 24,在哪裡等待都可以」。也要檢查公司內部的指引是否還停留在 JDK 21 時期的注意事項上。43
3. 減少共享可變狀態 ── 在寫同步之前先重新檢視共享
3.1. 即使是 count++,處理一重疊加總就會遺失
競爭狀態(race condition)是指結果會隨多個執行緒到達程式碼的先後順序而改變的錯誤。count++ 看起來是一個運算式,實際上會分成讀取、加總、寫回。若中途有其他執行緒插進來,其中一方的加總就會遺失。
sequenceDiagram
participant A as 執行緒A
participant M as 共享變數 count
participant B as 執行緒B
Note over M: count = 10
A->>M: 讀取(10)
B->>M: 讀取(10)
A->>A: 在手邊加總(11)
B->>B: 在手邊加總(11)
A->>M: 寫回(11)
B->>M: 寫回(11)
Note over M: 明明加了兩次,count 卻是 11<br/>執行緒A 的加總遺失了
圖2:兩個執行緒讀取同一個值之後各自寫回,本該加了兩次,卻只留下一次的份。
另一個典型,是互相等待對方的鎖而無法前進的死結。這一點在 4.3 節討論。兩者都取決於時機,因此在開發機上罕見,到了核心數與負載都不同的正式環境卻可能頻繁發生。加上偵錯器或記錄之後就不再重現,也是因為觀測本身改變了時機。
正因如此,要從在正確地同步之前,先減少需要同步的地方開始。如果以多個執行緒運行本身就是需求,那麼設計上想減少的,就是被共享的可變資料。
3.2. Java 記憶體模型規定「何時能被其他執行緒看到」
在 Java 中,共享資料的可見方式是由 Java 記憶體模型(JMM)的 happens-before 關係所定義的。沒有同步的共享變數存取,雖然不會像 C++ 那樣變成未定義行為,但可能一直看到舊值,或看到寫入順序被對調。5
例如,只是在迴圈中檢查一個 boolean 旗標,可能會一直看不到其他執行緒改過的值。這不是 JVM 的錯誤,而是因為沒有做必要的同步而發生的記憶體一致性錯誤。
建立 happens-before 的,是 synchronized、volatile 以及 java.util.concurrent 中的各個類別。並行集合也在規格上保證了更新某個值與其後取得該值之間的關係。不要在裸的共享變數上想辦法,而要使用這些工具提供的保證。不過,值能被看到,和多個操作能以一個整體執行,是兩回事。與原子性的差異在 4.1 節整理。56
3.3. 彙總要拆成部分結果,最後再合流
在平行彙總時,與其讓所有執行緒都寫入同一個合計變數,不如先考慮讓每個執行緒各自產生部分結果、最後再合算的形式。parallel stream 的 reduce / collect 就以框架的形式提供了這種結構。
用於高頻率加總的 LongAdder,同樣採取把更新分散到內部儲存格、讀取時再合算的策略。先減少對共享資料的寫入,之後再選擇必要的同步。
3.4. 要做成不可變,就要檢查到元素內部
組態與主檔資料,應作為建構後不再改寫的不可變資料來共享。要替換時,定石是建立新物件,再把 volatile 的參照指過去。
不過,光是用了 record 或 List.copyOf,並不會讓整份資料變成不可變。record 的存取子會原樣回傳各構成元素的參照。即使用 List.copyOf / Map.copyOf 讓集合無法修改,也不會連元素物件都一併深層複製。
若元素是可變的,持有同一元素參照的其他程式碼就能改寫其內容,競爭依然存在。要作為不可變資料在不加同步的情況下共享,就要確認包含元素在內的整個物件圖都是不可變的。若含有可變元素,就用深層複製切斷共享,或是讓元素也改用 record 或不可變型別。請區分「看起來是唯讀」與「確實不可變」。
3.5. ConcurrentHashMap 要用它的複合操作與保證範圍
「若鍵不存在就建立並放入」,不要把檢查與新增分開寫,而要使用 ConcurrentHashMap.computeIfAbsent。整個方法呼叫會以原子方式執行,若鍵不存在,對應函式會在那一次呼叫中恰好被呼叫一次。這與競爭時工廠函式可能執行多次的 .NET ConcurrentDictionary.GetOrAdd 在保證上並不相同。6
頻率計數器的定石寫法如下。
// 頻率計數器的定石:computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
「在那一次呼叫中一次」和「在那個鍵的一生中一次」並不相同。若函式回傳 null 或拋出例外,就不會登錄,後續的呼叫會再次執行。刪除了已登錄的項目時也一樣。對於不允許副作用重複的初始化,要連同「回傳非 null 的值讓它成功」以及項目的處理方式一起設計。6
另外,作為以原子方式執行的代價,計算期間其他執行緒的部分更新會被阻塞。對應函式要保持簡短單純,且不可在其中更新這個 Map 本身。可被偵測到的遞迴式更新有可能變成 IllegalStateException。6
3.6. 執行緒之間的傳遞使用附容量的佇列
與其讓多個執行緒直接觸碰資料,不如改成透過 BlockingQueue 傳遞。若使用有指定容量的 ArrayBlockingQueue,佇列滿時 put 會阻塞,自然就對生產端形成反壓。
這與 .NET 篇的 bounded 通道是相同的結構。即使換成虛擬執行緒,明確劃分生產者與消費者邊界、以及積壓量上限的設計依然有效。
4. 保護剩下的共享狀態 ── volatile、Atomic 系列與鎖分別選用
4.1. 可見性與原子性是不同的保證
volatile 是用於可見性與順序,也就是用於建立 happens-before 的工具。它並不保證把「讀取、計算、寫回」合為一體,所以即使多個執行緒對 volatile int 執行 ++,也無法防止圖2 的加總遺失。5
| 想保護的東西 | 選用的工具 | 注意事項 |
|---|---|---|
| 單純狀態的通知、指向不可變資料的參照替換 | volatile |
得不到複合操作的原子性 |
| 單一值的原子式更新 | AtomicInteger / AtomicLong / AtomicReference |
高頻率加總的統計值也可以用 LongAdder |
| 跨多個值的一致性 | 鎖 | 在所有觸碰同一份資料的地方遵守同樣的紀律 |
與 .NET 篇、C++ 篇相同,把原子式更新交給 Atomic 系列,把複合狀態交給鎖。重點是不要只靠 volatile 就想達成執行緒安全。
4.2. 鎖不對應程式碼區段,而要對應它所保護的資料
為每一組想保護的可變資料,各對應一個專用的鎖物件。然後,在所有觸碰該資料的地方都取用同一個鎖。審查時要能確認「這份資料由哪個鎖保護」。
要避免 synchronized(this)、synchronized(SomeClass.class) 以及對公開物件上鎖。因為外部程式碼也能鎖住同一個物件,會發生非預期的衝突。應使用不對外公開的 private final Object lock = new Object();,或是專用的 ReentrantLock。
4.3. 避免在持有鎖時執行外部處理,並固定取得順序
持有鎖的同時執行 I/O、呼叫監聽器或執行未知的程式碼,會拉長持有時間。若被呼叫的一方又取得另一個鎖,還可能形成循環等待。
flowchart LR
A["執行緒A<br/>持有鎖1中"] -->|"等待鎖2 釋放"| B["執行緒B<br/>持有鎖2中"]
B -->|"等待鎖1 釋放"| A
圖3:一方持有鎖1 並等待鎖2,另一方以相反的順序等待,於是雙方都無法往前推進。
在需要取得多個鎖的位置,要讓所有執行緒的取得順序一致。對於無法保證順序的位置,則用 tryLock(timeout) 準備一條「取不到就放開手上的鎖,重新來過」的路徑。「持有鎖時不做耗時處理與外部處理」和「統一取得順序」這兩點要成套遵守。
4.4. 簡短的互斥用 synchronized,需要額外功能就用 ReentrantLock
若互斥既簡短又單純,synchronized 就足夠了。當需要以 tryLock(timeout) 進行附期限的取得、需要公平性策略、需要多個 Condition,或是需要把取得與釋放分到不同方法時,就選擇 ReentrantLock。
一般使用 ReentrantLock 時,請不要破壞緊接在 lock() 之後寫 try,並在 finally 中 unlock()這個形式。不要指望像 C++ 的 RAII 那樣自動釋放鎖,而要建構成即使發生例外也會釋放的結構。
與虛擬執行緒搭配時,還要確認 2.4 節的 JDK 版本差異。在 JDK 24 以後,沒有必要只以因應釘選為由就一律改寫 synchronized。不過,把鎖保持簡短的紀律並沒有改變。4
5. 決定任務的停止方式 ── 不要吞掉中斷
5.1. interrupt 不是強制結束,而是協調式停止的訊號
Java 的停止與取消,要以中斷(interruption)帶來的協調式停止來設計。t.interrupt() 會設定目標執行緒的中斷狀態。若目標正因 sleep / wait / join 等而阻塞,就會透過 InterruptedException 離開等待,此時中斷狀態會被清除。7
在本文所參照的 JDK 21 API 中,過去作為強制手段的 Thread.stop / suspend / resume 會拋出 UnsupportedOperationException。不要退回到那些會在狀態不正常時釋放鎖、或招致死結的危險手段,而要做成由任務自己回應停止訊號並結束的形式。7
flowchart TB
OWNER["停止方:t.interrupt()"] --> ST["中斷狀態被設定"]
ST --> A["計算中的執行緒:<br/>在迴圈中檢查 Thread.interrupted()"]
ST --> B["正因 sleep / wait / join 而阻塞:<br/>拋出 InterruptedException 並立即喚醒<br/>(狀態會被清除)"]
A --> E["收尾後自行結束"]
B --> C{"在 catch 中怎麼做?"}
C -->|"自己能夠結束"| E
C -->|"無法結束(例如在函式庫內)"| R["用 Thread.currentThread().interrupt()<br/>復原狀態,留下訊號"]
R --> E
圖4:收到中斷的任務自己收尾並結束。吞掉例外就會失去停止的訊號。
5.2. 明確界定收到 InterruptedException 之後的職責
不要寫出 catch 了 InterruptedException 卻什麼都不做的程式碼。如果能在自己的職責範圍內結束,就收尾後結束。若要把判斷交給呼叫端,就把例外原樣往上拋,或是用 Thread.currentThread().interrupt() 復原狀態、留下訊號。7
計算中的迴圈,也需要像圖4 那樣檢查中斷並走向結束。只實作送出停止要求的一方,若接收方無視它,還是停不下來。這個性質同樣直接適用於下一章的 shutdownNow()。
6. 終止 ExecutorService ── 把要求與完成確認分開
6.1. 只呼叫 shutdownNow 並不等於停止完成
終止 ExecutorService 時,要把停止接受的操作、要求取消的操作,以及等待完成的操作分開。2
| API | 作用 | 光靠它無法保證的事 |
|---|---|---|
shutdown() |
停止接受新任務,讓已投入的任務繼續執行 | 呼叫它的執行緒並不會一直等到完成 |
awaitTermination(...) |
提出關閉要求後,等到完成、逾時或被中斷的其中之一 | 逾時情況下的停止完成 |
shutdownNow() |
嘗試停止執行中的任務,並回傳等待中尚未執行的任務 | 執行中任務的強制結束,以及等到它結束為止 |
close() |
停止接受,並等到 Executor 終止為止 | 等待時間的上限 |
shutdownNow() 是盡力而為的。在 ThreadPoolExecutor 等標準實作中,典型做法是以中斷進行取消,因此不回應中斷的任務就停不下來。若使用自訂的 Executor,要確認該實作的取消方式,包括它是否會送出中斷。2
取消個別任務則使用 Future.cancel(true)。同樣地,不要把「對執行中的任務以中斷提出停止要求」和「任務確實結束了處理」混為一談。
6.2. 使用附期限的兩階段關閉
先停止接受新任務並等待既有任務跑完,超過期限就要求取消,再等待一次完成。若沒有結束,就要與成功區分開來並告知呼叫端。以下是以官方的兩階段模式為基礎,並納入停止成敗判定與未執行 Future 處理方式的範例。2
/** 停止完成則回傳 true。仍為 false 時不可繼續去釋放共享資源。 */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // 第一階段:停止接受新任務,等待跑完
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// 第二階段:要求取消。尚未執行就被卸下的任務會被回傳,
// 因此把那些 Future 設為已取消,喚醒在 get() 上等待的呼叫端
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // 停止未完成。以能與成功區分的形式告知
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // 也復原自己的中斷狀態
return false; // 這條路徑上停止也可能尚未完成
}
}
flowchart TB
S["shutdown()<br/>停止接受新任務"] --> W1{"用 awaitTermination<br/>等待跑完"}
W1 -->|"在期限內完成"| DONE["停止完成"]
W1 -->|"逾時"| NOW["shutdownNow()<br/>對執行中的任務送出 interrupt<br/>(是否回應取決於任務)"]
NOW --> W2{"用 awaitTermination<br/>再等一次"}
W2 -->|"完成"| DONE
W2 -->|"仍未結束"| LOG["記錄為異常<br/>(嫌疑是不回應中斷的任務)"]
圖5:把等待跑完的階段,與要求取消後再次等待的階段分開。若還是沒有結束,就當作停止未完成處理。
在回傳值仍為 false 的情況下,不可釋放任務所使用的共享資源。不只是第二次等待逾時的情況,在等待方本身被中斷的路徑上,任務也可能仍在運行。不要只留一筆記錄就當成成功,而要讓呼叫端能夠判斷停止尚未完成。
6.3. close 與 try-with-resources 只用在能跑完的範圍
從 JDK 19 起,ExecutorService 可以當作 AutoCloseable 使用。try (var executor = ...) 會在離開範圍時以 close() 等待終止。它也可以與虛擬執行緒的 Executor 搭配使用。2
不過,close() 是不設逾時地等待,因此無法取代附期限的兩階段模式。只要存在結束不了的任務或不回應中斷的任務,嘗試關閉的那支執行緒也會一直等下去。
對於就地投入任務、就地就能等到跑完的有限範圍,使用 try-with-resources。而在應用程式整體關閉這類不能一直等下去的路徑上,則要設計附期限的等待,以及停止未完成時的處理方式。2
6.4. 佇列中的任務未必就是使用者手上的 Future
shutdownNow() 回傳的是留在執行佇列中的物件。使用裸的 submit 時,它通常就是交給使用者的那個 FutureTask 本身,所以範例中的 cancel(false) 能夠喚醒在 get() 上等待的呼叫端。
另一方面,若是透過 ExecutorCompletionService 之類的包裝器投入的,回傳的是佇列中的包裝器,與使用者的 Future 是不同的物件。存在光靠上述範例無法讓使用者端的 Future 完成的架構。
這種情況下,要在投入時保留一份使用者端 Future 的清單,於關閉時取消那些 Future,或是設計成把從佇列卸下的任務交還給它的持有者。請把「等待一件已確定不會執行的工作的那一方」也納入,確認整條停止路徑。
7. 把 UI 交回專用執行緒 ── Swing 的 EDT
管理 Swing UI 的是事件分派執行緒(EDT)。Swing 元件的方法原則上並非執行緒安全,若從多個執行緒去觸碰,會招致執行緒干擾與記憶體一致性錯誤。8
從其他執行緒更新畫面時,要用 SwingUtilities.invokeLater 委託給 EDT。反過來,若在 EDT 上執行耗時處理,UI 就會卡住,因此繁重的工作要用 SwingWorker 之類交給工作執行緒。把工作移到外面,和把結果的顯示交回 UI 執行緒,是成套的。8
JavaFX 也是相同的結構。UI 更新要用 Platform.runLater 委託給應用程式執行緒。即使執行緒的種類增加,「UI 是管理它的那支執行緒的專屬物」這項原則也不會改變。
8. 備好驗證與調查手段 ── 設計審查、傾印、負載測試
8.1. 先審查共享資料與停止路徑
就算一般的測試通過了,也可能只是「碰巧沒有發生競爭」而已。不要指望光靠測試找出競爭錯誤,而要把第一道防線放在設計上。
用一張對應表確認共享的可變資料、保護該資料的鎖,以及鎖的取得順序。此外,還要確認有沒有吞掉 InterruptedException 的 catch,以及關閉與中斷的路徑能否傳達到所有任務。把第 3 章到第 6 章的設計原樣當成審查項目。
8.2. 依執行緒的種類擷取對應的傾印
平台執行緒的狀態,用 jstack 或 jcmd <pid> Thread.print 查看。使用 -l 選項還能確認鎖的附加資訊。9
不過,傳統格式的傾印並不包含應用程式的虛擬執行緒。在使用虛擬執行緒的架構中追查卡住的請求時,請用 jcmd <pid> Thread.dump_to_file -format=json <檔案> 擷取包含虛擬執行緒的格式。1
調查掛起(hang)時,每隔幾秒擷取二到三次傾印,比對沒有動作的執行緒。平台執行緒在等鎖時,要追查它在等什麼、那個鎖被誰持有。虛擬執行緒則從包含虛擬執行緒的傾印中,確認處理停在哪裡。若把 tryLock(timeout) 的逾時記錄下來,還能製造擷取傾印的時機。
8.3. 用接近正式環境的負載搖晃執行順序
除了作為第二道準備的傾印之外,還要把壓力測試當成第三道準備。用超過核心數的平行度長時間運行、把處理順序隨機化、插入人為的延遲,藉這些方法讓引發競爭的執行順序更容易被踩到。
請在發布前,至少跑過一次接近正式環境的資料量與執行緒數的測試。不過,不要把通過負載測試當成不再需要設計共享狀態的理由。
9. 新 API 的定位 ── 把預覽功能與正式功能分開
本章是截至 2026 年 8 月的整理。為了不把已經可用的基本工具與開發中的 API 混為一談,在此先分開說明。
Structured Concurrency(StructuredTaskScope)是把多個相關的子任務當成一個作業單位處理,並將失敗傳播與取消結構化的 API。它設想的用法以虛擬執行緒為前提,但在這個時點仍是預覽功能。在 JDK 25 的第五次預覽(JEP 505)中,它改成使用 StructuredTaskScope.open() 的 API 形式,在 JDK 26 中也以第六次預覽(JEP 525)的形式持續進行。1011
另一方面,處理不可變情境共享的 Scoped Values 已在 JDK 25 正式化。它是針對 ThreadLocal 的可變性、生命週期管理、繼承成本等問題的解決方案。12
新 API 所追求的方向,也與本文的原則相同。明確劃分任務的邊界、共享盡量靠向不可變、停止以協調的方式處理。
10. 總結 ── 實作前與審查時要確認的 10 個項目
| # | 要確認的事 |
|---|---|
| 1 | 業務程式碼中是否沒有直接建立 new Thread,而是把任務交給了 ExecutorService |
| 2 | 是否把 I/O 等待與 CPU 計算的執行手段分開,並為固定池的等待佇列也考慮了上限或反壓 |
| 3 | 是否沒有把虛擬執行緒放進池裡,並用 Semaphore 限制對外部服務的同時數 |
| 4 | 是否把共享資料轉向拆分、不可變化與傳遞,並檢查到 record 與不可變集合的元素內部 |
| 5 | 是否讓專用鎖與資料一一對應,並避免了 synchronized(this) 與對公開物件上鎖 |
| 6 | 是否使用了 computeIfAbsent 等複合操作,並遵守了對應函式的重新執行條件與保持處理簡短 |
| 7 | 是否沒有指望 volatile 提供原子性,而把計數器交給 Atomic 系列或 LongAdder、把複合狀態交給鎖 |
| 8 | 是否沒有吞掉 InterruptedException,停止的訊號能否傳達到任務那一端 |
| 9 | 終止處理是否為附期限的兩階段模式。close() 是否僅限於能保證跑完的範圍,並且也處理了停止未完成與未執行的 Future |
| 10 | 是否把 Swing / JavaFX 的 UI 更新集中到 EDT 與應用程式執行緒 |
Java 的虛擬執行緒開闢了讓直白的同步程式碼原樣規模化的道路。即便如此,提升吞吐量的工具、保護共享狀態的工具、傳達停止的工具依然各自獨立。把執行、共享、停止的角色分開來選,正是 Java 多執行緒設計的基本。
相關文章
- 多執行緒實務最佳實踐 .NET 篇
- 多執行緒實務最佳實踐 C++ 篇
- 多執行緒實務最佳實踐 C 語言篇
- C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait
相關諮詢領域
合同會社小村軟體承接 Java 撰寫的業務系統與批次處理的多執行緒設計審查、共享狀態損壞或「偶爾停不下來、卡住」這類由並行處理引起的故障調查(執行緒傾印分析),以及導入虛擬執行緒的技術諮詢。
參考連結
-
OpenJDK, JEP 444: Virtual Threads. 關於虛擬執行緒在 JDK 21 成為正式功能,它是能大幅減少撰寫、維護與觀測高吞吐量並行應用程式所需心力的輕量執行緒,其設計思想是讓「一個請求一支執行緒」的直白同步程式碼原樣規模化,JDK 的虛擬執行緒排程器是以 FIFO 模式運作的工作竊取型 ForkJoinPool、其預設平行度為可用處理器數量,以及包含虛擬執行緒的新式執行緒傾印格式已加入為 jcmd Thread.dump_to_file(純文字及 JSON 格式),而傳統的執行緒傾印不包含虛擬執行緒等內容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). 關於 shutdown() 會讓已投入的任務跑完,同時停止接受新任務,shutdownNow() 會嘗試停止執行中的任務並回傳等待中任務的清單,但典型實作是透過 Thread.interrupt() 進行取消,不保證超出盡力而為的效果,不回應中斷的任務不會結束,可用 awaitTermination 等待完成,close()(Java 19 以後,AutoCloseable)會執行 shutdown 並等到完成、可用 try-with-resources 使用,以及文件以 shutdown → awaitTermination → shutdownNow 的兩階段關閉作為使用範例等內容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Oracle Java SE Core Libraries, Virtual Threads. 關於虛擬執行緒是由 Java 執行環境實作、在阻塞式 I/O 時會放開 OS 執行緒的輕量執行緒,它是為了規模(吞吐量)而非速度(延遲)所設計的功能,不適合 CPU 密集型的處理,虛擬執行緒絕對不要放進池裡,而是每個任務使用一支(newVirtualThreadPerTaskExecutor),同時執行數的限制應使用 Semaphore 而非執行緒池,在 JDK 21 時點,synchronized 內的阻塞會導致釘選在 OS 執行緒上,因此在頻繁、長時間的情況下建議改用 ReentrantLock,以及可透過 -Djdk.tracePinnedThreads 偵測釘選等內容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. 關於 JDK 24 重寫了 JVM 的監視器實作以支援虛擬執行緒,即使在 synchronized 區塊或方法內發生阻塞,虛擬執行緒也不再被釘選在承載執行緒上,以及這使得 JDK 21~23 時代「把 synchronized 改成 ReentrantLock」的對策原則上已不再需要等內容。 ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. 關於記憶體一致性錯誤是由多個執行緒對同一份資料看到不一致的樣貌所產生,避免它的關鍵是 happens-before 關係(保證某個陳述式對記憶體的寫入能被另一個陳述式看見),以及 synchronized、volatile、Thread.start / join 等會建立 happens-before 等內容。 ↩ ↩2 ↩3
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). 關於 computeIfAbsent 整個方法呼叫是以原子方式執行,鍵不存在時對應函式恰好只會被呼叫一次,計算期間會阻塞其他執行緒的部分更新操作,因此計算應保持簡短單純,不可在對應函式內修改這個 Map,可被偵測到的遞迴式更新會引發 IllegalStateException,以及取得操作(get)不會阻塞、對某個鍵的更新與其後續取得之間會成立 happens-before 關係等內容。 ↩ ↩2 ↩3 ↩4
-
Oracle, Thread (Java SE 21 & JDK 21 API). 關於 Thread.stop / suspend / resume 因為本質上並不安全(會在鎖處於不正常狀態時釋放,導致其他執行緒看到損壞的物件,suspend 會招致死結)而被列為預定移除的已棄用方法,目前呼叫會拋出 UnsupportedOperationException,interrupt() 會設定中斷狀態,對正因 sleep / wait / join 而阻塞的執行緒會拋出 InterruptedException 將其喚醒(此時中斷狀態會被清除),以及 interrupted() 與 isInterrupted() 在狀態處理上的差異等內容。 ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. 關於 Swing 的事件處理程式碼在事件分派執行緒(EDT)上執行,絕大多數 Swing 物件的方法並非執行緒安全,從多個執行緒呼叫會招致執行緒干擾與記憶體一致性錯誤,因此對 Swing 元件的存取原則上應在 EDT 上進行,從其他執行緒要用 SwingUtilities.invokeLater / invokeAndWait 把任務委託給 EDT,以及 EDT 上的任務應在短時間內完成等內容。 ↩ ↩2
-
Oracle, The jstack Command (Java SE 21 Tools Reference). 關於 jstack 會輸出指定 Java 處理程序中所有執行緒的堆疊追蹤(類別名稱、方法名稱、行號),可用 -l 選項顯示包含鎖相關附加資訊的詳細內容,以及會與 jcmd 等其他診斷工具搭配使用等內容。 ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). 關於結構化並行 API 把相關的子任務群當成一個作業單位處理,將錯誤傳播與取消結構化,StructuredTaskScope 已改為透過靜態工廠方法(open)開啟的形式,以及在 JDK 25 時點仍是第五次預覽、尚非正式功能等內容。 ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). 關於結構化並行在 JDK 26 中仍以第六次預覽持續進行,也就是說截至 2026 年 8 月的現行 JDK 中仍是預覽功能,使用時需要啟用預覽功能等內容。 ↩
-
OpenJDK, JEP 506: Scoped Values. 關於 Scoped Values 已在 JDK 25 正式化,它是一種在執行緒內及執行緒之間安全且有效率地共享不可變情境資料的機制,是針對 ThreadLocal 問題點(可變性、生命週期管理、繼承成本)的解決方案等內容。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
針對 .NET/C# 整理能防止「建立執行緒後偶爾當機、卡死」的設計定石。內容涵蓋不自行建立執行緒而改用 Task、減少共享可變狀態、鎖的紀律、以 CancellationToken 設計停止機制,以及 UI 執行緒的處理方式。
多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫
C 語言 × Win32 的多執行緒有其定石:以 _beginthreadex 建立執行緒、SRW 鎖與條件變數、Interlocked、以停止事件 + WaitForMultipleObjects 設計停止流程。本文一併整理 TerminateThread 的危險性與 D...
多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故
C++ 的多執行緒是資料競爭會變成未定義行為的世界。本文整理 std::thread 解構函式的陷阱、以 jthread 與 stop_token 設計停止機制、scoped_lock 的死結迴避、atomic 的正確定位,以及與 Win32 同步 API 的使用區分。
從睡眠恢復就壞掉的應用程式 ── 電源事件的機制,以及耐得住恢復的業務應用程式寫法
打開筆電,業務應用程式的連線卻斷了──原因是設計從未把睡眠算進去。本文依一次資訊整理 WM_POWERBROADCAST 的通知流程、Modern Standby 的行為、斷線/重連設計、睡眠抑制,以及調查指令。
DllMain 與載入器鎖定 ── 「DLL 初始化時什麼都別做」真正的理由
為什麼不能在 DllMain 裡呼叫 LoadLibrary 或與其他執行緒同步?本文依據一次資料,從序列化所有 DLL 通知的載入器鎖定機制,一路說明到死結成立的典型情境、延遲初始化等正確設計,以及無回應的調查步驟。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
常見問題
整理諮詢這個主題時常見的問題。
- 既然有了虛擬執行緒,是不是就不再需要執行緒池(ExecutorService)了?
- 要看用途。虛擬執行緒是為了大量執行以 I/O 等待為主的任務而設計的機制,它提升的不是程式碼的速度,而是吞吐量。I/O 密集型的工作,每個任務用一支虛擬執行緒(Executors.newVirtualThreadPerTaskExecutor),不可以把虛擬執行緒放進池裡。另一方面,要把 CPU 用滿的計算平行化,仍然像以往一樣,適合使用限制在核心數量級的平台執行緒池(或 parallel stream)。此外,若想收緊對外部服務的同時存取數,虛擬執行緒時代建議的做法不是用池來收緊,而是用 Semaphore 限制。
- synchronized 與 ReentrantLock 應該用哪一個?
- 如果互斥既簡短又單純,用 synchronized 就足夠,程式碼也更簡潔。當需要 tryLock 的附逾時取得、公平性策略、多個 Condition 這類功能時,才選擇 ReentrantLock。另外,與虛擬執行緒搭配時有一個歷史性的注意事項:在 JDK 21~23 中,只要在 synchronized 區塊內發生阻塞,虛擬執行緒就會被釘選在 OS 執行緒上,因此當時建議把伴隨頻繁、長時間阻塞的位置改寫成 ReentrantLock。到了 JDK 24(JEP 491),監視器的實作已經重寫,這項限制已經解除。若使用 JDK 24 以後的版本,就不需要只為了釘選而做改寫。
- 只要加上 volatile 就能達成執行緒安全嗎?
- 不能。Java 的 volatile 會在對該變數的寫入與讀取之間建立 happens-before 關係,保證可見性(其他執行緒能看到最新的寫入)與順序,但不保證「讀取、計算、寫回」這種複合操作的原子性。若多個執行緒對 volatile int 的計數器執行 ++,加總就會遺失。計數器請使用 AtomicInteger / AtomicLong(高頻率的彙總則用 LongAdder),若要一併保護多個變數就使用鎖。volatile 適用的場合,幾乎僅限於像簡單的狀態旗標那樣「一個執行緒寫入、其他執行緒只讀取」的情境。
- 可以 catch 住 InterruptedException 之後忽略它嗎?
- 不可以。中斷是 Java 標準的停止與取消訊號,一旦吞掉就會製造出「停不下來的執行緒」。在 InterruptedException 被拋出的當下,中斷狀態已經被清除,因此若自己無法把處理結束掉,請用 Thread.currentThread().interrupt() 復原狀態,把訊號留給呼叫端,或是把例外原樣往上拋。catch 之後什麼都不做的空區塊,正是「shutdown 不生效」、「shutdownNow 被忽略」這類故障的典型成因。
- 不能用 Thread.stop 停止執行緒嗎?
- 不能。Thread.stop 本質上並不安全(會在鎖仍處於不正常狀態時將其釋放,讓損壞的物件被其他執行緒看到),因此長期以來都被標為棄用,在目前的 Java 中呼叫它會拋出 UnsupportedOperationException。Thread.suspend / resume 也一樣。停止執行緒唯一正當的手段,只有透過中斷(interrupt)進行的協調式停止。若使用 ExecutorService,shutdown 只會停止接受新任務並等待既有任務跑完,不會對執行中的任務送出中斷。嘗試停止執行中任務的是 shutdownNow,而它只是盡力而為(標準實作是透過中斷)。