↓ Skip to main content

Java Concurrency: Executors and Thread Pools

·579 words·3 mins

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]
ThreadPoolExecutor under loadcore=1, queue capacity=2, max=2

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

Suggested threads –
cores * (1 + wait / cpu)

Rejection policies
#

PolicyOn overload
AbortPolicy (default)throws RejectedExecutionException
CallerRunsPolicythe submitting thread runs the task itself, which slows the producer down (natural backpressure)
DiscardPolicydrops the task silently
DiscardOldestPolicydrops 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 the Future. Nobody calls get(), 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.
  • scheduleWithFixedDelay waits 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.

Amar Singh
Author
Amar Singh
I build stuff

Related