ExecutorServiceとは、タスク(処理)を渡すとスレッドを用意して実行し、結果や終了を管理してくれるJavaの仕組みです。スレッドを自分で new Thread() する代わりに、決まった数のスレッドを使い回す「スレッドプール」や、Java 21 の仮想スレッドを、同じ書き方で使えます。

この記事では、Executors のファクトリ6種の使い分け表、execute と submit の違い(例外が表示されないケースの再現)、正しい終了処理の定型コード、ThreadPoolExecutor のパラメータと拒否ポリシー、I/O 待ち1,000件の処理時間比較を、OpenJDK 21.0.10 で実行した出力つきでまとめました。すべて単一ファイルで java ファイル名.java と実行できます。

実行環境:OpenJDK 21.0.10、CPU 2コアの Linux。日本語の出力には -Dstdout.encoding=UTF-8 を付けています。Java 25 での違いは公式ドキュメントで確認して注記しています(未実行)。

ExecutorServiceの早見表

やりたいこと 書き方 補足
決まった数のスレッドで実行 Executors.newFixedThreadPool(n) 基本の選択肢。キューは上限なし
I/O 待ちのタスクを大量に Executors.newVirtualThreadPerTaskExecutor() Java 21 以降。タスクごとに仮想スレッド
順番に1つずつ実行 Executors.newSingleThreadExecutor() 投入順に実行される
遅延・定期実行 Executors.newScheduledThreadPool(n) schedule、scheduleAtFixedRate
結果が要らないタスクを投げる ex.execute(runnable) 例外はスレッドの標準エラーに出る
結果を受け取る Future<T> f = ex.submit(callable) → f.get() 例外は get() で ExecutionException
時間制限つきで待つ f.get(500, TimeUnit.MILLISECONDS) 超えたら TimeoutException
全部まとめて実行して待つ ex.invokeAll(tasks) 全部の Future を返す
どれか1つの結果だけ欲しい ex.invokeAny(tasks) 最初に成功した結果。残りはキャンセル
終了する(Java 19 以降) try (var ex = ...) { ... } close() が shutdown+完了待ち
終了する(定型) shutdown() → awaitTermination() → shutdownNow() 本文の定型コード
細かく設定する new ThreadPoolExecutor(core, max, keepAlive, unit, queue, factory, handler) キュー上限と拒否ポリシー
受け付けられないとき RejectedExecutionException(AbortPolicy) CallerRunsPolicy などに変更可

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ExecutorServiceの使い方|スレッドプールと仮想スレッド」

ExecutorServiceの基本:3ステップで使う

作る → タスクを渡す → 終了する、の3ステップが基本形です。

import java.util.concurrent.*;

public class Basic {
    public static void main(String[] args) throws Exception {
        try (ExecutorService ex = Executors.newFixedThreadPool(3)) {       // 1. 作る
            Future<String> f = ex.submit(() -> "注文 A-100 を処理しました"); // 2. 渡す
            System.out.println(f.get());                                    //    結果を受け取る
        }                                                                   // 3. 終了(close)
    }
}
注文 A-100 を処理しました

スレッドプールは、空いているワーカースレッドがキューからタスクを取り出して実行する仕組みです。スレッドを毎回作らず使い回すので、作成コストを抑えつつ、同時に動くスレッド数を制限できます。

Executorsのファクトリ6種と使い分け

各ファクトリが作るスレッドを確認する

6種類のファクトリでそれぞれ50msのタスクを6件実行し、どんなスレッドが使われたかを表示しました。

import java.util.*;
import java.util.concurrent.*;
import java.util.function.Supplier;

public class FactoryDemo {
    static void run(String label, Supplier<ExecutorService> factory) throws Exception {
        Set<String> names = new ConcurrentSkipListSet<>();
        try (ExecutorService ex = factory.get()) {
            for (int i = 0; i < 6; i++) {
                ex.submit(() -> {
                    Thread t = Thread.currentThread();
                    names.add((t.isVirtual() ? "virtual#" + t.threadId() : t.getName()) + (t.isDaemon() ? "(daemon)" : ""));
                    try { Thread.sleep(50); } catch (InterruptedException e) { }
                });
            }
        } // close(): shutdown して全タスクの終了を待つ(Java 19+)
        System.out.printf("%-36s threads=%d %s%n", label, names.size(), names);
    }

