TL;DR
ThreadPoolExecutorに対して、すでにシャットダウン済み、またはキューが満杯でスレッドの空きがない状態でタスクを送信しています。どの修正を適用するかは、実際に何が起きているかによります:
- エグゼキュータのシャットダウン:
shutdown()またはshutdownNow()を呼び出した後は、タスクの送信を停止してください。 - キューが満杯:キューの容量を増やす、最大スレッド数を増やす、または
CallerRunsPolicy拒否ハンドラに切り替えてください。
この例外が発生する原因
完全なスタックトレースは次のようになります:
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask rejected from java.util.concurrent.ThreadPoolExecutor[Running, pool size = 10, active threads = 10, queued tasks = 100, completed tasks = 2048]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:833)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1365)
このエラーは2つのシナリオで発生します:
- 原因A — エグゼキュータがシャットダウン済み:
executor.shutdown()またはexecutor.shutdownNow()が呼び出された後も、タスクが送信され続けている。シングルトンエグゼキュータでアプリのシャットダウンフック実行中によく発生します — プロデューサーがまだ動作しているうちにフックが起動してしまうためです。 - 原因B — キューが飽和状態:すべてのスレッドがビジー状態で、かつブロッキングキューが上限に達している。デフォルトの
AbortPolicyでは、次に投入されたタスクは即座にRejectedExecutionExceptionを発生させます。リトライも待機もありません。
どちらのケースか診断する
例外メッセージをよく読んでください。状態フィールドにShutting downまたはTerminatedがあれば原因Aです。Runningでqueued tasksが上限に達していれば原因Bです。
まだ判断できない場合は、送信呼び出しの前に以下を追加してください:
// 送信前にエグゼキュータの状態をログ出力
ThreadPoolExecutor tpe = (ThreadPoolExecutor) executor;
System.out.println("Active: " + tpe.getActiveCount());
System.out.println("Queue size: " + tpe.getQueue().size());
System.out.println("Is shutdown: " + tpe.isShutdown());
修正A:エグゼキュータがシャットダウン済みの場合
すべての送信をisShutdown()チェックでガードするか、プロデューサーが先に停止するようにシャットダウンの順序を再構成してください。
// 悪い例 — シャットダウン後に送信
executor.shutdown();
executor.submit(someTask); // RejectedExecutionExceptionをスロー
// 良い例 — 送信前にチェック
if (!executor.isShutdown()) {
executor.submit(someTask);
}
共有シングルトン(例:Springの@Beanエグゼキュータ)は特別な注意が必要です。@PreDestroyフックは、プロデューサーと競合せず、すべてのプロデューサーが停止した後に実行される必要があります。30秒のグレースピリオドは合理的な出発点です:
@PreDestroy
public void destroy() {
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
修正B:キューが満杯 — 拒否ポリシーを選択する
Javaには4つの組み込みRejectedExecutionHandler実装が含まれています。プールが追いつかない場合にアプリがどう振る舞うべきかに基づいて選択してください:
オプション1:CallerRunsPolicy(最も一般的な修正)
タスクを送信したスレッドが、渡す代わりに直接そのタスクを実行します。これによりバックプレッシャーが自然に生まれます — ワーカーが過負荷になるとプロデューサーが減速します。バッチジョブや処理パイプラインには最適なデフォルトです。
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // コアスレッド数
10, // 最大スレッド数
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
new ThreadPoolExecutor.CallerRunsPolicy() // (500),
new ThreadPoolExecutor.DiscardOldestPolicy()
);
オプション3:DiscardPolicy
受信タスクを静かに破棄します。作業を失うことが本当に許容できる場合にのみ使用してください — 例えば、ファイアアンドフォーゲットの分析イベントなどです。
new ThreadPoolExecutor(
4, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
new ThreadPoolExecutor.DiscardPolicy()
);
オプション4:ログ付きカスタムポリシー
実際のトラフィックを扱うシステムでは、何をするか決める前に拒否をログに記録してください。サイレントな破棄は、深夜3時に何か問題が起きたときのデバッグの悪夢になります。
RejectedExecutionHandler loggingPolicy = (task, executor) -> {
log.warn("Task rejected: {} | Pool: active={}, queue={}",
task,
executor.getActiveCount(),
executor.getQueue().size());
// 必要に応じて再試行するかデッドレターキューに送信
};
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
loggingPolicy
);
修正C:プールまたはキューのサイズを変更する
場合によっては、プールに余裕を持たせることが本当の修正になります。プロファイリングでワークロードがより多くの並行処理を必要としていることが示された場合は、パラメータを増やしてください:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, // コアスレッドを増やす
32, // 最大スレッドを増やす
120L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000) // キューを大きくする
);
// 実行時に動的にサイズ変更 — 再起動不要
executor.setCorePoolSize(16);
executor.setMaximumPoolSize(64);
避けるべきこと:無制限キュー(容量引数なしのnew LinkedBlockingQueue<>())。これらは、JVMがヒープを使い果たすまで静かにプレッシャーを吸収します — そうなるとRejectedExecutionExceptionの代わりにOutOfMemoryErrorが発生します。OOMは明確な拒否よりもはるかに追跡が困難です。上限を設定し、拒否を明示的に処理してください。
Spring Bootの設定
SpringのThreadPoolTaskExecutorはThreadPoolExecutorをラップし、同様に動作します。Beanの定義で設定してください:
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
または、Javaコードを変更せずにapplication.propertiesでデフォルト値を調整してください:
spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=32
spring.task.execution.pool.queue-capacity=500
修正の検証
再起動して様子を見るだけではいけません。エグゼキュータに負荷をかけて何が起きるか確認してください:
- ピークレートでタスクを送信する — ログに
RejectedExecutionExceptionが表示されないことを確認。 - JMXまたはMicrometerでメトリクスを公開し、
executor.queueSizeとexecutor.activeCountをリアルタイムで監視する。 CallerRunsPolicyを使用すると、プレッシャー下で送信スレッドが減速することに気づくでしょう。これはバックプレッシャーが意図通りに機能している証拠であり、バグではありません。
以下は、小さなプール(コア2、最大4、キュー10)で100タスクを使った簡単なストレステストです。100タスクすべてがクリーンに完了するはずです:
ExecutorService executor = new ThreadPoolExecutor(
2, 4, 30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10),
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i {
Thread.sleep(100);
System.out.println("Done: " + taskId + " on " + Thread.currentThread().getName());
return null;
});
}
executor.shutdown();
100タスクすべてが例外なく完了すれば、修正は確実です。
まとめ
- まず
isShutdown()を確認する — シャットダウン済みのエグゼキュータへの送信は常に例外をスローします。 - 飽和状態のプールには、ほとんどの場合
CallerRunsPolicyが適切なデフォルトです。作業を破棄する代わりに自然にプロデューサーを減速させます。 - 常に有界キューを使用してください。無制限キューは明確な
RejectedExecutionExceptionをサイレントなOOMクラッシュに変えてしまいます。 - 本番環境ではカスタム
RejectedExecutionHandlerですべての拒否をログに記録してください — 後で自分に感謝することになります。

