マルチスレッドの実務ベストプラクティス 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で正式化された仮想スレッドは「タスク1つに仮想スレッド1本」で使い、決してプールしません。同時実行数の制限はプールではなく Semaphore で行います。23
  • 仮想スレッドはスループットの道具であって、計算を速くする道具ではありません。CPUバウンドの並列化は、従来どおりコア数程度のプラットフォームスレッド(固定プールやparallel stream)が担当です。3
  • ロックは private final のロックオブジェクトか専用の ReentrantLock で。synchronized(this) や公開オブジェクトへのロックは外部と衝突します。タイムアウト付き取得(tryLock)が要るなら ReentrantLock です。
  • JDK 21〜23では synchronized 内のブロックが仮想スレッドをピン留めする問題がありましたが、JDK 24(JEP 491)で解消されました。古い注意書きと新しい実態を区別してください。34
  • volatile は可視性と順序の保証であって原子性ではありません。カウンターは AtomicInteger / LongAdder、複合状態はロックです。5
  • 停止は割り込み(interrupt)による協調停止が唯一の正解です。Thread.stop / suspend / resume は現在 UnsupportedOperationException を投げます。InterruptedException は握りつぶさず、ステータスを復元するか上に投げます。6
  • ExecutorService の停止は「shutdown → awaitTermination → shutdownNow」の2段階パターンで。shutdownNow はベストエフォート(標準実装では割り込み経由)なので、タスク側が割り込みに応答することが前提です。1
  • SwingのUIはEDT(イベントディスパッチスレッド)の専有物です。別スレッドからは SwingUtilities.invokeLater で依頼します。7

2. なぜマルチスレッドは難しいのか ── 競合状態・デッドロック・メモリモデル

マルチスレッドが持ち込む問題は、言語を問わず突き詰めると2種類です。

競合状態(race condition)は、複数のスレッドがどの順序で特定のコードに到達するかによって結果が変わってしまうバグです。定番の例が共有カウンターで、count++ という1つの式は実際には「読み込み → 加算 → 書き戻し」の3ステップに分かれます。2つのスレッドがこの3ステップに同時に入ると、一方の加算がもう一方の書き戻しで上書きされて消えます。実行のたびに結果が変わり、どの結果になるかは予測できません。

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

図1: 共有カウンターで加算が失われる典型的な競合状態。count++ の3ステップの間に別スレッドが割り込むと、後から書き戻したほうが上書きする

デッドロックは、2つのスレッドが互いに相手の持っているロックを待ち合って、どちらも先へ進めなくなる状態です。スレッドAがロック1を持ってロック2を待ち、スレッドBがロック2を持ってロック1を待つ ── これだけで両者は永遠に止まります。

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

図2: デッドロックの循環待ち。待ちの矢印が輪を作った瞬間、輪の中の全スレッドが永遠に停止する

どちらもタイミング依存で、開発機では数万回に1回しか当たりを引かない実行順の組み合わせが、コア数も負荷も違う本番サーバーでは毎日起きます。「デバッガーを付けると再現しない」「ログを足したら消えた」のも観測がタイミングを変えるためで、競合バグの典型的な振る舞いです。だからこそ本記事の原則はすべて、「正しく同期する」より前に「同期が必要な場所を減らす」という方向を向いています。

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呼び出し・DB・ファイルCPUを使う計算並行にしたいタスクがあるタスクの主体は?仮想スレッドExecutors.newVirtualThreadPerTaskExecutor()タスク1つに1本。プールしないプラットフォームスレッドの固定プール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スレッドを手放すため、数百万本を1つのJVMで走らせられます。ただし「あらゆるブロックで手放せる」わけではありません。ネイティブコード(JNI)やforeign functionの実行中にブロックした場合、仮想スレッドはキャリアスレッドにピン留めされたままです。JDK 24(後述のJEP 491)が解消したのは synchronized によるピン留めであって、ネイティブ境界のピン留めは残るため、JNI経由のドライバーやデバイスAPIで長くブロックする処理を大量の仮想スレッドに載せると、キャリアスレッドが払底します。ただし公式ガイドが強調するとおり、「速いスレッド」ではありません。コードの実行速度は変わらず、提供するのはスケール(スループット)です。3

