多執行緒實務最佳實踐 Java 篇 ── 虛擬執行緒時代的定石

· · 多執行緒, Java, 業務應用程式, 不具合調查, 設計

「想用 Java 把業務系統的批次作業並行化」「Spring 製作的 Web 應用程式,共享快取偶爾會壞掉」「接手了一個滿是 new Thread 的老舊 Swing 應用程式」── Java 從 JDK 1.0 起就把多執行緒內建於語言之中,擁有成熟的工具箱 java.util.concurrent,再加上 JDK 21 的虛擬執行緒,又一次改寫了並行處理的常識。正因為工具豐富,「選哪一個」本身就直接決定了設計品質。

本文是多執行緒實務系列的Java 篇。以用 Java 撰寫業務系統、批次作業、伺服器應用程式的開發者為對象,把多執行緒設計的原則 ── 不直接建立執行緒、減少共享的可變狀態、鎖的紀律、事先設計好停止方式 ── 落實到 Java(以 LTS 版本 JDK 21 以後為主要對象)的工具中,結合虛擬執行緒時代的用法區分與 Java 特有的陷阱,依據 2026 年 8 月時點的一手資料整理而成。本文寫成單篇即可閱讀。同樣的原則在其他語言展開的還有「.NET 篇」「C++ 篇」「C 語言篇」。

1. 先講結論

  • 不在業務程式碼中寫 new Thread,這一點在 Java 也一樣。任務要丟給 ExecutorService,執行緒的生命週期管理交給函式庫負責。1
  • 以 I/O 等待為主的任務,交給虛擬執行緒。JDK 21 正式化的虛擬執行緒,用法是「一個任務對應一支虛擬執行緒」,絕對不要放進池中。要限制同時執行數,不是用池,而是用 Semaphore23
  • 虛擬執行緒是提升輸送量的工具,不是加快計算速度的工具。CPU 密集型的並行化,依然由核心數量左右的平台執行緒(固定大小的池或 parallel stream)負責。3
  • 鎖要用 private final 的鎖物件,或專用的 ReentrantLocksynchronized(this) 或對公開物件上鎖,會與外部程式碼衝突。若需要附逾時的取得(tryLock),就用 ReentrantLock
  • 在 JDK 21~23 中,synchronized 內的阻塞會把虛擬執行緒釘選住,但這個問題已在 JDK 24(JEP 491)中解決。請區分舊有的注意事項與新的實際狀況。34
  • volatile 保證的是可見性與順序,而不是原子性。計數器用 AtomicInteger / LongAdder,複合狀態則用鎖。5
  • 停止的唯一正解,是透過中斷(interrupt)進行協調式停止。Thread.stop / suspend / resume 目前都會丟出 UnsupportedOperationExceptionInterruptedException 不可吞掉,要不就復原狀態,要不就往上丟。6
  • ExecutorService 的停止採用「shutdown → awaitTermination → shutdownNow」的兩階段模式。shutdownNow 只是盡力而為(標準實作是透過中斷),前提是任務端本身會回應中斷。1
  • Swing 的 UI 是 EDT(事件分派執行緒)的專屬物。從其他執行緒操作時,要用 SwingUtilities.invokeLater 委託。7

2. 為什麼多執行緒很難 ── 競爭狀態・死結・記憶體模型

無論哪種語言,多執行緒帶來的問題追根究柢都可歸為兩類。

競爭狀態(race condition)是指多個執行緒到達某段特定程式碼的順序不同,導致結果跟著改變的錯誤。最典型的例子是共享計數器:count++ 這個看似單一的運算式,實際上分成「讀取 → 加算 → 寫回」三個步驟。若兩個執行緒同時進入這三個步驟,其中一方的加算會被另一方的寫回覆蓋而消失。每次執行結果都不同,而且無法預測會得到哪一種結果。

執行緒B共享變數 count執行緒A執行緒B共享變數 count執行緒Acount = 10明明加了兩次,count 卻等於 11執行緒A的加算消失了讀取,10讀取,10在手邊加算,11在手邊加算,11寫回,11寫回,11

圖1:共享計數器中加算遺失的典型競爭狀態。在 count++ 的三個步驟之間若被另一個執行緒插入,後寫回的一方就會覆蓋前者

