マルチスレッドの実務ベストプラクティス Java編 ── 仮想スレッド時代の定石

· 更新日: · · マルチスレッド, Java, 業務アプリ, 不具合調査, 設計

更新履歴(6件・最終更新 2026年09月07日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の主張と条件・例外を保ち、実行手段の選択、共有状態、同期、協調停止、終了確認、UI、調査の順に再構成しました。JDK別の注意点と停止処理の限界を独立した節にまとめました。コードと知識マップは変更していません。
知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
知識マップの「Javaメモリモデルがメモリ一貫性エラーを防ぐ」という関係を、「メモリモデルはhappens-before関係を用いる」「happens-before関係がメモリ一貫性エラーを防ぐ」の2本に分けました。同期なしの観測はメモリモデル自身が許すためです。本文の説明は変えていません。
初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.22054236)

以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。

小村 豪(2026)「マルチスレッドの実務ベストプラクティス Java編 ── 仮想スレッド時代の定石」合同会社小村ソフト. https://comcomponent.com/blog/multithreading-best-practices-java/

DOI(登録済みアーカイブ)
10.5281/zenodo.22054236
DOI(前回登録した版)
10.5281/zenodo.22054237

「業務システムのバッチを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

実行手段は、まず仕事の主体で選びます。13

I/O待ちが主体HTTP呼び出し・DB・ファイルCPUを使う計算並行にしたいタスクがあるタスクの主体は?仮想スレッドExecutors.newVirtualThreadPerTaskExecutor()タスク1つに1本。プールしないプラットフォームスレッドの固定プールExecutors.newFixedThreadPool(コア数程度)または parallel stream外部サービスへの同時数制限はプールではなく Semaphore で

図1: I/O待ちには仮想スレッド、CPU計算には従来型のプールを選ぶ。同時アクセス数の制限は別に設計する。

仮想スレッドは「速いスレッド」ではありません。計算そのものの実行速度を上げるのではなく、待ち時間の多い仕事を大量に並行して扱い、スループットを上げるための道具です。CPUを使い切る計算には、従来どおりコア数程度のプラットフォームスレッドの固定プールやparallel streamを使います。3

2.2. 仮想スレッドはプールせず、制限したい資源をSemaphoreで守る

仮想スレッドはOSスレッドから切り離された軽量スレッドです。JDKが対応するI/O・ロック・sleepなどのブロッキング操作の間にOSスレッドを手放せるため、1つのJVMで数百万本を扱えるほどの並行性を実現します。ただし、すべてのブロックで手放せるわけではありません。ピン留めの条件は2.4節で分けて説明します。3

使い方は、1タスクにつき仮想スレッド1本です。安価な使い捨てとして扱い、newFixedThreadPool に仮想スレッドを入れて使い回す設計にはしません。Executors.newVirtualThreadPerTaskExecutor() を使います。13

一方、「外部APIへの同時接続は10件まで」といった制限は必要です。この上限は仮想スレッドのプールサイズではなく、Semaphore で表します。仮想スレッドをプールしないことと、外部サービスへ無制限にアクセスしてよいことは別です。3

仮想スレッドの中で動くのは普通の同期コードです。.NETのasync/awaitのような形へ書き換えず、「1リクエスト1スレッド」の素直なコードをそのまま大量に動かせるのが設計思想です。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++ は1つの式に見えても、読み込み・加算・書き戻しに分かれます。その途中へ別スレッドが入ると、一方の加算が失われます。

スレッドB共有変数 countスレッドAスレッドB共有変数 countスレッドAcount = 102回加算したのに count = 11スレッドAの加算が失われた読み込み(10)読み込み(10)手元で加算(11)手元で加算(11)書き戻し(11)書き戻し(11)

図2: 2つのスレッドが同じ値を読んでから書き戻すと、2回加算したはずが1回分しか残らない。

もうひとつの典型が、互いのロックを待って進めなくなるデッドロックです。こちらは4.3節で扱います。どちらもタイミングに依存するため、開発機ではまれでも、コア数や負荷が違う本番では頻発することがあります。デバッガーやログを加えると再現しなくなるのも、観測がタイミングを変えるためです。

