ExecutorService is the abstraction that separates what work to run from which thread runs it. Instead of new Thread(task).start() — which creates an unbounded number of expensive OS threads — you submit tasks to a managed pool that reuses a controlled set of threads. Getting the pool configuration right is one of the highest-leverage things in a Java server, and getting it wrong is behind a large share of production outages.
submit vs execute, and Future
Two ways to hand work to an executor. execute(Runnable) is fire-and-forget. submit(...) returns a Future you can use to get the result, wait for completion, or cancel:
Future<Integer> f = pool.submit(() -> compute());
Integer result = f.get(); // blocks until done; throws ExecutionException on failureA crucial difference in error handling: an exception from an executed Runnable propagates to the thread's uncaught-exception handler (usually logged). An exception from a submitted task is captured in the Future and only surfaces when you call get() — so a submitted task that fails and whose Future is never inspected fails silently. Always retrieve results or check outcomes.
ThreadPoolExecutor: the knobs that matter
The real engine behind most executors is ThreadPoolExecutor, whose behaviour is governed by core pool size, max pool size, the work queue, and the rejection policy. The interaction is subtle and counter-intuitive:
new ThreadPoolExecutor(
core, // threads kept alive even when idle
max, // absolute ceiling on threads
60, TimeUnit.SECONDS, // idle timeout for threads above core
new ArrayBlockingQueue<>(qsize), // the work queue
new ThreadPoolExecutor.CallerRunsPolicy()); // what to do when saturatedThe order of operations surprises people: the pool grows to core threads, then fills the queue, and only creates threads beyond core (up to max) once the queue is full. So with an unbounded queue, max is never reached — the pool stays at core and the queue grows without limit. Bound the queue, or the max setting is a lie.
Rejection policies
When both the pool and the queue are full, the RejectedExecutionHandler decides the fate of a new task:
| Policy | Behaviour |
|---|---|
AbortPolicy (default) | throw RejectedExecutionException |
CallerRunsPolicy | run the task on the submitting thread — natural back-pressure |
DiscardPolicy | silently drop the task |
DiscardOldestPolicy | drop the oldest queued task, enqueue the new one |
CallerRunsPolicy is often the smart choice for a server: when saturated, the thread that submitted the task is forced to execute it, which slows the producer and gives the pool time to catch up — back-pressure without dropping work.
Why the Executors factory methods are risky
The convenient Executors.newFixedThreadPool() and newCachedThreadPool() hide exactly the dangerous defaults. newFixedThreadPool uses an unbounded LinkedBlockingQueue — under overload the queue grows until OutOfMemoryError. newCachedThreadPool has max pool size Integer.MAX_VALUE — under load it spawns threads without limit until the machine dies. Many teams (and Google's Java style guide) recommend constructing ThreadPoolExecutor directly with a bounded queue and an explicit rejection policy, so overload degrades predictably instead of catastrophically.
Graceful shutdown
An ExecutorService must be shut down or its non-daemon threads keep the JVM alive. The correct two-phase sequence:
pool.shutdown(); // stop accepting new tasks; finish queued ones
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt running tasks
pool.awaitTermination(10, TimeUnit.SECONDS);
}shutdown() is graceful (lets in-flight and queued work finish); shutdownNow() interrupts running tasks and returns the queued ones. Tasks must actually respond to interruption for shutdownNow() to work — a task that ignores its interrupt flag will not stop.
Virtual-thread executors
JDK 21 adds Executors.newVirtualThreadPerTaskExecutor(), which does not pool at all — it starts a fresh virtual thread per task. Because virtual threads are cheap (a few KB, not an OS thread), pooling them is unnecessary and even counterproductive. For I/O-bound workloads this often replaces a carefully tuned ThreadPoolExecutor with something simpler and higher-throughput. For CPU-bound work, a bounded platform-thread pool sized near the core count is still the right model, since there is no benefit to more runnable threads than cores.
ExecutorService decouples tasks from threads via a managed pool. Understand the ThreadPoolExecutor order — core threads, then queue, then up to max — so an unbounded queue doesn't quietly cap you at core and grow forever. Avoid the Executors factory defaults (unbounded queue / unbounded threads); build the pool directly with a bounded queue and CallerRunsPolicy. Always shut down in two phases. On JDK 21+, prefer a virtual-thread-per-task executor for I/O-bound work.