死結(deadlock)是指兩個執行緒互相等待對方持有的鎖,導致雙方都無法繼續前進的狀態。執行緒A持有鎖1並等待鎖2,執行緒B持有鎖2並等待鎖1 ── 只要出現這種情況,雙方就會永遠停住。

等待鎖2釋放等待鎖1釋放執行緒A持有鎖1執行緒B持有鎖2

圖2:死結的循環等待。等待箭頭一旦形成環,環內的所有執行緒就會永遠停止

兩者都與時機有關,在開發機上數萬次才會踩中一次的執行順序組合,到了核心數與負載都不同的正式環境伺服器上,可能天天發生。「接上偵錯器就重現不了」「加了記錄就消失了」也是因為觀察行為本身改變了時機,這是競爭錯誤的典型表現。正因如此,本文的所有原則都指向同一個方向:與其「正確地同步」,不如先「減少需要同步的地方」。

2.1. Java 特有的前提 ── 記憶體模型與 happens-before

在此之上,Java 特有的地方在於,共享資料的可見性是由 Java 記憶體模型(JMM)的 happens-before 關係所定義的。

若沒有同步就對共享變數進行讀寫,雖然不會像 C++ 那樣變成「未定義行為」,但看到舊值、或看到寫入順序被調換是正當可能發生的情形。「明明在迴圈中檢查一個 boolean 旗標,卻一直看不到其他執行緒改過的值」這種記憶體一致性錯誤,是 JMM 所允許的行為,並不是 JVM 的錯誤。5 用來防止這種情況的工具,就是能建立 happens-before 的機制 ── synchronizedvolatilejava.util.concurrent 中的各個類別。只要正確使用並行集合,「更新操作與其後續取得操作之間的 happens-before」就由函式庫來保證。8

換句話說,Java 的實務指引可以歸納成這句話:不要在裸的共享變數上動手腳。共享時使用 java.util.concurrent 的工具,讓 happens-before 交給函式庫去建立。

3. 建立執行緒的方式 ── ExecutorService 與虛擬執行緒

3.1. 任務與執行的分離

在 Java 中,承擔「不自行建立執行緒」這項原則的,就是 ExecutorService。它把工作(Runnable / Callable)與「如何執行」(用幾支執行緒、用哪個佇列)分開,將執行緒的產生、重複使用、銷毀交給函式庫負責。1

JDK 21 以後,執行手段的選擇變成了單純的二選一。23

以I/O等待為主HTTP呼叫・資料庫・檔案用到CPU的計算有想要並行處理的任務任務的主體是?虛擬執行緒Executors.newVirtualThreadPerTaskExecutor()一個任務一支,不要放進池中平台執行緒的固定池Executors.newFixedThreadPool,約核心數或 parallel stream對外部服務的同時數限制不用池,改用Semaphore

圖3:JDK 21 以後執行手段的選擇。先劃出「I/O 是改變等待方式,CPU 是進行並行化」這條界線,I/O 密集型交給虛擬執行緒,CPU 密集型則由傳統的池負責

CPU 密集型這一側有一個需要注意的地方。Executors.newFixedThreadPool 雖然限制了執行緒數量,但等待佇列是無上限的。在投入量持續超過處理量的常駐服務中,被限制在核心數量的只有執行緒本身,堆積在佇列中的任務及其資料仍會持續佔用記憶體。在這種架構下,請直接使用 ThreadPoolExecutor,自行配置有容量上限的佇列+拒絕策略,或是在投入端加上 Semaphore 之類的入場限制,讓系統能夠施加反壓(這與第 4 章佇列的話題是相同的原則)。

3.2. 不要用錯虛擬執行緒的用法

虛擬執行緒是與 OS 執行緒切離的輕量執行緒,在JDK 的阻塞操作(標準函式庫的 I/O・鎖・sleep 等)期間會釋放 OS 執行緒,因此可以在一個 JVM 中執行數百萬支。不過,並不是「在任何阻塞下都能釋放」。若在原生程式碼(JNI)或 foreign function 執行期間發生阻塞,虛擬執行緒仍會被釘選在承載執行緒(carrier thread)上。JDK 24(後述的 JEP 491)解決的是由 synchronized 造成的釘選,原生邊界的釘選則依然存在,因此若把透過 JNI 的驅動程式或裝置 API、且會長時間阻塞的處理大量放到虛擬執行緒上,承載執行緒仍會被耗盡。不過正如官方指南所強調的,虛擬執行緒並不是「更快的執行緒」。程式碼的執行速度不會改變,它提供的是規模(輸送量)。3