    public static void main(String[] args) throws Exception {
        System.out.println("CPU=" + Runtime.getRuntime().availableProcessors());
        run("newFixedThreadPool(3)", () -> Executors.newFixedThreadPool(3));
        run("newCachedThreadPool()", Executors::newCachedThreadPool);
        run("newSingleThreadExecutor()", Executors::newSingleThreadExecutor);
        run("newScheduledThreadPool(2)", () -> Executors.newScheduledThreadPool(2));
        run("newWorkStealingPool()", Executors::newWorkStealingPool);
        run("newVirtualThreadPerTaskExecutor()", Executors::newVirtualThreadPerTaskExecutor);
    }
}
CPU=2
newFixedThreadPool(3)                threads=3 [pool-1-thread-1, pool-1-thread-2, pool-1-thread-3]
newCachedThreadPool()                threads=6 [pool-2-thread-1, pool-2-thread-2, pool-2-thread-3, pool-2-thread-4, pool-2-thread-5, pool-2-thread-6]
newSingleThreadExecutor()            threads=1 [pool-3-thread-1]
newScheduledThreadPool(2)            threads=2 [pool-4-thread-1, pool-4-thread-2]
newWorkStealingPool()                threads=2 [ForkJoinPool-1-worker-1(daemon), ForkJoinPool-1-worker-2(daemon)]
newVirtualThreadPerTaskExecutor()    threads=6 [virtual#33(daemon), virtual#35(daemon), virtual#36(daemon), virtual#37(daemon), virtual#38(daemon), virtual#39(daemon)]

newCachedThreadPool はタスクの数だけスレッドを増やし、newWorkStealingPool は CPU 数(ここでは2)に固定されます。また、WorkStealingPool と仮想スレッドはデーモンスレッド(JVM の終了を妨げないスレッド)である点が、後で説明する「終了しない問題」に関係します。

使い分け表

ファクトリ スレッド数 キュー 向いている処理 気を付けること
newFixedThreadPool(n) 常に n 本 上限なし(LinkedBlockingQueue) 同時実行数を絞りたい処理全般、CPU 計算 投入が速すぎるとキューが伸び続ける
newCachedThreadPool() 必要なだけ増える(待機60秒で消える) なし(SynchronousQueue) 短いタスクが散発的に来る 大量投入でスレッドが際限なく増える
newSingleThreadExecutor() 1本 上限なし 順序を守りたい処理(ログ書き出しなど) 1件が詰まると後続が全部待つ
newScheduledThreadPool(n) n 本 遅延キュー 定期実行・遅延実行 周期タスクの例外で以後停止(後述)
newWorkStealingPool() CPU 数(ForkJoinPool) スレッドごとの両端キュー タスクがさらに小タスクを作る CPU 計算 I/O 待ちには不向き(後述の比較で50秒)
newVirtualThreadPerTaskExecutor() タスクごとに仮想スレッド1本 なし I/O 待ちが長いタスクを大量に(Java 21+) 同時実行数を絞るなら Semaphore

execute と submit の違い(例外が消える再現)

execute は結果を返さず、例外はそのスレッドの標準エラーに表示されます。submit は Future を返し、例外は Future の中にしまわれるため、get() しないと表示されません。

import java.util.concurrent.*;

public class ExecuteVsSubmit {
    static void task(String kind) {
        System.out.println(kind + " 実行スレッド=" + Thread.currentThread().getName());
        throw new IllegalStateException(kind + " で在庫が不足しました");
    }

    public static void main(String[] args) throws Exception {
        ExecutorService ex = Executors.newFixedThreadPool(1);
        try {
            System.out.println("--- execute");
            ex.execute(() -> task("execute"));
            Thread.sleep(200);

            System.out.println("--- submit(get しない)");
            Future<?> f = ex.submit(() -> task("submit"));
            Thread.sleep(200);
            System.out.println("何も表示されない。isDone=" + f.isDone());

            System.out.println("--- submit して get する");
            try {
                f.get();
            } catch (ExecutionException e) {
                System.out.println("ExecutionException: " + e.getMessage());
                System.out.println("原因: " + e.getCause());
            }
        } finally {
            ex.shutdown();
        }
    }
}
--- execute
execute 実行スレッド=pool-1-thread-1
Exception in thread "pool-1-thread-1" java.lang.IllegalStateException: execute で在庫が不足しました
    at ExecuteVsSubmit.task(ExecuteVsSubmit.java:6)
    at ExecuteVsSubmit.lambda$main$0(ExecuteVsSubmit.java:13)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
    at java.base/java.lang.Thread.run(Thread.java:1583)
--- submit(get しない)
submit 実行スレッド=pool-1-thread-2
何も表示されない。isDone=true
--- submit して get する
ExecutionException: java.lang.IllegalStateException: submit で在庫が不足しました
原因: java.lang.IllegalStateException: submit で在庫が不足しました

読み取れるポイントは3つです。

  1. submit で投げた例外は、get() するまでどこにも表示されません。結果を見ないタスクを submit すると、失敗に気づけなくなります。
  2. execute で例外が起きたスレッド(thread-1)は終了し、プールが新しいスレッド(thread-2)を作り直しています。例外が頻発するとスレッドの作り直しが繰り返されます。
  3. get() の例外は ExecutionException に包まれるので、本当の原因は getCause() で取り出します。

結果を使わないタスクでも、タスクの中で try-catch してログに出すと、どちらの方法でも失敗を見逃しません。

Callable と Future:結果・タイムアウト・まとめて実行

Callable は「値を返し、チェック例外を投げられる」タスクで、submit すると結果を受け取る Future が返ります。

import java.util.*;
import java.util.concurrent.*;

public class FutureDemo {
    static long start = System.currentTimeMillis();
    static String t() { return String.format("[%4dms]", System.currentTimeMillis() - start); }

    // 倉庫ごとの在庫を問い合わせる(時間がかかる処理の代わり)
    static Callable<String> stock(String warehouse, long millis) {
        return () -> {
            Thread.sleep(millis);
            return warehouse + "=" + millis / 10 + "個";
        };
    }

    public static void main(String[] args) throws Exception {
        try (ExecutorService ex = Executors.newFixedThreadPool(3)) {
            // 1. Callable の戻り値を Future で受け取る
            Future<String> f = ex.submit(stock("東京", 300));
            System.out.println(t() + " submit 直後 isDone=" + f.isDone());
            String result = f.get(); // 終わるまで待つ
            System.out.println(t() + " get() = " + result);

            // 2. タイムアウト付き get
            Future<String> slow = ex.submit(stock("大阪", 2000));
            start = System.currentTimeMillis();
            try {
                slow.get(500, TimeUnit.MILLISECONDS);
            } catch (TimeoutException e) {
                System.out.println(t() + " TimeoutException → cancel(true)=" + slow.cancel(true)
                        + " isCancelled=" + slow.isCancelled());
            }

            // 3. invokeAll:全部終わるまで待ち、Future のリストを返す
            start = System.currentTimeMillis();
            List<Future<String>> all = ex.invokeAll(List.of(
                    stock("札幌", 300), stock("福岡", 600), stock("名古屋", 100)));
            for (Future<String> r : all) System.out.println(t() + " invokeAll " + r.get());

            // 4. invokeAny:最初に成功した1つの結果だけ返し、残りはキャンセル
            start = System.currentTimeMillis();
            String first = ex.invokeAny(List.of(
                    stock("札幌", 300), stock("福岡", 600), stock("名古屋", 100)));
            System.out.println(t() + " invokeAny " + first);
        }
    }
}
[   9ms] submit 直後 isDone=false
[ 344ms] get() = 東京=30個
[ 500ms] TimeoutException → cancel(true)=true isCancelled=true
[ 603ms] invokeAll 札幌=30個
[ 604ms] invokeAll 福岡=60個
[ 604ms] invokeAll 名古屋=10個
[ 100ms] invokeAny 名古屋=10個
メソッド 動き
get() 終わるまで待つ
get(timeout, unit) 時間内に終わらなければ TimeoutException。タスク自体は止まらないので cancel(true) で割り込む
invokeAll(tasks) 全部終わるまで待つ(一番遅い600msで全件そろう。結果は投入順)
invokeAny(tasks) 最初に成功した1つを返し、残りはキャンセル
invokeAll(tasks, timeout, unit) 時間切れのタスクはキャンセル(Future の state() が CANCELLED。Java 19+)

get() を引数なしで呼ぶと、タスクが終わらない限り永遠に待ちます。外部への問い合わせなど時間が読めない処理は、get(timeout, unit) か時間制限つき invokeAll を使うと安全です。複数の非同期処理を「つなげる」「合成する」場合は CompletableFuture の方が書きやすく、別記事「CompletableFuture入門」で詳しく扱っています。

正しい終了処理:shutdown と try-with-resources

shutdown しないと JVM が終わらない(再現)

newFixedThreadPool のスレッドは非デーモンスレッドなので、shutdown しないと main メソッドが終わっても JVM が終了しません。

import java.util.concurrent.*;

public class NoShutdown {
    public static void main(String[] args) throws Exception {
        String kind = args.length > 0 ? args[0] : "fixed";
        ExecutorService ex = switch (kind) {
            case "fixed" -> Executors.newFixedThreadPool(2);
            case "cached" -> Executors.newCachedThreadPool();
            case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
            default -> throw new IllegalArgumentException(kind);
        };
        Future<String> f = ex.submit(() -> "注文 A-100 を処理しました");
        System.out.println(kind + ": " + f.get());
        System.out.println("main メソッドの終わり(shutdown を呼んでいない)");
    }
}
$ timeout 10 java -cp . NoShutdown fixed
fixed: 注文 A-100 を処理しました
main メソッドの終わり(shutdown を呼んでいない)
exit=124 elapsed=10.0s      ← 10秒たっても終わらず timeout で強制終了

$ java -cp . NoShutdown cached
cached: 注文 A-100 を処理しました
main メソッドの終わり(shutdown を呼んでいない)
exit=0 elapsed=60.1s        ← 待機スレッドが60秒で消えるまで終わらない

$ java -cp . NoShutdown virtual
virtual: 注文 A-100 を処理しました
main メソッドの終わり(shutdown を呼んでいない)
exit=0                      ← 仮想スレッドはデーモンなのですぐ終わる

fixed はコアスレッド(非デーモン)が残り続け、cached は待機スレッドが keepAlive の60秒で消えるまで終わりませんでした。

仮想スレッドは JVM の終了を妨げませんが、逆に「実行中のタスクが途中で打ち切られる」ことになります。どのファクトリでも、終了処理を書くのが基本です。

Java 19 以降:try-with-resources で close する

Java 19 から ExecutorService は AutoCloseable になり、close() は「shutdown して、全タスクの完了を待つ」動きをします。

import java.util.concurrent.*;

public class CloseDemo {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
            for (int i = 1; i <= 5; i++) {
                int id = i;
                pool.submit(() -> {
                    try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
                    System.out.printf("[%4dms] タスク%d 完了%n", System.currentTimeMillis() - start, id);
                });
            }
        } // ここで close() → shutdown() して全タスクの完了を待つ
        System.out.printf("[%4dms] try ブロックを抜けた%n", System.currentTimeMillis() - start);
    }
}
[1004ms] タスク1 完了
[1004ms] タスク2 完了
[2015ms] タスク3 完了
[2016ms] タスク4 完了
[3017ms] タスク5 完了
[3018ms] try ブロックを抜けた

