概要

Thread を直接 new してタスクごとに生成・破棄すると、スレッド生成のオーバーヘッドが無視できなくなり、同時実行数の制御も困難です。ExecutorService はスレッドプールを管理し、タスクの投入と結果の取得を分離する仕組みを提供します。固定サイズのプール、キャッシュプール、シングルスレッドプールなど用途別のファクトリが用意されており、タスクの結果は Future を通じてタイムアウト付きで取得できます。この記事では、各プールの特性と使い分け、shutdown の正しいタイミング、Future.get のタイムアウト制御までを、業務で頻出するパターンに絞って整理します。

使いどころ

帳票生成バッチでスレッドプールサイズを固定し、DB コネクション数の上限を超えないよう並列度を制御する

外部 API への並列呼び出しを ExecutorService に投入し、Future.get でタイムアウト付きで結果を集約する

定期実行バッチを ScheduledExecutorService で管理し、cron の代わりにアプリ内でスケジュールを制御する

コード例

ExecutorServiceDemo.java
import java.util.ArrayList;
import java.util.concurrent.*;

public class ExecutorServiceDemo {

    public static void main(String[] args) throws Exception {
        System.out.println("=== 固定スレッドプール ===");
        var executor = Executors.newFixedThreadPool(3);

        try {
            var futures = new ArrayList<Future<String>>();
            for (var i = 1; i <= 5; i++) {
                var taskName = "タスク-" + i;
                var future = executor.submit(() -> {
                    Thread.sleep(100);
                    return "完了: " + taskName + " by "
                        + Thread.currentThread().getName();
                });
                futures.add(future);
            }

            for (var future : futures) {
                System.out.println(future.get());
            }
        } finally {
            executor.shutdown();
            executor.awaitTermination(5, TimeUnit.SECONDS);
        }

        System.out.println("\n=== タイムアウト付き Future.get() ===");
        var timeoutExecutor = Executors.newSingleThreadExecutor();
        try {
            var future = timeoutExecutor.submit(() -> {
                Thread.sleep(500);
                return "重い処理の結果";
            });
            try {
                var result = future.get(200, TimeUnit.MILLISECONDS);
                System.out.println(result);
            } catch (TimeoutException e) {
                System.out.println("タイムアウト → キャンセル");
                future.cancel(true);
            }
        } finally {
            timeoutExecutor.shutdown();
        }
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

ラムダ式 + var + record で簡潔に記述可能。record でタスク情報を保持する設計パターンが使える。

Java 17
// Java 17: ラムダ式 + record で簡潔に
record Task(String name, long sleepMs) {}
var task = new Task("タスク-1", 100);
var future = executor.submit(() -> {
    Thread.sleep(task.sleepMs());
    return "完了: " + task.name();
});

Library Comparison

ExecutorService(標準 API)スレッドプール管理と Future によるタスク結果取得を標準 API だけで完結させたいとき。CompletableFuture と比べて非同期チェーンの記述力が弱い。複数タスクの合成が冗長になる。
CompletableFuture非同期タスクのチェーン(thenApply, thenCombine 等)で複雑な非同期フローを構築したいとき。チェーンが深くなるとデバッグが困難になる。例外ハンドリングの見落としにも注意が必要。
Spring TaskExecutorSpring 環境で DI 経由のスレッドプール管理を行いたいとき。設定の外部化が容易。フレームワーク依存。Spring 以外の環境では使えず、内部的には ExecutorService のラッパー。

注意点

shutdown() を呼ばないとスレッドプールが生き残り、アプリケーションが終了しない。try-finally で必ず shutdown する

newCachedThreadPool はタスク数に応じてスレッドを無制限に作成するため、大量タスクを投入すると OutOfMemoryError の原因になる

Future.get() にタイムアウトを設定しないと、タスクが完了しない限りメインスレッドが永久にブロックされる

ExecutorService を static フィールドに持つ場合、アプリ終了時の shutdown 呼び出しを忘れやすい。ShutdownHook の登録を検討する

submit() の戻り値(Future)を握りつぶすと、タスク内の例外が検知されない。少なくとも Future.get() で例外の有無を確認する

FAQ

FixedThreadPool と CachedThreadPool はどう使い分けますか。

並列度の上限を制御したいときは FixedThreadPool、短時間のタスクが散発的に発生する場面では CachedThreadPool が向いています。CachedThreadPool は上限なしにスレッドを作るため、タスク量が読めない場合は避けてください。

shutdown() と shutdownNow() の違いは何ですか。

shutdown() は新規タスクの受付を停止し、投入済みタスクの完了を待ちます。shutdownNow() は実行中タスクへの interrupt を試み、未実行タスクのリストを返します。通常は shutdown + awaitTermination を使います。

Java 21 の仮想スレッドプールは既存コードをそのまま置き換えられますか。

API は互換性がありますが、synchronized ブロック内でのブロッキングが仮想スレッドのキャリアスレッドをピン留めする問題があります。性能テストで確認してから移行してください。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます