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 failure

A 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.

Advertisement

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 saturated

The 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:

PolicyBehaviour
AbortPolicy (default)throw RejectedExecutionException
CallerRunsPolicyrun the task on the submitting thread — natural back-pressure
DiscardPolicysilently drop the task
DiscardOldestPolicydrop 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.

Advertisement

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.