2スレッドで1秒のタスク5件なので約3秒、全部終わってからブロックを抜けました。close() は時間制限なしで待つため、終わらないタスクがあると抜けられません。待ち時間に上限が必要なら、次の定型コードを使います。

時間制限つきの定型コード(shutdown → awaitTermination → shutdownNow)

ExecutorService の Javadoc に載っている終了処理の形です。1.5秒かかるタスクを5件投入し、1秒で打ち切りました。

import java.util.List;
import java.util.concurrent.*;

public class ShutdownDemo {
    static long start = System.currentTimeMillis();
    static void log(String msg) {
        System.out.printf("[%4dms] %s%n", System.currentTimeMillis() - start, msg);
    }

    // ExecutorService の Javadoc にある定型の終了処理
    static void shutdownAndAwaitTermination(ExecutorService pool, long timeoutSec) {
        pool.shutdown(); // 新しいタスクの受付を止める(実行中・待機中のタスクは続ける)
        try {
            if (!pool.awaitTermination(timeoutSec, TimeUnit.SECONDS)) {
                List<Runnable> notStarted = pool.shutdownNow(); // 実行中のタスクに割り込み
                log("時間切れ → shutdownNow。未開始のタスク=" + notStarted.size() + "件");
                if (!pool.awaitTermination(timeoutSec, TimeUnit.SECONDS)) {
                    log("プールが終了しませんでした");
                }
            }
        } catch (InterruptedException e) {
            pool.shutdownNow();
            Thread.currentThread().interrupt(); // 割り込み状態を戻す
        }
    }

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        for (int i = 1; i <= 5; i++) {
            int id = i;
            pool.submit(() -> {
                log("タスク" + id + " 開始");
                try {
                    Thread.sleep(1500);
                    log("タスク" + id + " 完了");
                } catch (InterruptedException e) {
                    log("タスク" + id + " 割り込みで中断");
                }
            });
        }
        shutdownAndAwaitTermination(pool, 1);
        log("isShutdown=" + pool.isShutdown() + " isTerminated=" + pool.isTerminated());
        try {
            pool.submit(() -> log("終了後のタスク"));
        } catch (RejectedExecutionException e) {
            log("shutdown 後の submit → RejectedExecutionException");
        }
    }
}
[   4ms] タスク1 開始
[  10ms] タスク2 開始
[1005ms] タスク2 割り込みで中断
[1006ms] タスク1 割り込みで中断
[1007ms] 時間切れ → shutdownNow。未開始のタスク=3件
[1013ms] isShutdown=true isTerminated=true
[1013ms] shutdown 後の submit → RejectedExecutionException
メソッド 新規受付 待機中のタスク 実行中のタスク 戻り値
shutdown() 止める 実行する 続ける なし(すぐ戻る)
awaitTermination(t, unit) 終わるまで最大 t 待つ 終わったら true
shutdownNow() 止める 実行しない(リストで返す) 割り込み(interrupt)を送る 未開始タスクのリスト
close()(Java 19+) 止める 実行する 終わるまで待つ なし