使用上的紀律有三條。3

  1. 不要放進池中。虛擬執行緒是廉價的、用完即丟的,「任務數 = 虛擬執行緒數」才是正確狀態。把虛擬執行緒放進 newFixedThreadPool 是錯誤用法,正確的形式是 try (var executor = Executors.newVirtualThreadPerTaskExecutor())
  2. 同時執行數的限制交給 Semaphore像「對外部 API 最多同時連線 10 條」這種限制,不是用池的大小來表達,而是用信號量(semaphore)。
  3. 不要用在 CPU 密集型的工作上。計算的並行化,依然由核心數量左右的平台執行緒負責較為合適。

另外,在虛擬執行緒中執行的是一般的同步程式碼。虛擬執行緒的設計思想不是像 .NET 的 async/await 那樣改寫程式碼的寫法,而是把「一個請求對應一支執行緒」這種直白的程式碼原樣大量執行。2

3.3. 不要誤解 ── 「不需要池」只是針對虛擬執行緒而言

請不要把「不放進池中」這項紀律,誤解成「Java 沒有執行緒池這種機制(或它效率不佳)」。事實正好相反,Java 的池從 JDK 5(2004 年)起就是標準函式庫中成熟的工具。可細部配置的通用池 ThreadPoolExecutor(由 Executors 的各個工廠方法建立)、工作竊取(work-stealing)型的 ForkJoinPool(其共用實例 commonPool() 是 parallel stream 與 CompletableFuture 的預設執行位置)、用於週期性執行的 ScheduledThreadPoolExecutor ── 在 CPU 密集型的工作中,這些至今依然是主角。

池本來就是「因為建立、維持 OS 執行緒的成本很高,所以重複利用」這樣的一種最佳化手段。虛擬執行緒把建立成本降到幾乎為零,消除了這個前提,重複利用的理由也就不存在了 ── 不是池變得沒有效率,而是輕量到不再需要「池」這種最佳化,這才是正確的理解。而且在虛擬執行緒的底層,JDK 的排程器正是以工作竊取的 ForkJoinPool在運作核心數量左右的承載執行緒(OS 執行緒)。2 也就是說,「用少量的 OS 執行緒池,處理大量的並行工作」這個結構本身依然保留著,只是這個池的管理從開發者手中移交給了 JVM。.NET 的 async/await 在 await 的時點把執行緒歸還給池,而 Java 在不改變程式碼寫法的前提下,達到了相同的終點,可以說這就是 Java 的答案。

4. 減少共享可變狀態 ── 分割・不可變・並行集合・佇列

只有在「多個執行緒」與「共享的可變資料」同時具備時,才會發生競爭。執行緒的數量由需求決定,設計上能削減的是共享這一側。手段可分為「分割」「不可變化」「傳遞」三個系統,Java 中的寫法如下。

分割。在並行彙總時,不要讓各個執行緒都寫入同一個共享的合計變數,而是讓每個執行緒各自產生部分結果,最後再合併。parallel stream 的 reduce / collect 正是提供這種形式的框架,後述的 LongAdder 也是一種「內部把儲存格分割以分散競爭,讀取時再合算」的分割策略實作。減少對共享資料的寫入次數,比正確寫出同步邏輯更優先。

不可變化。record 與不可變集合(List.copyOf / Map.copyOf)建立「建構後不再改寫的資料」,就能不需同步地共享。組態或主檔資料,定石是「要替換時就建立新物件,再替換 volatile 的參照」這種形式。不過,「看起來是唯讀」與「真正不可變」是兩回事。record 的存取子回傳的是構成元素原本的參照,List.copyOf 的複製也是淺層複製(不會複製到元素物件本身),因此若元素本身是可變的,持有別名的其他人仍然能夠改寫內容,競爭依然存在。可以不加同步就共享的,僅限於連元素在內、整個物件圖都不可變的情況。若含有可變元素,就要傳遞深層複製,或是讓元素也改用 record / 不可變型別。