使い方の規律は3つです。3

  1. プールしない。仮想スレッドは安価な使い捨てで、「タスクの数=仮想スレッドの数」が正しい状態です。newFixedThreadPool に仮想スレッドを入れるのは誤りで、try (var executor = Executors.newVirtualThreadPerTaskExecutor()) の形で使います。
  2. 同時実行数の制限は Semaphore で。「外部APIへ同時10接続まで」のような制限を、プールのサイズではなくセマフォで表現します。
  3. CPUバウンドの仕事に使わない。計算の並列化は従来どおりコア数程度のプラットフォームスレッドが適任です。

なお、仮想スレッドの中で動くのは普通の同期コードです。.NETのasync/awaitのようにコードを書き換えるのではなく、「1リクエスト1スレッドの素直なコード」をそのまま大量に走らせられるのが仮想スレッドの設計思想です。2

3.3. 誤解しないでほしいこと ── 「プール不要」は仮想スレッドだけの話

「プールしない」という規律から「Javaにはスレッドプールという仕組みがない(または非効率)」と読まないでください。実際は逆で、Javaのプールは JDK 5(2004年)から標準ライブラリにある成熟した道具です。細かく構成できる汎用プールの ThreadPoolExecutor(Executors の各ファクトリで作るもの)、ワークスティーリング型の ForkJoinPool(その共通インスタンス commonPool() は parallel stream や CompletableFuture の既定の実行先)、周期実行用の ScheduledThreadPoolExecutor ── CPUバウンドの仕事では今もこれらが主役です。

プールとは本来「OSスレッドの作成・保持が高価だから、使い回す」という最適化です。仮想スレッドは作成コストをほぼゼロにすることでこの前提を消したため、使い回す理由がなくなった ── プールが非効率なのではなく、プールという最適化が不要なほど軽くなった、というのが正確な理解です。しかも仮想スレッドの足元では、JDKのスケジューラがワークスティーリングのForkJoinPoolとして、コア数程度のキャリアスレッド(OSスレッド)を運用しています。2 つまり「少数のOSスレッドのプールで、大量の並行処理を捌く」という構図自体は保たれていて、そのプールの管理が開発者の手からJVMへ移っただけです。.NETのasync/awaitが await の時点でスレッドをプールに返すのと同じ到達点を、コードの形を変えずに実現するのがJavaの答えだと言えます。

4. 共有可変状態を減らす ── 分割・不変・並行コレクション・キュー

競合は「複数のスレッド」と「共有された可変データ」が揃ったときにだけ起きます。スレッドの数は要件で決まるので、設計で削れるのは共有のほうです。手段は「分割」「不変化」「受け渡し」の3系統で、Javaではこう書きます。

分割する。並列集計では、共有の合計変数に各スレッドが書き込むのではなく、スレッドごとに部分結果を作って最後に合流させます。parallel streamの reduce / collect はまさにこの形を枠組みとして提供しますし、後述の LongAdder も「内部でセルを分割して競合を散らし、読むときに合算する」という分割戦略の実装です。共有への書き込み回数を減らすことが、同期を正しく書くことより先に来ます。

不変にする。record と不変コレクション(List.copyOf / Map.copyOf)で「構築後に書き換えないデータ」を作れば、同期なしで共有できます。設定やマスターデータは「差し替えるときは新しいオブジェクトを作り、volatile な参照を付け替える」形が定石です。ただし「読み取り専用に見える」と「不変である」は別物です。record のアクセサーは構成要素の参照をそのまま返しますし、List.copyOf のコピーも浅い(要素オブジェクトまでは複製しない)ため、要素側が可変ならエイリアスを持つ誰かが中身を書き換えられ、競合は残ります。同期なしで共有してよいのは、要素まで含めてオブジェクトグラフ全体が不変な場合だけです。可変な要素を含むなら、深いコピーを渡すか、要素も record / 不変型に寄せてください。