shutdownNow は「割り込みを送る」だけで、タスクを強制停止するわけではありません。タスクが Thread.sleep や BlockingQueue.take のように割り込みに反応する処理をしていれば止まりますが、計算ループの中では Thread.currentThread().isInterrupted() を自分で確認する必要があります。InterruptedException を握りつぶさず、終了するか割り込み状態を戻すのが大切です。

ThreadPoolExecutor のパラメータと RejectedExecutionException

パラメータ表

Executors のファクトリは、内部で ThreadPoolExecutor を設定済みで作っています。キューの上限や拒否時の動作を決めたいときは、ThreadPoolExecutor を直接作ります。

パラメータ 意味 例
corePoolSize 常に維持するスレッド数 2
maximumPoolSize キューが満杯になったときだけ増やせる上限 4
keepAliveTime, unit core を超えたスレッドが、待機してから消えるまでの時間 30秒
workQueue 実行待ちタスクのキュー ArrayBlockingQueue<>(2)(上限あり)
threadFactory スレッドの作り方(名前・デーモン設定) "order-worker-" + 連番
handler 受け付けられないときの動作(RejectedExecutionHandler) AbortPolicy(既定)
flowchart TD
    S[タスク投入] --> C{実行中 < core ?}
    C -- はい --> N1[新しいスレッドで実行]
    C -- いいえ --> Q{キューに空きあり?}
    Q -- はい --> W[キューで待つ]
    Q -- いいえ --> M{スレッド数 < max ?}
    M -- はい --> N2[スレッドを追加して実行]
    M -- いいえ --> R[拒否ポリシーを実行]