使用並行集合的複合操作。ConcurrentHashMap 的「若不存在就建立並放入」用 computeIfAbsent。這個方法整個呼叫是以原子方式執行的,若鍵不存在,在這一次呼叫中對應函式恰好只會被呼叫一次8 .NET 的 ConcurrentDictionary.GetOrAdd(競爭時工廠函式可能被呼叫多次)在保證上有所不同,這一點是往返兩種語言的人容易混淆的地方。不過這並非「該鍵一生中恰好一次」。若函式回傳 null 或丟出例外,對應關係就不會被登錄,後續的呼叫會再次執行該函式(登錄後又刪除該項目時也是同樣的情況)。不允許重複副作用的初始化,設計時務必包含「讓函式以非 null 方式成功執行」這一點。另外,作為原子性的代價,在計算期間會阻塞其他執行緒的部分更新操作,因此對應函式要保持簡短,且不可在函式內部更新這個 Map 本身(遞迴式的更新可能引發 IllegalStateException)。8

// 頻率計數器的定石: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

用佇列傳遞。執行緒之間的資料流動,交給 BlockingQueue指定容量ArrayBlockingQueue,在滿了的時候 put 會阻塞,自然形成反壓,與 .NET 篇中 bounded channel 是相同的結構。即使在虛擬執行緒時代,這種明確劃分生產者/消費者邊界的設計依然有效。

5. 鎖的紀律 ── synchronized 與 ReentrantLock

5.1. 用什麼鎖,鎖著的時候不要做什麼

鎖的單位不要以「程式碼區段」來思考,而要以「資料」來思考。為想保護的每一組可變資料對應一個鎖物件,並在觸碰該資料的所有地方都取用同一個鎖 ── 這張對應表一旦崩壞,就是競爭錯誤的實際成因。synchronized(this)synchronized(SomeClass.class) 會讓外部程式碼也能鎖住同一個物件,應該避免,而是讓不對外公開的 private final Object lock = new Object();與想保護的資料一對一對應。

用法上的紀律再補充兩點。第一,鎖著的時候不要做耗時的事、不要做外部的事。持有鎖時進行 I/O、呼叫監聽器、執行未知的程式碼,不僅會拉長持有時間,若被呼叫的一方又試圖取得另一個鎖,就會形成圖2那種循環等待。第二,固定多個鎖的取得順序。在需要取得兩個以上鎖的地方,要規定所有執行緒都以相同順序取得,對於無法保證順序的地方,則用後述的 tryLock(timeout) 準備一條「取不到就放手重來」的路徑。

synchronized 足以應付的是「短小簡單的互斥」。當需要以下功能時,就進階到 ReentrantLock

  • 透過 tryLock(timeout) 進行附逾時的取得(把永遠掛住的情況,轉變成可以記錄下來並處理的失敗)
  • 需要公平性策略、多個 Condition、想把取得鎖與釋放鎖分到不同方法中的情況

使用 ReentrantLock 時,不要打破「lock() 之後緊接著 try,在 finallyunlock()」這個形式(因為沒有相當於 C++ RAII 的語法,這個形式就是紀律的全部)。

5.2. 虛擬執行緒與釘選 ── JDK 24 改變的注意事項

虛擬執行緒導入初期(JDK 21~23),存在一項限制:synchronized 區塊內發生阻塞,虛擬執行緒會被釘選在 OS 執行緒上(無法釋放 OS 執行緒,喪失規模化的優勢),因此當時建議把頻繁、長時間阻塞的地方改成 ReentrantLock3 這項限制已在JDK 24 的 JEP 491 中因為重寫了監視器(monitor)的實作而解除,synchronized 不再釘選虛擬執行緒。4 若使用的是 JDK 24 以後的版本,就不需要為了應對釘選而機械式地改寫。值得檢查一下公司內部的舊指引,是否還停留在 JDK 21 時期的注意事項上。

5.3. Atomic 系列與 volatile 的定位

單一變數的原子式更新,由 AtomicInteger / AtomicLong / AtomicReference(若只是高頻率累加的統計值,則用抗競爭力更強的 LongAdder)負責。volatile 保證的是可見性與順序(happens-before),並不賦予複合操作原子性。5 與 .NET 篇、C++ 篇相同的結論在 Java 中同樣成立:旗標與單一值用 Atomic 系列,複合狀態用鎖,不要單靠 volatile 硬撐。

