An ExecutorService separates what to run (a task) from how it runs (pooled threads, a queue, a policy for overload). ThreadPoolExecutor handles each new task in a fixed order: start a core thread, else queue the task, else start an extra thread up to the maximum, else reject it. So with an unbounded queue, maximumPoolSize never takes effect. The Executors factory methods hide risky defaults: newFixedThreadPool has an unbounded queue that can run you out of memory, and newCachedThreadPool has no thread limit. In production, build a ThreadPoolExecutor yourself with a bounded queue, named threads, and a rejection policy you chose. Exceptions from submit() disappear unless someone calls get(), and a scheduled task that throws never runs again.
Part of the Java Concurrency Roadmap. Builds on Fundamentals.
How a task is handled#
flowchart LR
S[submit task] --> C{threads below core?}
C -- yes --> N[start core thread]
C -- no --> Q{queue has room?}
Q -- yes --> E[enqueue]
Q -- no --> M{threads below max?}
M -- yes --> X[start extra thread]
M -- no --> R[rejection policy]
Building one properly#
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 16, // core, max
60, SECONDS, // idle extra threads die after this
new ArrayBlockingQueue<>(500), // bounded
Thread.ofPlatform().name("orders-", 0).factory(),
new ThreadPoolExecutor.CallerRunsPolicy());
Name your threads. A dump full of pool-7-thread-3 tells you nothing. In Spring, ThreadPoolTaskExecutor takes the same settings.
Sizing: for CPU-bound work, use about the number of cores. For I/O-bound work, use roughly cores × (1 + wait time / compute time). Then check it with a load test, because the formula is only a starting point. The calculator below runs that formula.
I/O-bound pool size
cores * (1 + wait / cpu)Rejection policies#
| Policy | On overload |
|---|---|
AbortPolicy (default) | throws RejectedExecutionException |
CallerRunsPolicy | the submitting thread runs the task itself, which slows the producer down (natural backpressure) |
DiscardPolicy | drops the task silently |
DiscardOldestPolicy | drops the oldest queued task and retries |
CallerRunsPolicy is usually the right default. Never use it when the submitter is an event-loop thread (Netty, WebFlux), because running a task there stalls every connection on that loop.
Shutdown and exceptions#
pool.shutdown(); // no new tasks, finish queued ones
if (!pool.awaitTermination(30, SECONDS)) {
pool.shutdownNow(); // interrupt workers, return queued tasks
}
Since Java 19, ExecutorService is AutoCloseable, so a try-with-resources block does the same.
execute(task): an exception goes to the thread’s uncaught exception handler, and the pool replaces the dead worker.submit(task): the exception is stored in theFuture. Nobody callsget(), so nobody ever sees it. Wrap tasks with your own try/catch and logging.
Scheduled executors#
scheduleAtFixedRate(task, 0, 1, SECONDS)aims for one start per second. A slow run delays the next one, but runs never overlap.scheduleWithFixedDelaywaits a fixed gap after each run finishes.- If a run throws, every later run is cancelled silently. Always catch exceptions inside scheduled tasks.
Prefer these over java.util.Timer, which runs everything on one thread and dies for good on the first exception.
Future and FutureTask#
Future lets you get() (blocking), get(timeout), cancel(true) (interrupts the worker) and isDone(). FutureTask is the class behind it: a Runnable and a Future at once, so you can hand it to a plain Thread. For chaining results without blocking, see Async and Fork/Join.
Don’t pool virtual threads. Use Executors.newVirtualThreadPerTaskExecutor() and limit concurrency with a Semaphore.