拒否ポリシー4種を実行して比べる

core=2、max=4、キュー上限2件のプールに、300ms のタスクを8件続けて投入しました。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class PoolDemo {
    static ThreadPoolExecutor newPool(BlockingQueue<Runnable> queue, RejectedExecutionHandler handler) {
        AtomicInteger seq = new AtomicInteger();
        ThreadFactory tf = r -> {
            Thread t = new Thread(r, "order-worker-" + seq.incrementAndGet());
            t.setDaemon(false);
            return t;
        };
        return new ThreadPoolExecutor(
                2,                      // corePoolSize:常に維持するスレッド数
                4,                      // maximumPoolSize:キューが満杯のときに増やせる上限
                30, TimeUnit.SECONDS,   // keepAliveTime:core を超えた分が待機で消えるまでの時間
                queue,                  // 実行待ちタスクのキュー
                tf,                     // スレッドの作り方(名前を付ける)
                handler);               // 受け付けられないときの動作
    }

    static void submitTasks(String label, ThreadPoolExecutor pool, int n) throws Exception {
        System.out.println("=== " + label);
        List<String> done = Collections.synchronizedList(new ArrayList<>());
        for (int i = 1; i <= n; i++) {
            int id = i;
            try {
                pool.execute(() -> {
                    try { Thread.sleep(300); } catch (InterruptedException e) { }
                    done.add(id + "@" + Thread.currentThread().getName());
                });
                System.out.printf("タスク%d 投入 pool=%d active=%d queue=%d%n",
                        id, pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size());
            } catch (RejectedExecutionException e) {
                System.out.println("タスク" + id + " 拒否: " + e.getMessage());
            }
        }
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);
        System.out.println("完了 " + done.size() + "件 " + done);
    }

    public static void main(String[] args) throws Exception {
        submitTasks("AbortPolicy(既定)キュー2件", newPool(new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.AbortPolicy()), 8);
        submitTasks("CallerRunsPolicy", newPool(new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.CallerRunsPolicy()), 8);
        submitTasks("DiscardPolicy", newPool(new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.DiscardPolicy()), 8);
        submitTasks("DiscardOldestPolicy", newPool(new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.DiscardOldestPolicy()), 8);
        submitTasks("上限なしキュー(LinkedBlockingQueue)", newPool(new LinkedBlockingQueue<>(), new ThreadPoolExecutor.AbortPolicy()), 8);
    }
}