6. 停止方式的設計 ── 中斷這門共通語言

6.1. interrupt 的用法

Java 的停止・取消,統一透過中斷(interruption)進行。t.interrupt() 會設定目標執行緒的中斷狀態,若目標正因為 sleep / wait / join 等而阻塞,就會丟出 InterruptedException 立即將其喚醒(此時中斷狀態會被清除)。6 過去用來強制停止的 Thread.stop / suspend / resume,由於本質上不安全,目前呼叫時會丟出 UnsupportedOperationException6

自己能夠結束無法自行結束,例如在函式庫內停止方: t.interrupt()中斷狀態被設定計算中的執行緒:在迴圈中確認Thread.interrupted()因sleep/wait/join而阻塞中:InterruptedException被丟出並立即喚醒,狀態會被清除收尾後自行結束catch之後如何處理?用Thread.currentThread().interrupt()復原狀態,留下訊號

圖4:透過中斷進行的協調式停止。若把 InterruptedException 吞掉,停止的訊號就會消失 ── catch 之後只有「結束」或「復原」兩種選擇

實務上的紀律只要記住一條就夠了:不要寫出 catch 住 InterruptedException 卻什麼都不做的程式碼。若能在自己的責任範圍內結束,就結束;若不能,就用 Thread.currentThread().interrupt() 復原狀態,把訊號傳達給呼叫端(參見 FAQ)。

6.2. ExecutorService 的兩階段關閉

ExecutorService 的停止 API 是建立在中斷模型之上的。shutdown() 停止接受新任務,並讓已投入的任務跑完;shutdownNow() 則嘗試停止執行中的任務。就介面規格而言,這是盡力而為(best effort),標準實作(ThreadPoolExecutor 等)明確記載,典型做法是透過 Thread.interrupt() 取消 ── 也就是說,不回應中斷的任務,即使用 shutdownNow 也停不下來,若使用自訂實作的 Executor,就必須在該實作的文件中確認它的取消方式(是否會送出中斷)。1 官方文件所示的停止定石,就是以下的兩階段模式。1