並行コレクションの複合操作を使う。ConcurrentHashMap の「なければ作って入れる」は computeIfAbsent を使います。このメソッドは呼び出し全体が原子的に実行され、キーが不在なら、その1回の呼び出しの中でマッピング関数がちょうど1回だけ呼ばれます8 .NETの ConcurrentDictionary.GetOrAdd(競合時にファクトリが複数回走りうる)とは保証が違う点は、両方の言語を行き来する人が混同しやすいポイントです。ただし「キーの一生でちょうど1回」ではありません。関数が null を返した場合や例外を投げた場合はマッピングが登録されず、後続の呼び出しで関数が再び実行されます(登録後にエントリを削除した場合も同様)。副作用の重複が許されない初期化は、関数を非nullで成功させることまで含めて設計してください。ただし原子的である代償として、計算中は他スレッドの一部の更新がブロックされるため、マッピング関数は短く保ち、関数の中でこのマップ自体を更新してはいけません(再帰的更新は IllegalStateException になりえます)。8

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

キューで受け渡す。スレッド間のデータの流れは BlockingQueue に寄せます。容量を指定した ArrayBlockingQueue なら、満杯時に put がブロックして自然な背圧になり、.NET編のboundedチャネルと同じ構図になります。仮想スレッド時代でも、プロデューサー/コンシューマーの境界を明確にするこの設計は有効です。

5. ロックの規律 ── synchronized と ReentrantLock

5.1. 何でロックするか、ロック中に何をしないか

ロックの単位は「コードの区間」ではなく「データ」で考えます。守りたい可変データの集合ごとにロックオブジェクトを1つ対応させ、そのデータに触るすべての場所で同じロックを取る ── この対応表が崩れているのが競合バグの実態です。synchronized(this)synchronized(SomeClass.class) は、外部のコードが同じオブジェクトをロックできてしまうため避け、外部に公開しない private final Object lock = new Object(); を守りたいデータと1対1で対応させます。

使い方の規律も2つ添えます。第一に、ロック中に時間のかかること・外部のことをしない。保持したままのI/O・リスナー呼び出し・未知のコードの実行は、保持時間を延ばすうえに、呼んだ先が別のロックを取ろうとして図2の循環待ちを作ります。第二に、複数ロックの取得順序を固定する。2つ以上のロックを取る場所では全スレッドが同じ順序で取ることをルール化し、順序を保証できない箇所には後述の tryLock(timeout) で「取れなければ手放してやり直す」経路を用意します。

synchronized で足りるのは「短くシンプルな排他」です。次が必要になったら ReentrantLock に進みます。

  • tryLock(timeout) によるタイムアウト付き取得(永遠のハングを、記録して対処できる失敗に変える)
  • 公平性ポリシー、複数の Condition、ロックの取得と解放を別メソッドに分けたい場合

ReentrantLock を使うときは lock() の直後に tryfinallyunlock() という形を崩さないでください(C++のRAIIに相当する構文がないため、この形が規律のすべてです)。

5.2. 仮想スレッドとピン留め ── JDK 24で変わった注意点

仮想スレッドの導入当初(JDK 21〜23)には、synchronized ブロックの中でブロックすると仮想スレッドがOSスレッドにピン留めされる(OSスレッドを手放せず、スケールの利点が失われる)という制約があり、頻繁・長時間のブロックを伴う箇所は ReentrantLock への置き換えが推奨されていました。3 この制約はJDK 24のJEP 491でモニター実装が書き直されて解消され、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 は本質的に安全でないため、現在は呼ぶと UnsupportedOperationException になります。6

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