だからこそ、正しく同期する前に、同期が必要な場所を減らすことから始めます。複数スレッドで動くことが要件なら、設計で減らしたいのは共有された可変データです。

3.2. Javaメモリモデルは「いつ他のスレッドから見えるか」を定める

Javaでは、共有データの見え方がJavaメモリモデル(JMM)の happens-before 関係で定義されています。同期なしの共有変数アクセスはC++のような未定義動作にはならないものの、古い値が見え続けたり、書き込み順序が入れ替わって見えたりすることがあります。5

たとえば、ループ中に boolean フラグを確認しているだけでは、別スレッドが変更した値がいつまでも見えないことがあります。これはJVMのバグではなく、必要な同期をしていないことで起こるメモリ一貫性エラーです。

happens-before を作るのが、synchronizedvolatilejava.util.concurrent の各クラスです。並行コレクションも、更新した値をその後に取得するときの関係を仕様として保証します。素の共有変数で工夫せず、こうした道具の保証を使います。ただし、値が見えることと、複数の操作をひとまとまりに実行できることは別です。原子性との違いは4.1節で整理します。56

3.3. 集計は部分結果に分け、最後に合流させる

並列集計では、全スレッドから1つの合計変数へ書き込むより、スレッドごとに部分結果を作り、最後に合算する形を先に考えます。parallel streamの reduce / collect は、この構成を枠組みとして提供します。

高頻度の加算に使う LongAdder も、内部のセルへ更新を分散し、読むときに合算する戦略です。まず共有への書き込みを減らし、そのあとで必要な同期を選びます。

3.4. 不変にするなら、要素の中まで確認する

設定やマスターデータは、構築後に書き換えない不変データとして共有します。差し替えるときは新しいオブジェクトを作り、volatile な参照を付け替えるのが定石です。

ただし、recordList.copyOf を使っただけで、データ全体が不変になるわけではありません。record のアクセサーは構成要素の参照をそのまま返します。List.copyOf / Map.copyOf でコレクションを変更できなくしても、要素オブジェクトまで深く複製されるわけではありません。

要素が可変なら、同じ要素への参照を持つ別のコードが中身を書き換えられ、競合が残ります。不変データとして同期なしで共有するには、要素まで含めたオブジェクトグラフ全体が不変であることを確認します。可変な要素を含むなら、深いコピーで共有を切るか、要素も record / 不変型に寄せます。「読み取り専用に見える」と「不変である」を区別してください。

3.5. ConcurrentHashMapは複合操作と、その保証範囲を使う

「キーがなければ作って入れる」は、確認と追加を別々に書かず、ConcurrentHashMap.computeIfAbsent を使います。メソッド呼び出し全体が原子的に実行され、キーが不在なら、その1回の呼び出しの中でマッピング関数がちょうど1回呼ばれます。競合時にファクトリが複数回走り得る.NETの ConcurrentDictionary.GetOrAdd とは保証が違います。6

頻度カウンターでは、次の形が定石です。

// 頻度カウンターの定石: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

「その呼び出しで1回」と「そのキーの一生で1回」は違います。関数が null を返した場合や例外を投げた場合は登録されず、後続の呼び出しで再実行されます。登録済みのエントリを削除した場合も同様です。副作用の重複が許されない初期化では、非nullの値を返して成功させることや、エントリの扱いまで含めて設計します。6

また、原子的に実行される代わりに、計算中は他スレッドの一部の更新がブロックされます。マッピング関数は短くシンプルに保ち、その中でこのマップ自体を更新してはいけません。検出可能な再帰的更新は 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、リスナー呼び出し、未知のコードを実行すると、保持時間が延びます。呼び出し先が別のロックを取れば、循環待ちを作ることもあります。

ロック2の解放待ちロック1の解放待ちスレッドAロック1を保持中スレッドBロック2を保持中

図3: 一方がロック1を持ってロック2を待ち、もう一方が逆の順に待つと、どちらも先へ進めない。