/** 停止完成則回傳 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;                   // 這條路徑也可能停止未完成
    }
}
期限內完成逾時完成仍未結束shutdown()停止接受新任務用awaitTermination等待跑完停止完成shutdownNow()對執行中任務送出interrupt,是否回應要看任務本身再次用awaitTermination等待記錄為異常,不回應中斷的任務是頭號嫌疑

圖5:ExecutorService 的兩階段關閉。「先穩健等待 → 用中斷要求 → 仍未結束就當作異常觀測」這種分階段設計

另外,shutdownNow() 回傳的 Runnable 在取消上有一個限制。回傳的是留在執行佇列中的物件,若是單純的 submit,那就是回傳給使用者的 FutureTask 本身,但若透過 ExecutorCompletionService 之類的包裝器投入任務,佇列中的物件是包裝器,與使用者手上的 Future 是不同的東西。在這種架構下,上述的取消不會使使用者端的 Future 完成,因此請在投入時自行保留 Future 的清單,在關閉時取消該清單(或是把被降下的任務交回給持有者)。

JDK 19 以後的 close()(AutoCloseable)把「執行 shutdown 並等到完成」變成可以用 try-with-resources 寫出的形式,與虛擬執行緒的 newVirtualThreadPerTaskExecutor 搭配使用的 try (var executor = ...),是現代的基本寫法。1 不過 close() 並非上述兩階段模式的替代品。它不設逾時就等待完成,因此只要有一個不回應中斷或跑不完的任務,試圖關閉的執行緒就會永遠阻塞。它適合用在範圍內任務數量有限、且保證能跑完的場合(當場丟出、當場等待的用法),而像應用程式關閉路徑這種「必須在有限時間內結束」的地方,請使用附期限的兩階段模式。個別任務的取消,同樣是由 Future.cancel(true) 透過中斷來進行。

7. UI 執行緒 ── Swing 的 EDT

無論語言或框架為何,桌面應用程式都有一條規矩:「UI 是管理它的那支執行緒的專屬物」。在 Swing 中,這支專屬執行緒就是事件分派執行緒(EDT),Swing 元件的方法原則上並非執行緒安全,若被多個執行緒觸碰,會招來執行緒干擾與記憶體一致性錯誤。從其他執行緒更新畫面時,要用 SwingUtilities.invokeLater 委託給 EDT;反過來,若在 EDT 上執行耗時處理,UI 就會卡住,因此繁重的工作要透過 SwingWorker 之類的機制交給工作執行緒處理。7 JavaFX 的結構也相同,UI 更新要用 Platform.runLater 委託給應用程式執行緒。

8. 驗證與除錯 ── 執行緒傾印(thread dump)這項武器

競爭錯誤不能指望靠測試找出來,因為一般的測試會把「碰巧沒有發生競爭」的執行結果算作成功。防禦要分三層來考慮。

第一道防線是設計。在審查(review)時,用表格確認「共享的可變資料有哪些」「各自由哪個鎖保護(5.1 的對應表)」「鎖的取得順序是否唯一」「有沒有把 InterruptedException 吞掉的 catch」「停止路徑(shutdown/中斷)是否能傳達到所有任務」。

第二,善用執行緒傾印。Java 有標準工具可以擷取「當下卡住那一瞬間所有執行緒的狀態」,jstack(或 jcmd <pid> Thread.print)可以輸出堆疊追蹤,加上 -l 選項還能輸出鎖的附加資訊。9 需要注意的是,這種傳統格式的傾印是針對平台執行緒的,應用程式中的虛擬執行緒不會包含在內。若使用虛擬執行緒的架構(第3章)要追蹤被阻塞的請求,請改用能連虛擬執行緒也一併傾印的 jcmd <pid> Thread.dump_to_file -format=json <檔案>2 調查掛起(hang)的基本步驟,是每隔幾秒擷取二到三次傾印,比對哪些執行緒沒有動作、它們在等待哪個鎖、以及那個鎖目前被誰持有。若把 tryLock(timeout) 的逾時記錄下來(5.2),甚至可以把擷取傾印這件事自動化。

第三,用負載去搖晃它。用超過核心數的並行度長時間執行、把處理順序隨機化、插入人為延遲,這類壓力測試是在開發機上更容易「抽中」競爭問題的現實手段。請務必在正式發布前,以接近正式環境的資料量與執行緒數跑過一次測試。

9. 展望 Java 並行處理的未來 ── 結構化並行

最後談一點稍微超前的話題。以虛擬執行緒為前提,「把多個子任務當成一個作業單位處理,並將失敗傳播與取消結構化」的Structured Concurrency(StructuredTaskScope)正在開發中,截至 2026 年 8 月仍是預覽功能。在 JDK 25 的第五次預覽(JEP 505)中,API 形式改為透過 StructuredTaskScope.open() 開啟,在目前的 JDK 26 中,依然以第六次預覽(JEP 525)的形式持續進行。1011 另一方面,解決 ThreadLocal 問題的不可變情境共享機制 Scoped Values,已在 JDK 25 中正式化。12 本文的原則(明確劃分任務邊界、共享要不可變、停止要協調式進行),與這些新 API 追求的方向是一致的。

10. 總結 ── Java 版檢查清單

  1. 業務程式碼中是否還殘留 new Thread(是否已改用 ExecutorService / 虛擬執行緒)
  2. I/O 密集型與 CPU 密集型是否分開使用不同的執行手段(圖3的分岔)
  3. 是否沒有把虛擬執行緒放進池中,同時執行數的限制是否以 Semaphore 表達
  4. 共享資料是否為不可變(record / List.copyOf),或是使用了 java.util.concurrent 的工具
  5. 是否沒有 synchronized(this) / 對公開物件上鎖的情況
  6. 是否使用了 ConcurrentHashMap 的複合操作(computeIfAbsent 等),且對應函式保持簡短
  7. 是否沒有對 volatile 期待原子性(計數器是否使用 Atomic 系列/LongAdder)
  8. 是否沒有任何一個 catch 把 InterruptedException 吞掉
  9. ExecutorService 的停止是否採用附期限的兩階段模式(使用 close() 的地方,是否僅限於能保證任務跑完的範圍)
  10. Swing/JavaFX 的 UI 更新是否都集中在 EDT/應用程式執行緒上

Java 是並行處理工具最完善的語言之一,虛擬執行緒的出現也開啟了「讓直白的同步程式碼原樣規模化」這條路。正因如此,正確掌握工具的分工 ── 哪個是輸送量的工具、哪個是互斥的工具、停止的訊號是什麼 ── 才是 Java 多執行緒設計的實質內容。

相關文章

相關諮詢領域

合同會社小村軟體處理 Java 製業務系統・批次處理的多執行緒設計審查,共享狀態損壞或「偶爾停不下來・卡住」等由並行處理引起的不具合調查(執行緒傾印分析),以及虛擬執行緒導入的技術諮詢。

參考連結

</content>

  1. Oracle, ExecutorService (Java SE 21 & JDK 21 API). 關於 shutdown() 會讓已投入的任務跑完,同時停止接受新任務,shutdownNow() 會嘗試停止執行中的任務並回傳等待中任務的清單,但典型實作是透過 Thread.interrupt() 進行取消,不保證超出盡力而為(best effort)的效果,不回應中斷的任務不會結束,可用 awaitTermination 等待完成,close()(Java 19 以後,AutoCloseable)會執行 shutdown 並等到完成,可用 try-with-resources 使用,以及官方示例展示了 shutdown → awaitTermination → shutdownNow 的兩階段關閉等內容。  2 3 4 5 6

  2. OpenJDK, JEP 444: Virtual Threads. 關於虛擬執行緒在 JDK 21 成為正式功能,是能大幅減少撰寫・維護・觀察高輸送量並行應用程式所需心力的輕量執行緒,其設計思想是讓「一個請求一支執行緒」的直白同步程式碼原樣規模化,JDK 的虛擬執行緒排程器是以 FIFO 模式運作的工作竊取型 ForkJoinPool、其預設並行度為可用處理器數量,以及包含虛擬執行緒的新式執行緒傾印格式已加入為 jcmd Thread.dump_to_file(純文字及 JSON 格式),而傳統的執行緒傾印不包含虛擬執行緒等內容。  2 3 4 5

  3. 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

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. 關於 JDK 24 重寫了 JVM 的監視器(monitor)實作以支援虛擬執行緒,即使在 synchronized 區塊・方法內發生阻塞,虛擬執行緒也不再被釘選在承載執行緒上,以及這使得 JDK 21~23 時代「把 synchronized 改成 ReentrantLock」的對策原則上已不再需要等內容。  2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. 關於記憶體一致性錯誤是由多個執行緒對同一資料看到不一致的樣貌所產生,避免它的關鍵是 happens-before 關係(保證某個陳述式對記憶體的寫入能被另一個陳述式看見),以及 synchronized、volatile、Thread.start / join 等會建立 happens-before 等內容。  2 3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). 關於 Thread.stop / suspend / resume 因為本質上不安全(會在鎖處於不正常狀態時釋放,導致其他執行緒看到損壞的物件,suspend 會招致死結)而被列為預定移除的已棄用方法,目前呼叫會丟出 UnsupportedOperationException,interrupt() 會設定中斷狀態,對正因 sleep / wait / join 而阻塞的執行緒會丟出 InterruptedException 將其喚醒(此時中斷狀態會被清除),以及 interrupted() 與 isInterrupted() 在狀態處理上的差異等內容。  2 3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. 關於 Swing 的事件處理程式碼在事件分派執行緒(EDT)上執行,絕大多數 Swing 物件的方法並非執行緒安全,從多個執行緒呼叫會招致執行緒干擾與記憶體一致性錯誤,因此對 Swing 元件的存取原則上應在 EDT 上進行,從其他執行緒要用 SwingUtilities.invokeLater / invokeAndWait 把任務委託給 EDT,以及 EDT 上的任務應在短時間內完成等內容。  2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). 關於 computeIfAbsent 整個方法呼叫是以原子方式執行,鍵不存在時對應函式恰好只會被呼叫一次,計算期間會阻塞其他執行緒的部分更新操作,因此計算應保持簡短單純,不可在對應函式內修改這個 Map,可偵測到的遞迴式更新會引發 IllegalStateException,取得操作(get)不會阻塞,以及對某個鍵的更新與其後續取得之間會成立 happens-before 關係等內容。  2 3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). 關於 jstack 會輸出指定 Java 處理程序中所有執行緒的堆疊追蹤(類別名稱・方法名稱・行號),可用 -l 選項顯示包含鎖的附加資訊的詳細畫面,以及會與 jcmd 等其他診斷工具搭配使用等內容。 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). 關於結構化並行 API 把相關的子任務群當成一個作業單位處理,將錯誤傳播與取消結構化,StructuredTaskScope 已改為透過靜態工廠方法(open)開啟的形式,以及在 JDK 25 時點仍是第五次預覽、尚非正式功能等內容。 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). 關於結構化並行在 JDK 26 中仍以第六次預覽持續進行,也就是說截至 2026 年 8 月的現行 JDK 中仍是預覽功能,使用時需要啟用預覽功能等內容。 

  12. OpenJDK, JEP 506: Scoped Values. 關於 Scoped Values 已在 JDK 25 中正式化,它是一種在執行緒內及執行緒之間安全且有效率地共享不可變情境資料的機制,是針對 ThreadLocal 問題點(可變性、生命週期管理、繼承成本)的解決方案等內容。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

既然有虛擬執行緒了,還需要執行緒池(ExecutorService)嗎?
要看用途。虛擬執行緒是為了大量執行以 I/O 等待為主的任務而設計的機制,目的不是讓程式碼跑得更快,而是提高輸送量(throughput)。對於 I/O 密集型的工作,每個任務使用一支虛擬執行緒(Executors.newVirtualThreadPerTaskExecutor),絕對不要把虛擬執行緒放進池中。另一方面,對於會用盡 CPU 的計算並行化,仍然適合使用限制在核心數量左右的平台執行緒池(或 parallel stream)。此外,若想限制對外部服務的同時存取數,虛擬執行緒時代建議的做法不是用池來限制,而是用 Semaphore 限制。
synchronized 與 ReentrantLock 應該用哪一個?
短小簡單的互斥用 synchronized 就足夠了,程式碼也更簡潔。需要 tryLock 的逾時取得、公平性策略、多個 Condition 等功能時,才選擇 ReentrantLock。另外,與虛擬執行緒搭配使用時有一個歷史性的注意事項:在 JDK 21~23 中,若在 synchronized 區塊內發生阻塞,虛擬執行緒會被釘選(pin)在 OS 執行緒上,因此當時建議把頻繁、長時間阻塞的地方改寫成 ReentrantLock。JDK 24(JEP 491)重寫了監視器(monitor)的實作,這個限制已經解除。若使用 JDK 24 以後的版本,就不需要為了避免釘選而進行改寫了。
加上 volatile 就能保證執行緒安全嗎?
不能。Java 的 volatile 會在該變數的寫入與讀取之間建立 happens-before 關係,保證可見性(其他執行緒能看到最新的寫入)與順序性,但不保證「讀取、計算、寫回」這種複合操作的原子性。若多個執行緒對一個 volatile int 計數器執行 ++,加總結果會遺失。計數器請使用 AtomicInteger / AtomicLong(高頻率彙總的話用 LongAdder),若要一併保護多個變數則使用鎖。volatile 真正適合的場合,幾乎僅限於像簡單狀態旗標那種「一個執行緒寫入、其他執行緒只讀取」的情境。
可以 catch 住 InterruptedException 然後忽略它嗎?
不可以。中斷(interrupt)是 Java 標準的停止・取消訊號,若被吞掉就會製造出「停不下來的執行緒」。當 InterruptedException 被丟出時,中斷狀態已經被清除,因此若自己無法完成處理,就必須用 Thread.currentThread().interrupt() 復原狀態,把訊號留給呼叫端,或是直接把例外原樣往上丟。catch 之後什麼都不做的空區塊,是「shutdown 不生效」、「shutdownNow 被忽略」之類問題的典型成因。
不能用 Thread.stop 來停止執行緒嗎?
不能。Thread.stop 本質上是不安全的(會在鎖處於不正常狀態時將其釋放,導致其他執行緒看到損壞的物件),因此長久以來已被棄用,在目前的 Java 中呼叫它會丟出 UnsupportedOperationException。Thread.suspend / resume 也是一樣。停止執行緒唯一正當的手段,就是透過中斷(interrupt)進行協調式停止。若使用 ExecutorService,shutdown 只會停止接受新任務並等待既有任務跑完,不會對執行中的任務送出中斷。嘗試停止執行中任務的是 shutdownNow,而且它只是盡力而為(標準實作是透過中斷)。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