- JAVA
Java ExecutorServiceの使い方|スレッドプールと仮想スレッド
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つです。
- submit で投げた例外は、
get()するまでどこにも表示されません。結果を見ないタスクを submit すると、失敗に気づけなくなります。 - execute で例外が起きたスレッド(thread-1)は終了し、プールが新しいスレッド(thread-2)を作り直しています。例外が頻発するとスレッドの作り直しが繰り返されます。
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]
結果から読み取れることです。
- スレッドは「core まで増える → キューに積む → キューが満杯になってから max まで増える」の順でした。タスク3・4はキューで待ち、タスク5・6で初めて3本目・4本目が作られています。
- 後から作られたスレッドは、キューの先頭ではなく今投入されたタスク(5・6)を先に実行します。投入順に終わるとは限りません。
- 上限なしキューでは、スレッドが 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期連続の黒字決算。