実行結果(CallerRuns 以下の3つは完了行だけ抜粋):

=== AbortPolicy(既定)キュー2件
タスク1 投入 pool=1 active=1 queue=0
タスク2 投入 pool=2 active=2 queue=0
タスク3 投入 pool=2 active=2 queue=1
タスク4 投入 pool=2 active=2 queue=2
タスク5 投入 pool=3 active=3 queue=2
タスク6 投入 pool=4 active=4 queue=2
タスク7 拒否: Task PoolDemo$$Lambda/0x00007fcb9c158c30@49139829 rejected from java.util.concurrent.ThreadPoolExecutor@35cabb2a[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
タスク8 拒否: Task PoolDemo$$Lambda/0x00007fcb9c158c30@55183b20 rejected from java.util.concurrent.ThreadPoolExecutor@35cabb2a[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
完了 6件 [1@order-worker-1, 2@order-worker-2, 5@order-worker-3, 6@order-worker-4, 3@order-worker-1, 4@order-worker-2]
=== CallerRunsPolicy
完了 8件 [1@order-worker-1, 2@order-worker-2, 5@order-worker-3, 6@order-worker-4, 7@main, 3@order-worker-1, 4@order-worker-2, 8@order-worker-3]
=== DiscardPolicy
完了 6件 [1@order-worker-1, 2@order-worker-2, 5@order-worker-3, 6@order-worker-4, 3@order-worker-1, 4@order-worker-2]
=== DiscardOldestPolicy
完了 6件 [1@order-worker-1, 2@order-worker-2, 5@order-worker-3, 6@order-worker-4, 7@order-worker-1, 8@order-worker-2]
=== 上限なしキュー(LinkedBlockingQueue)
タスク1 投入 pool=1 active=1 queue=0
タスク2 投入 pool=2 active=2 queue=0
タスク3 投入 pool=2 active=2 queue=1
...
タスク8 投入 pool=2 active=2 queue=6
完了 8件 [1@order-worker-1, 2@order-worker-2, 3@order-worker-1, 4@order-worker-2, 5@order-worker-1, 6@order-worker-2, 7@order-worker-1, 8@order-worker-2]

結果から読み取れることです。

  1. スレッドは「core まで増える → キューに積む → キューが満杯になってから max まで増える」の順でした。タスク3・4はキューで待ち、タスク5・6で初めて3本目・4本目が作られています。
  2. 後から作られたスレッドは、キューの先頭ではなく今投入されたタスク(5・6)を先に実行します。投入順に終わるとは限りません。
  3. 上限なしキューでは、スレッドが core の2本から増えませんでした。キューが満杯にならないので maximumPoolSize は使われません。newFixedThreadPool はこの形で、投入が処理より速いとキューが伸び続けます(メモリ不足の原因については「Java OutOfMemoryErrorの原因と対処」記事を参照)。
拒否ポリシー 動き 実行結果 向いている場面
AbortPolicy(既定) RejectedExecutionException を投げる 7・8が拒否、完了6件 呼び出し元で失敗を扱いたい
CallerRunsPolicy 投入したスレッド自身で実行 7が main で実行、完了8件 取りこぼしたくない。投入側が自然に減速する
DiscardPolicy 何も言わずに捨てる 7・8が消えた、完了6件 捨ててよい処理(統計の間引きなど)
DiscardOldestPolicy キューの一番古いタスクを捨てて入れる 3・4が消え7・8が実行 新しい情報ほど大事な処理

DiscardPolicy と DiscardOldestPolicy は、タスクが消えても例外もログも出ません。捨てたことを把握したい場合は、RejectedExecutionHandler を自作して((task, executor) -> { ログ出力; throw new RejectedExecutionException(...); })ログを出します。

I/O待ちタスク1,000件:スレッドプールと仮想スレッドの比較

100ms 待つタスク(DB や外部 API の待ちの代わり)を1,000件投入し、全件終わるまでの時間を比べました。

import java.lang.management.ManagementFactory;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Supplier;

public class IoBench {
    static final int TASKS = 1000;
    static final int WAIT_MS = 100; // DB や外部 API の待ち時間の代わり

    static void run(String label, Supplier<ExecutorService> factory) throws Exception {
        Set<Thread> used = ConcurrentHashMap.newKeySet();
        long t0 = System.nanoTime();
        try (ExecutorService ex = factory.get()) {
            List<Future<Integer>> fs = new ArrayList<>();
            for (int i = 0; i < TASKS; i++) {
                int id = i;
                fs.add(ex.submit(() -> {
                    used.add(Thread.currentThread());
                    Thread.sleep(WAIT_MS);
                    return id;
                }));
            }
            long sum = 0;
            for (Future<Integer> f : fs) sum += f.get();
            if (sum != (long) TASKS * (TASKS - 1) / 2) throw new AssertionError();
        }
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.printf("%-34s %6d ms  使ったスレッド=%4d  プラットフォームスレッド最大=%d%n",
                label, ms, used.size(), ManagementFactory.getThreadMXBean().getPeakThreadCount());
        ManagementFactory.getThreadMXBean().resetPeakThreadCount();
    }

    public static void main(String[] args) throws Exception {
        System.out.println("タスク=" + TASKS + "件, 1件あたり待ち=" + WAIT_MS + "ms, CPU=" + Runtime.getRuntime().availableProcessors());
        run("newFixedThreadPool(10)", () -> Executors.newFixedThreadPool(10));
        run("newFixedThreadPool(100)", () -> Executors.newFixedThreadPool(100));
        run("newCachedThreadPool()", Executors::newCachedThreadPool);
        run("newWorkStealingPool()", Executors::newWorkStealingPool);
        run("newVirtualThreadPerTaskExecutor()", Executors::newVirtualThreadPerTaskExecutor);
    }
}
タスク=1000件, 1件あたり待ち=100ms, CPU=2
newFixedThreadPool(10)              10036 ms  使ったスレッド=  10  プラットフォームスレッド最大=16
newFixedThreadPool(100)              1026 ms  使ったスレッド= 100  プラットフォームスレッド最大=106
newCachedThreadPool()                 309 ms  使ったスレッド= 986  プラットフォームスレッド最大=992
newWorkStealingPool()               50179 ms  使ったスレッド=   2  プラットフォームスレッド最大=8
newVirtualThreadPerTaskExecutor()     127 ms  使ったスレッド=1000  プラットフォームスレッド最大=9

2回目の実行でも、Fixed(10) 10,033ms、Fixed(100) 1,054ms、Cached 312ms とほぼ同じ傾向でした。

Fixed(10) は10件ずつしか待てないため約10秒(1000÷10×0.1秒)、Cached は速いものの OS スレッドを約1,000本作り、WorkStealing は CPU 数(2)しか並列にならず約50秒かかりました。

I/O 待ちのタスクでは、仮想スレッドが最も速く、しかも OS スレッドをほとんど増やしませんでした。仮想スレッドは待っている間に OS スレッド(キャリアスレッド)を手放すためです。一方、CPU を使い続ける計算では仮想スレッドでも速くならないので、newFixedThreadPool(CPU数) を使います。10万件規模の実測や pinning(キャリアスレッドへの固定)の詳しい話は「Java 仮想スレッド入門」記事で扱っています。

仮想スレッドで同時に DB へ1,000件アクセスすると、接続プールの上限を超えることがあります。同時実行数を絞りたいときは、プールの大きさではなく Semaphore(new Semaphore(20) を作り、タスクの中で acquire()/finally で release())で制御します。

ScheduledExecutorService:定期実行で例外が出ると止まる

scheduleAtFixedRate/scheduleWithFixedDelay のタスクで例外が起きると、以後の実行は黙って止まります。

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class ScheduledDemo {
    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();
        AtomicInteger a = new AtomicInteger(), b = new AtomicInteger();
        try (ScheduledExecutorService ses = Executors.newScheduledThreadPool(1)) {
            // 1回だけ、500ms 後に実行
            ses.schedule(() -> System.out.printf("[%4dms] 1回だけの通知%n", System.currentTimeMillis() - start),
                    500, TimeUnit.MILLISECONDS);

            // 200ms ごとに実行。3回目で例外 → 以後は実行されない
            ScheduledFuture<?> sf = ses.scheduleAtFixedRate(() -> {
                int n = a.incrementAndGet();
                System.out.printf("[%4dms] 在庫同期 %d回目%n", System.currentTimeMillis() - start, n);
                if (n == 3) throw new IllegalStateException("同期先が応答しません");
            }, 0, 200, TimeUnit.MILLISECONDS);

            // try-catch で囲むと例外が出ても続く
            ScheduledFuture<?> safe = ses.scheduleWithFixedDelay(() -> {
                try {
                    int n = b.incrementAndGet();
                    if (n == 3) throw new IllegalStateException("一時的な失敗");
                } catch (RuntimeException e) {
                    System.out.printf("[%4dms] 例外を記録して続行: %s%n", System.currentTimeMillis() - start, e.getMessage());
                }
            }, 0, 200, TimeUnit.MILLISECONDS);

            Thread.sleep(1200);
            System.out.println("scheduleAtFixedRate: isDone=" + sf.isDone() + " 実行回数=" + a.get());
            try { sf.get(); } catch (ExecutionException e) { System.out.println("  原因: " + e.getCause()); }
            System.out.println("try-catch あり     : isDone=" + safe.isDone() + " 実行回数=" + b.get());
            ses.shutdown(); // 周期タスクを止める(close() も内部で shutdown を呼ぶ)
        }
    }
}
[   6ms] 在庫同期 1回目
[ 206ms] 在庫同期 2回目
[ 406ms] 在庫同期 3回目
[ 416ms] 例外を記録して続行: 一時的な失敗
[ 504ms] 1回だけの通知
scheduleAtFixedRate: isDone=true 実行回数=3
  原因: java.lang.IllegalStateException: 同期先が応答しません
try-catch あり     : isDone=false 実行回数=6

例外を投げた周期タスクは3回で止まり、エラー表示もありませんでした。周期タスクの中身は必ず try-catch で囲み、ログを出して続ける形にします。

メソッド 間隔の数え方
schedule(task, delay, unit) 1回だけ、delay 後に実行
scheduleAtFixedRate(task, initial, period, unit) 開始時刻から period ごと(処理が長いと間を詰めて実行)
scheduleWithFixedDelay(task, initial, delay, unit) 前回の終了から delay あけて実行

Java 25 での違い(公式で確認)

ExecutorService と Executors の使い方は、Java 21 と Java 25 で変わりません。手元には Java 21 しかないため、以下は OpenJDK の JEP で確認した内容です(未実行)。

項目 Java 21 Java 25
仮想スレッドと synchronized synchronized の中で待つとキャリアスレッドに固定(pinning) Java 24 の JEP 491 で、synchronized 内の待ちでも固定されなくなった
構造化並行性(StructuredTaskScope) プレビュー まだプレビュー(JEP 505、第5プレビュー)
ScopedValue(スレッド間で値を渡す) プレビュー 正式機能(JEP 506)

よくある質問

Q. スレッドプールのサイズはいくつにすればよいですか? A. CPU を使う計算なら CPU 数、I/O 待ちが多いなら「CPU 数 ×(1 + 待ち時間 ÷ 計算時間)」が目安です。Java 21 以降で I/O 待ちが中心なら、プールサイズを考える代わりに仮想スレッドを使い、同時アクセス数だけ Semaphore で制限する方法がわかりやすくなります。

Q. submit したタスクの例外が出ません。なぜですか? A. submit の例外は Future の中に保存され、get() を呼ぶまで表示されません。結果を使わない場合でも get() で確認するか、タスクの中で try-catch してログに出してください。

まとめ

  • ExecutorService は「作る → submit → 終了」の3ステップ。Java 19 以降は try-with-resources の close() で終了と完了待ちができる
  • submit の例外は get() するまで表示されない。execute は表示されるが、ワーカースレッドが作り直される
  • get() は時間制限つきで。invokeAll は全件、invokeAny は最速の1件を返す
  • newFixedThreadPool は shutdown しないと JVM が終わらない。時間制限が必要なら shutdown → awaitTermination → shutdownNow の定型コード
  • ThreadPoolExecutor は core → キュー → max の順に増える。上限なしキューでは max は使われない
  • 拒否ポリシーは4種。取りこぼしたくないなら CallerRunsPolicy、捨てる場合はログを出す
  • I/O 待ち1,000件は仮想スレッドが127msで最速、OS スレッドは9本のままだった。同時実行数は Semaphore で絞る

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ExecutorServiceの使い方|スレッドプールと仮想スレッド」

株式会社RJCでは、ExecutorServiceや仮想スレッドを使った並行処理を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?