【Java】JavaのExecutorServiceでスレッドプールを使いこなす

Java

こんにちは、かつコーチです。
今回はExecutorServiceによるスレッドプール管理を扱います。
Threadを都度newする実装から一歩進み、実務水準のタスク管理に踏み込みたい方向けの内容です。

Threadを直接newする実装の問題点

スレッド生成コストとリソース枯渇

スレッドの生成・破棄にはOSレベルのコストがかかります。
リクエストのたびにnew Thread().start()していると、大量アクセス時にスレッドが際限なく増え、メモリ枯渇やコンテキストスイッチの増加でシステム全体が不安定になります。

// ❌ Before:リクエストごとにThreadを生成
for (int i = 0; i < 10000; i++) {
    new Thread(() -> processRequest()).start();
}

10000本のスレッドを一気に立ち上げるコードを検証環境で試したところ、OutOfMemoryError: unable to create native threadが発生しました。
OSが確保できるスレッド数には上限があり、無制限にスレッドを作る設計は本番運用に耐えません。

✅ After:ExecutorServiceでスレッド数を制御する

ExecutorServiceは、あらかじめ用意した一定数のスレッド(スレッドプール)にタスクを流し込む仕組みです。
スレッド数の上限を管理できるため、リソース枯渇を防げます。

ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10000; i++) {
    executor.execute(() -> processRequest());
}
executor.shutdown();

10000個のタスクがあっても、実際に動くスレッドは最大10本に制御されます。
空いたスレッドから順にタスクを処理していく仕組みです。

スレッドプールの種類と使い分け

主なファクトリメソッド

Executorsクラスは代表的なスレッドプールを簡単に生成できるファクトリメソッドを提供しています。

メソッド特徴向いている用途
newFixedThreadPool(n)固定数のスレッドを維持同時実行数を制御したいバッチ処理
newCachedThreadPool()必要に応じてスレッドを増減、未使用は60秒で破棄短時間で終わる大量の非同期タスク
newSingleThreadExecutor()スレッド1本で順番に実行タスクの実行順序を保証したい処理
newScheduledThreadPool(n)遅延実行・定期実行に対応バッチのスケジューリング
newVirtualThreadPerTaskExecutor()(Java 21〜)タスクごとに軽量な仮想スレッドを生成I/O待ちが多い大量同時処理

判断軸:どのプールを選ぶか

  • 同時実行数に上限を設けたい・リソースを厳密に制御したい → newFixedThreadPool
  • 短命なタスクが大量に発生し、待ち時間を減らしたい → newCachedThreadPool
  • 実行順序を保証したい・排他制御を簡略化したい → newSingleThreadExecutor
  • I/O待ちの多い処理を数万〜数十万件並行させたい → newVirtualThreadPerTaskExecutor(Java 21で正式導入された仮想スレッドを使う軽量な並行処理の仕組み)

Java 21以降は仮想スレッドの登場により、I/Oバウンドな処理ではnewCachedThreadPoolよりも仮想スレッドの利用が推奨される流れになっています。
ただしCPUバウンドな計算処理では、物理CPUコア数に応じたnewFixedThreadPoolの方が適しています。

タスクの投入と結果の受け取り

submitとFutureで結果をまとめる

ExecutorService executor = Executors.newFixedThreadPool(4);
List<Future<Integer>> futures = new ArrayList<>();

for (int i = 1; i <= 4; i++) {
    int taskId = i;
    futures.add(executor.submit(() -> {
        Thread.sleep(500);
        return taskId * 100;
    }));
}

int total = 0;
for (Future<Integer> future : futures) {
    total += future.get(); // 各タスクの完了を待って合計
}
System.out.println("合計: " + total);

executor.shutdown();

invokeAllでまとめて実行する

複数のCallableをまとめて実行し、全件の完了を待ちたい場合はinvokeAllが便利です。

List<Callable<Integer>> tasks = List.of(
    () -> 10 * 10,
    () -> 20 * 20,
    () -> 30 * 30
);

List<Future<Integer>> results = executor.invokeAll(tasks);
for (Future<Integer> result : results) {
    System.out.println(result.get());
}

shutdownの正しい呼び方

つまずきやすいポイント:shutdownを忘れる

ExecutorServiceのスレッドはデフォルトではデーモンスレッドではないため、shutdown()を呼び忘れるとJVMプロセスが終了せず残り続けます。
検証中に「プログラムが終わったはずなのにプロセスが残っている」という現象に遭遇し、shutdown()の呼び忘れが原因だったことがありました。

// ✅ After:try-finallyやshutdownNowで確実に後始末する
ExecutorService executor = Executors.newFixedThreadPool(4);
try {
    executor.submit(() -> processRequest());
} finally {
    executor.shutdown();
    if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
        executor.shutdownNow(); // タイムアウトしたら強制終了
    }
}

shutdown()は新規タスクの受付を停止し、実行中・待機中のタスクは完了させます。
shutdownNow()は実行中のタスクに割り込みをかけ、即座に停止を試みる点が異なります。

まとめ

この記事のポイント

  • Threadを都度newする実装はスレッド数が際限なく増え、リソース枯渇のリスクがある
  • ExecutorServiceはスレッドプールでタスクを管理し、同時実行数を制御できる
  • 用途に応じてnewFixedThreadPoolnewCachedThreadPoolnewVirtualThreadPerTaskExecutor(Java 21〜)などを使い分ける
  • shutdown()の呼び忘れはJVMがプロセス終了できない原因になる

次に読むべき記事

  • RunnableCallableの違いは「Runnable・Callableの違い」の記事へ
  • 複数スレッドから共有データを操作する際の注意点は「synchronizedと排他制御」の記事へ

タグ: Java, 上級者向け, 並行処理

タイトルとURLをコピーしました