複数ロックを取る箇所では、全スレッドで取得順序を固定します。順序を保証できない箇所には、tryLock(timeout) で「取れなければ保持中のロックを手放し、やり直す」経路を用意します。ロック中に長い処理や外部の処理をしないことと、取得順序を揃えることをセットで守ります。

4.4. 短い排他はsynchronized、追加機能が必要ならReentrantLock

短くシンプルな排他なら synchronized で十分です。tryLock(timeout) による期限付きの取得、公平性ポリシー、複数の Condition、取得と解放を別メソッドに分ける必要がある場合は ReentrantLock を選びます。

通常の ReentrantLock の使用では、lock() の直後に tryfinallyunlock()という形を崩さないでください。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 / resumeUnsupportedOperationException を投げます。不正な状態のままロックを解放したり、デッドロックを招いたりする危険な手段に戻らず、タスク自身が停止の合図に応答して終わる形にします。7

自分で終われる終われない(ライブラリ内など)止める側: t.interrupt()割り込みステータスが立つ計算中のスレッド:Thread.interrupted() をループで確認sleep / wait / join でブロック中:InterruptedException が飛んで即座に目覚める(ステータスはクリアされる)後片付けして自分で終わるcatch でどうする?Thread.currentThread().interrupt()でステータスを復元して合図を残す

図4: 割り込みを受けたタスク自身が後片付けして終わる。例外を握りつぶすと、停止の合図を失う。

5.2. InterruptedExceptionを受けたあとの責務を明確にする

InterruptedException をcatchして何もしないコードは書きません。自分の責務の中で終了できるなら、後片付けして終わります。呼び出し元に判断を任せるなら、例外をそのまま上へ投げるか、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. 期限付きの2段階シャットダウンを使う

まず新規受付を止めて完走を待ち、期限を過ぎたらキャンセルを要求して、もう一度完了を待ちます。終了しなかった場合は、成功とは区別して呼び出し元へ伝えます。次は、公式の2段階パターンをもとに、停止の成否と未実行Futureの扱いを含めた例です。2

/** 停止が完了したら true。false のまま共有資源の解放に進んではいけない。 */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // 1段階目: 新規受付を止め、完走を待つ
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // 2段階目: キャンセルを要求。未実行のまま降ろされたタスクが返るので、
            // その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: 完走を待つ段階と、キャンセルを要求して再度待つ段階を分ける。それでも終わらなければ、停止未完了として扱う。

戻り値が false のまま、タスクが使用する共有資源を解放してはいけません。2回目の待機が期限切れになった場合だけでなく、待っている側が割り込まれた経路でも、まだタスクが動いている可能性があります。ログを残すだけで成功扱いにせず、呼び出し元が停止未完了を判断できるようにします。

6.3. closeとtry-with-resourcesを使うのは、完走できるスコープ

JDK 19以降、ExecutorServiceAutoCloseable として使えます。try (var executor = ...) は、スコープを抜けるときに close() で終了を待つ形です。仮想スレッドのExecutorと組み合わせても使えます。2

ただし、close() はタイムアウトなしで待つため、期限付きの2段階パターンの代わりにはなりません。終わらないタスクや割り込みに応答しないタスクがあれば、閉じようとしたスレッドも待ち続けます。

その場でタスクを投入し、その場で完走を待てる有限のスコープにはtry-with-resourcesを使います。アプリ全体のシャットダウンなど、待ち続けるわけにはいかない経路では、期限付きの待機と停止未完了時の扱いを設計します。2

6.4. キュー内のタスクと、利用者のFutureが同じとは限らない

shutdownNow() が返すのは、実行キューに残っていたオブジェクトです。素の submit では通常、利用者へ渡した FutureTask 自身なので、サンプルの cancel(false)get() 待ちの呼び出し側を起こせます。

一方、ExecutorCompletionService などのラッパー経由で投入した場合、返るのはキュー内のラッパーであり、利用者の 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

ハング調査では、数秒おきに2〜3回ダンプを取り、動いていないスレッドを突き合わせます。プラットフォームスレッドのロック待ちでは、何を待ち、そのロックを誰が持っているかを追います。仮想スレッドでは、仮想スレッドを含むダンプから処理のどこで止まっているかを確認します。tryLock(timeout) の期限切れをログへ残しておけば、ダンプ取得のきっかけも作れます。

8.3. 本番相当の負荷で、実行順序を揺さぶる

第二の備えであるダンプに加え、第三の備えとしてストレステストを行います。コア数より多い並列度で長時間動かす、処理順をランダム化する、人工的な遅延を挟むといった方法で、競合が起こる実行順を踏みやすくします。

本番相当のデータ量とスレッド数での試験を、リリース前に一度は通してください。ただし、負荷試験を通ったことを、共有状態の設計が不要になる理由にはしません。

9. 新しいAPIの位置づけ ── プレビューと正式機能を分ける

この章は、2026年8月時点の整理です。すでに使える基本の道具と、開発中のAPIを混同しないために分けておきます。

Structured Concurrency(StructuredTaskScope)は、関連する複数のサブタスクを1つの作業単位として扱い、失敗の伝播とキャンセルを構造化するAPIです。仮想スレッドを前提にした使い方が想定されていますが、この時点ではまだプレビュー機能です。JDK 25の第5次プレビュー(JEP 505)で StructuredTaskScope.open() を使うAPI形へ改められ、JDK 26でも第6次プレビュー(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 終了処理は期限付きの2段階パターンか。close() は完走を保証できるスコープに限り、停止未完了と未実行Futureも扱っているか
10 Swing / JavaFXのUI更新をEDT / アプリケーションスレッドへ集約しているか

Javaの仮想スレッドは、素直な同期コードをそのままスケールさせる道を開きました。それでも、スループットの道具、共有状態を守る道具、停止を伝える道具は別です。実行・共有・停止の役割を分けて選ぶことが、Javaのマルチスレッド設計の基本です。

関連記事

関連する相談領域

合同会社小村ソフトでは、Java製の業務システム・バッチ処理のマルチスレッド設計レビュー、共有状態の破損や「たまに止まらない・固まる」といった並行処理起因の不具合調査(スレッドダンプ解析)、仮想スレッド導入の技術相談を扱っています。

参考リンク

  1. OpenJDK, JEP 444: Virtual Threads. 仮想スレッドがJDK 21で正式機能となったこと、高スループットな並行アプリケーションの記述・保守・観測の労力を大幅に減らす軽量スレッドであること、「1リクエスト1スレッド」の素直な同期コードをそのままスケールさせるという設計思想、JDKの仮想スレッドスケジューラがFIFOモードで動作するワークスティーリングのForkJoinPoolであり、既定の並列度が利用可能なプロセッサ数であること、仮想スレッドを含むスレッドダンプの新形式が jcmd Thread.dump_to_file(プレーンテキストおよびJSON形式)として追加され、従来のスレッドダンプには仮想スレッドが含まれないことについて。  2 3 4 5 6

  2. 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段階シャットダウンが使用例として示されていることについて。  2 3 4 5 6 7 8

  3. Oracle Java SE Core Libraries, Virtual Threads. 仮想スレッドがJavaランタイムによって実装されブロッキングI/O時にOSスレッドを手放す軽量スレッドであること、速度(レイテンシ)ではなくスケール(スループット)のための機能でありCPU集約的な処理には向かないこと、仮想スレッドは決してプールせずタスクごとに1本使うこと(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のモニター実装が仮想スレッド対応に書き直され、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, ConcurrentHashMap (Java SE 21 & JDK 21 API). computeIfAbsentのメソッド呼び出し全体が原子的に実行され、キー不在時にマッピング関数がちょうど1回だけ呼ばれること、計算中は他スレッドの一部の更新操作がブロックされるため計算は短くシンプルに保つべきこと、マッピング関数内でこのマップを変更してはならず検出可能な再帰的更新はIllegalStateExceptionになること、取得操作(get)がブロックせず、キーごとの更新とその後の取得の間にhappens-before関係が成り立つことについて。  2 3 4

  7. Oracle, Thread (Java SE 21 & JDK 21 API). Thread.stop / suspend / resume が本質的に安全でない(不正な状態のままロックが解放され壊れたオブジェクトが見える、suspendはデッドロックを招く)ため削除予定の非推奨であり、現在は呼ぶとUnsupportedOperationExceptionを投げること、interrupt()が割り込みステータスを立て、sleep / wait / join でブロック中のスレッドにはInterruptedExceptionを投げて起こすこと(このとき割り込みステータスはクリアされる)、interrupted()とisInterrupted()のステータス扱いの違いについて。  2 3

  8. Oracle, The Java Tutorials, The Event Dispatch Thread. Swingのイベント処理コードがイベントディスパッチスレッド(EDT)上で走ること、ほとんどのSwingオブジェクトのメソッドはスレッドセーフではなく複数スレッドからの呼び出しがスレッド干渉やメモリ一貫性エラーを招くため、Swingコンポーネントへのアクセスは原則EDT上で行うべきこと、他スレッドからはSwingUtilities.invokeLater / invokeAndWaitでEDTにタスクを依頼すること、EDT上のタスクは短時間で終えるべきことについて。  2

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). jstackが指定したJavaプロセスの全スレッドのスタックトレース(クラス名・メソッド名・行番号)を出力すること、-lオプションでロックに関する追加情報を含む詳細表示ができること、jcmdなど他の診断ツールと併用されることについて。 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). 構造化並行性APIが関連するサブタスク群を1つの作業単位として扱い、エラー伝播とキャンセルを構造化するものであること、StructuredTaskScopeをstaticファクトリメソッド(open)で開く形に改められたこと、JDK 25時点で第5次プレビューであり正式機能ではないことについて。 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). 構造化並行性がJDK 26でも第6次プレビューとして継続していること、つまり2026年8月時点の現行JDKでもプレビュー機能のままであり、利用にはプレビュー機能の有効化が必要なことについて。 

  12. OpenJDK, JEP 506: Scoped Values. Scoped ValuesがJDK 25で正式化されたこと、スレッド内およびスレッド間で不変のコンテキストデータを安全かつ効率的に共有する仕組みであり、ThreadLocalの問題点(可変性、寿命管理、継承コスト)への解決策であることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