図4: 割り込みによる協調停止。InterruptedException を握りつぶすと停止の合図が消滅する ── catch したら「終わる」か「復元する」かの二択

実務の規律はひとつだけ覚えれば足ります。InterruptedException を catch して何もしないコードを書かない。自分の責務の中で終了できるならそこで終わり、できないなら Thread.currentThread().interrupt() でステータスを復元して呼び出し元に合図を伝えます(FAQ参照)。

6.2. ExecutorService の2段階シャットダウン

ExecutorService の停止APIは割り込みモデルの上に乗っています。shutdown() は新規受付を止めて投入済みタスクを完走させ、shutdownNow() は実行中タスクの停止を試みます。インターフェイス仕様としてこれはベストエフォートであり、標準の実装(ThreadPoolExecutor など)は典型的に Thread.interrupt() でキャンセルすると明記されています ── つまり割り込みに応答しないタスクは shutdownNow でも止まりませんし、独自実装のExecutorを使う場合はそのキャンセル方法(割り込みを送るのか否か)を実装のドキュメントで確認する必要があります。1 公式ドキュメントが示す停止の定石が、次の2段階パターンです。1

/** 停止が完了したら 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: ExecutorService の2段階シャットダウン。「穏当に待つ → 割り込みで要求 → それでも終わらなければ異常として観測」という段階設計

なお、shutdownNow() が返す Runnable のキャンセルにはひとつ限界があります。返ってくるのは実行キューに入っていたオブジェクトで、素の submit ならそれが利用者へ渡した FutureTask 自身ですが、ExecutorCompletionService などのラッパー経由で投入したタスクではキュー内のラッパーであって利用者の Future とは別物です。その構成では上のキャンセルは利用者側の Future を完了させないため、投入時に自前で Future の一覧を保持しておき、シャットダウン時にそちらをキャンセルする(または降ろされたタスクを持ち主に返す)設計にしてください。

JDK 19以降の close()(AutoCloseable)は「shutdown して完了まで待つ」を try-with-resources で書ける形にしたもので、仮想スレッドの newVirtualThreadPerTaskExecutor と組み合わせた try (var executor = ...) が現代の基本形です。1 ただし close() は上の2段階パターンの代替ではありません。タイムアウトなしで完了を待つため、割り込みに応答しないタスクや終わらないタスクが1つでもあると、閉じようとしたスレッドが永遠にブロックします。スコープ内のタスクが有限で完走が保証される場面(その場で投げてその場で待つ使い方)に向いた道具で、アプリのシャットダウン経路のような「必ず有限時間で終えたい」場所には、期限付きの2段階パターンを使ってください。個別のタスクの取り消しは Future.cancel(true) が同じく割り込み経由で行います。

7. UIスレッド ── SwingのEDT

デスクトップアプリには、言語やフレームワークを問わず「UIはそれを管理するスレッドの専有物」という掟があります。Swingではその専有スレッドがイベントディスパッチスレッド(EDT)で、Swingコンポーネントのメソッドは原則スレッドセーフではなく、複数スレッドから触るとスレッド干渉やメモリ一貫性エラーを招きます。別スレッドからの画面更新は SwingUtilities.invokeLater でEDTに依頼し、逆にEDT上で長い処理をするとUIが固まるため、重い仕事は SwingWorker などでワーカースレッドに出します。7 JavaFXでも構図は同じで、UI更新は Platform.runLater でアプリケーションスレッドに依頼します。

8. 検証とデバッグ ── スレッドダンプという武器

競合バグはテストで見つかることを期待できません。通常のテストは「たまたま競合しなかった」実行を成功と数えるからです。備えは3層で考えます。

第一の防衛線は設計です。レビューでは「共有している可変データはどれか」「それぞれどのロックが守るか(5.1の対応表)」「ロックの取得順序は一意か」「InterruptedException を握りつぶしている catch がないか」「停止経路(shutdown/割り込み)は全タスクに届くか」を表で確認します。

第二に、スレッドダンプを使いこなします。Javaには「固まった今この瞬間のスレッドの状態」を取る標準の道具があり、jstack(または jcmd <pid> Thread.print)でスタックトレースを、-l オプションでロックの追加情報まで出力できます。9 注意点として、この従来形式のダンプはプラットフォームスレッド向けで、アプリの仮想スレッドは含まれません。仮想スレッドを使う構成(3章)でブロックしたリクエストを追うときは、仮想スレッドも含めてダンプできる jcmd <pid> Thread.dump_to_file -format=json <ファイル> を使ってください。2 ハングの調査は、ダンプを数秒おきに2〜3回取り、動いていないスレッドがどのロックを待ち、そのロックを誰が握っているかを突き合わせるのが基本手順です。tryLock(timeout) の時間切れをログに残しておけば(5.2)、ダンプを取るきっかけも自動化できます。

第三に、負荷で揺さぶります。コア数より多い並列度で長時間回す・処理順をランダム化する・人工遅延を挿入するストレステストは、開発機で競合の「当たり」を引きやすくする現実的な手段です。本番相当のデータ量とスレッド数での試験を、リリース前に一度は通してください。

9. これからのJava並行処理 ── 構造化並行性

最後に半歩先の話を。仮想スレッドを前提に「複数のサブタスクを1つの作業単位として扱い、失敗時の伝播とキャンセルを構造化する」Structured Concurrency(StructuredTaskScope)が開発中で、2026年8月時点ではまだプレビュー機能です。JDK 25の第5次プレビュー(JEP 505)で StructuredTaskScope.open() によるAPI形へ改められ、現行のJDK 26でも第6次プレビュー(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. InterruptedException を握りつぶしている catch が1つもないか
  9. ExecutorService の停止が期限付きの2段階パターンになっているか(close() を使う箇所は、タスクの完走が保証されるスコープに限られているか)
  10. Swing/JavaFXのUI更新がEDT/アプリケーションスレッドに集約されているか

Javaは並行処理の道具がもっとも整備された言語のひとつで、仮想スレッドの登場で「素直な同期コードをそのままスケールさせる」道も開けました。だからこそ、道具の役割分担 ── どれがスループットの道具で、どれが排他の道具で、停止の合図は何か ── を正しく押さえることが、Javaでのマルチスレッド設計の実質です。

関連記事

関連する相談領域

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

参考リンク

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

  2. OpenJDK, JEP 444: Virtual Threads. 仮想スレッドがJDK 21で正式機能となったこと、高スループットな並行アプリケーションの記述・保守・観測の労力を大幅に減らす軽量スレッドであること、「1リクエスト1スレッド」の素直な同期コードをそのままスケールさせるという設計思想、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集約的な処理には向かないこと、仮想スレッドは決してプールせずタスクごとに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, 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のメソッド呼び出し全体が原子的に実行され、キー不在時にマッピング関数がちょうど1回だけ呼ばれること、計算中は他スレッドの一部の更新操作がブロックされるため計算は短くシンプルに保つべきこと、マッピング関数内でこのマップを変更してはならず検出可能な再帰的更新は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が関連するサブタスク群を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の問題点(可変性、寿命管理、継承コスト)への解決策であることについて。 

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

ボリュームシャドウコピー(VSS)の仕組みと実務 ── 使用中ファイルのバックアップがなぜ取れるのか

使用中のファイルは共有違反でコピーできないのに、バックアップソフトはなぜ取れるのか。ボリュームシャドウコピー(VSS)のリクエスター・ライター・プロバイダーの役割分担、コピーオンライトの仕組み、vssadminの実務と差分領域の落とし穴を解説します。

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

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

よくある質問

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

仮想スレッドがあるなら、もうスレッドプール(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 ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る