🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOGjava.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@30f39991[Not completed, task = com.devinhyderabad.jobs.ReportJob$$Lambda$42/0x0000000801046228@a09ee92] rejected from java.util.concurrent.ThreadPoolExecutor@5caf905d[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 42] at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2032) at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:787) at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1328) at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:149) at com.devinhyderabad.jobs.JobScheduler.submitReport(JobScheduler.java:58)

⚡ Quick Fix Works 80% of the time

Stop submitting after shutdown begins — gate producers on the pool state, or restructure so every submit happens before close.

if (!pool.isShutdown()) { pool.submit(new ReportJob(orderId)); // guarded submission } else { durableQueue.save(new ReportJob(orderId)); // replay after restart }

🧠 Why this Happens

Tap to expand the deep technical explanation

ThreadPoolExecutor packs runState and worker count into one atomic ctl integer. Once shutdown() moves the state past RUNNING, execute() short-circuits: no worker is started and the task goes straight to the RejectedExecutionHandler, whose AbortPolicy throws. The exception message embeds toString() of both the task and the pool, and the trailing bracket is a live autopsy — Terminated with pool size 0 means shutdown finished entirely, so this submit raced a close somewhere else in the code.

The HITEC City Parking Spot Analogy:

Rushing to the airport check-in desk after the flight boarded and the counter shut: your bag is still in hand (the task), but the airline (the pool) has closed operations and hands you a printed refusal slip listing exactly when everything stopped.

🔁 How to Reproduce Confirm this is your error

Create Executors.newFixedThreadPool(2), call shutdown(), then submit(() -> "x") — the REE with the [Terminated, ...] snapshot appears instantly. (Lab capture: OpenJDK Temurin 25.0.2.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

Give the executor a single owner and a strict lifecycle

👉 Use this when/if several components share a pool and any of them may close it first.

The race disappears when exactly one class creates, uses, and closes the executor. Producers receive the running service through injection and never see shutdown(), while the owner closes it only after all inbound work is provably complete — typically from a container stop hook.

@Component public class JobRunner implements SmartLifecycle { private ExecutorService pool; @Override public void stop() { pool.shutdown(); try { if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { pool.shutdownNow(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } // start(), isRunning(), getPhase() omitted for brevity }
Solution 2

Follow the three-step graceful shutdown recipe

👉 Use this when/if you own teardown and want zero lost tasks.

shutdown() stops intake (future submits fail fast — that is this exception), awaitTermination gives workers a grace period, and shutdownNow() returns whatever was still queued so you can persist it. Draining the returned list converts silent loss into recoverable data.

pool.shutdown(); if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { List<Runnable> pending = pool.shutdownNow(); pending.forEach(jobs::saveForReplay); }
Solution 3

Scope the executor with try-with-resources

👉 Use this when/if the pool serves one bounded unit of work (request, batch, test).

Since Java 19, ExecutorService implements AutoCloseable: close() blocks until submitted tasks finish. Scoping the pool to the batch guarantees no code path submits after the resource exits scope, eliminating the race by construction.

try (ExecutorService scoped = Executors.newVirtualThreadPerTaskExecutor()) { futures = paths.map(p -> scoped.submit(() -> parse(p))).toList(); } // close() joined everything; nothing can submit afterwards
Solution 4

Install a handler that persists instead of throwing

👉 Use this when/if tasks must survive restarts and losing one is a business incident.

A custom RejectedExecutionHandler replaces AbortPolicy: rejected tasks go to a durable store with the original payload, and a recovery job replays them after the new pool starts. The exception becomes a metric instead of a crash.

ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100), (r, executor) -> { metrics.increment("jobs.rejected.shutdown"); jobStore.save((JobPayload) r); }); // constructor arg order: core, max, keepalive, unit, queue, handler
Solution 5

DEV ONLY: recreate a fresh pool whenever rejection hits

👉 Use this only to keep a broken prototype limping — it multiplies pools and hides who leaked them.

Catching REE and spinning up a replacement executor makes every lifecycle bug invisible: old pools linger half-shutdown, threads accumulate, and the next OutOfMemoryError lands far from the cause. Fix ownership instead.

// ANTI-PATTERN — do not ship try { pool.submit(job); } catch (RejectedExecutionException e) { pool = Executors.newFixedThreadPool(4); // leak factory pool.submit(job); }

📋 Version Notes

Java 7

Bracket snapshot [state, pool size = ..., active threads = ..., queued tasks = ..., completed tasks = ...] already present — verified in jdk7u and jdk8u sources; rejection messages are fully diagnosable from the start.

Java 11

Format unchanged from Java 7/8 — same snapshot in every rejection message.

Java 19

ExecutorService becomes AutoCloseable; close() waits for queued work, shrinking the shutdown race window.

Java 21

newVirtualThreadPerTaskExecutor rejects identically after close() — same exception, same lifecycle rules.

🛡️ How to Prevent This Next Time

Assign each executor exactly one owning component with a documented stop phase, wire producers through it rather than holding static references, and add an integration test that stops the application context while traffic is flowing — the race shows up in CI, not production.