仮想スレッドがあるなら、もうスレッドプール(ExecutorService)は不要ですか?
用途によります。仮想スレッドはI/O待ちが主体のタスクを大量に走らせるための仕組みで、コードを速くするものではなくスループットを上げるものです。I/Oバウンドの仕事にはタスクごとに仮想スレッドを1本使い(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 が適切なのは、単純な状態フラグのような「1つのスレッドが書き、他が読むだけ」の場面にほぼ限られます。
InterruptedException は catch して無視してもよいですか?
いけません。割り込みはJavaの標準的な停止・キャンセルの合図であり、握りつぶすと「止まらないスレッド」を作ります。InterruptedException が投げられた時点で割り込みステータスはクリアされているため、自分で処理を終えられない場合は Thread.currentThread().interrupt() でステータスを復元して呼び出し元に合図を残すか、例外をそのまま上に投げてください。catch して何もしない空ブロックは、シャットダウンが効かない・shutdownNowが無視されるといった不具合の典型的な原因です。
Thread.stop でスレッドを止められないのですか?
止められません。Thread.stop は本質的に安全でない(ロックを不正な状態のまま解放し、壊れたオブジェクトが他スレッドから見える)ため長く非推奨とされ、現在のJavaでは呼ぶと UnsupportedOperationException が投げられます。Thread.suspend / resume も同様です。スレッドを止める正当な手段は割り込み(interrupt)による協調停止だけです。ExecutorService を使っている場合、shutdown は新規受付を止めて完走を待つだけで実行中のタスクに割り込みは送りません。実行中タスクの停止を試みるのは shutdownNow で、こちらはベストエフォート(標準実装では割り込み経由)です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る