Executors & Thread Pools
The Executor framework, Runnable vs Callable, thread-pool sizing, rejection policies, CompletableFuture, and ForkJoinPool.
8 questions
JuniorTheoryVery commonWhat does the Executor framework give you over new Thread(...).start()?
What does the Executor framework give you over new Thread(...).start()?
It decouples submitting a task from the thread that runs it. A pool reuses a small set of threads instead of one per task, queues work while they are busy, returns a Future per result, and adds shutdown/awaitTermination control.
Common mistakes
- ✗Thinking a pool spawns a new thread per task rather than reusing a fixed set
- ✗Believing the Executor only adds a
Futureand changes nothing about threads - ✗Forgetting the pool must be shut down, leaking non-daemon threads
Follow-up questions
- →What happens to the pool's threads if you never call
shutdown? - →Why is a fixed pool safer than an unbounded one under load?
MiddleCodeVery commonCompose two dependent async stages with CompletableFuture
Compose two dependent async stages with CompletableFuture
CompletableFuture is a Future you can complete yourself and chain into stages. thenApply maps a completed value with a plain function; thenCompose chains a stage that itself returns a CompletableFuture and flattens the result, so dependent async calls run in order with no blocking get().
Common mistakes
- ✗Calling
get()between stages, turning an async chain back into blocking code - ✗Using
thenApplywhere the mapper returns a future, producing a nestedCompletableFuture - ✗Believing
CompletableFuturecannot be completed manually or composed
Follow-up questions
- →When would you reach for
thenComposeinstead ofthenApply? - →How does
thenCombinediffer fromthenComposewhen joining two futures?
JuniorTheoryCommonHow do Runnable and Callable differ, and submit() vs execute()?
How do Runnable and Callable differ, and submit() vs execute()?
Callable<V> returns a value and may throw a checked exception; Runnable returns neither. Trap: submit() swallows a thrown exception into the Future, surfacing it only on get(); execute() propagates it to the thread's uncaught handler.
Common mistakes
- ✗Assuming a task submitted via
submit()logs its own exception automatically - ✗Thinking
Runnablecan return a value or throw a checked exception - ✗Expecting
execute()andsubmit()to report failures the same way
Follow-up questions
- →Why does a task submitted with
submit()seem to fail silently? - →How do you route uncaught task exceptions to a central handler?
MiddleTheoryOccasionalHow does work-stealing in a ForkJoinPool schedule subtasks?
How does work-stealing in a ForkJoinPool schedule subtasks?
Each worker thread owns a double-ended queue. fork() pushes a subtask onto the owner's own deque, and the owner pops it LIFO — the newest, cache-hot work first. An idle worker steals from the other end of a busy worker's deque, taking the oldest and coarsest task, which minimises contention with the owner. There is no central queue to become a bottleneck.
Common mistakes
- ✗Picturing one shared central queue instead of a deque per worker
- ✗Thinking the owner and a thief take work from the same end of the deque
- ✗Believing a worker blocked in
join()simply sits idle instead of running other work
Follow-up questions
- →Why does the owner pop LIFO while a thief steals from the opposite end?
- →What does
ManagedBlockerdo when aForkJoinPooltask has to block?
MiddleTheoryOccasionalWhat happens when a ThreadPoolExecutor's task queue is full?
What happens when a ThreadPoolExecutor's task queue is full?
The executor first grows the pool towards maximumPoolSize; only when the queue is full and that maximum is reached does it hand the task to its RejectedExecutionHandler. The four built-ins are AbortPolicy (throws RejectedExecutionException — the default), CallerRunsPolicy (runs it on the submitting thread, which backpressures the producer), DiscardPolicy, and DiscardOldestPolicy.
Common mistakes
- ✗Expecting
execute()to block on a full queue rather than reject the task - ✗Thinking
maximumPoolSizeis reached before the queue fills, rather than after - ✗Assuming the default policy discards silently instead of throwing
Follow-up questions
- →Why does
CallerRunsPolicyact as backpressure on the producing thread? - →With an unbounded queue, which rejection policy ever runs?
MiddleDesignOccasionalYour team runs a Spring Boot service in an 8-vCPU container. Two workloads share a single ThreadPoolExecutor: an image-resampling job that is pure CPU work, and an order-export job that spends roughly 90% of its wall time blocked on a slow third-party HTTP API. The pool is currently Executors.newFixedThreadPool(8) backed by an unbounded LinkedBlockingQueue. Under load, exports sit in the queue for minutes while CPU utilisation stays near 30%, and one traffic spike drove the JVM into OutOfMemoryError. In a design review, argue what pool configuration you would propose — core and maximum size, queue type and bound, and how the two workloads should be separated — and justify each number against the workload it serves.
Your team runs a Spring Boot service in an 8-vCPU container. Two workloads share a single ThreadPoolExecutor: an image-resampling job that is pure CPU work, and an order-export job that spends roughly 90% of its wall time blocked on a slow third-party HTTP API. The pool is currently Executors.newFixedThreadPool(8) backed by an unbounded LinkedBlockingQueue. Under load, exports sit in the queue for minutes while CPU utilisation stays near 30%, and one traffic spike drove the JVM into OutOfMemoryError. In a design review, argue what pool configuration you would propose — core and maximum size, queue type and bound, and how the two workloads should be separated — and justify each number against the workload it serves.
Size by blocking factor, not by a fixed rule: threads ≈ cores × (1 + wait/service). CPU-bound resampling wants about availableProcessors() threads; the export, blocked ~90% of the time, wants roughly ten times that. Give them separate pools so neither starves the other. Replace the unbounded queue with a bounded one — an unbounded queue silently absorbs a spike into the heap, which is what caused the OutOfMemoryError — and pair it with an explicit rejection policy.
Common mistakes
- ✗Sizing every pool at core count regardless of how long its tasks block
- ✗Leaving the queue unbounded, so a spike is absorbed into the heap instead of rejected
- ✗Sharing one pool between CPU-bound and blocking tasks, letting one starve the other
Follow-up questions
- →With an unbounded queue, when does
maximumPoolSizeever take effect? - →How would you choose between rejecting a task and throttling the submitting thread?
SeniorDesignOccasionalAn ingestion service accepts webhook callbacks over HTTP and hands each one to a ThreadPoolExecutor for enrichment — two blocking calls of roughly 400 ms each — before writing to Kafka. The producers are external and retry aggressively on any error. In steady state the service handles 2,000 events/s; during an upstream replay the arrival rate briefly reaches 20,000 events/s. Today the pool has 64 threads and an unbounded LinkedBlockingQueue: during the last replay the queue grew to millions of entries, latency rose to minutes, the heap filled, and the process died — losing everything that was queued. Design the backpressure strategy for the next release: where the boundary sits, what the executor does once it is saturated, and what the HTTP layer tells a producer it cannot serve. Justify why you would not simply enlarge the pool.
An ingestion service accepts webhook callbacks over HTTP and hands each one to a ThreadPoolExecutor for enrichment — two blocking calls of roughly 400 ms each — before writing to Kafka. The producers are external and retry aggressively on any error. In steady state the service handles 2,000 events/s; during an upstream replay the arrival rate briefly reaches 20,000 events/s. Today the pool has 64 threads and an unbounded LinkedBlockingQueue: during the last replay the queue grew to millions of entries, latency rose to minutes, the heap filled, and the process died — losing everything that was queued. Design the backpressure strategy for the next release: where the boundary sits, what the executor does once it is saturated, and what the HTTP layer tells a producer it cannot serve. Justify why you would not simply enlarge the pool.
Bound the buffer and push pressure back to the producer instead of into the heap. Size a bounded queue to the latency you tolerate, and pick a rejection policy that signals saturation — AbortPolicy mapped to HTTP 429/503 with Retry-After, or CallerRunsPolicy to throttle the accepting thread. Enlarging the pool does not help: the work is I/O-bound and capped downstream, so more threads only deepen the queue. Durable buffering belongs in a broker, not on the heap.
Common mistakes
- ✗Adding threads to absorb a spike whose real ceiling is a downstream dependency
- ✗Leaving the queue unbounded, turning a rate problem into an
OutOfMemoryError - ✗Accepting work you cannot serve instead of signalling saturation to the producer
Follow-up questions
- →Why does
CallerRunsPolicyslow the producer down rather than just relocating the work? - →When should the durable buffer be a broker rather than the executor's own queue?
SeniorTheoryOccasionalWhat causes thread starvation in a bounded thread pool?
What causes thread starvation in a bounded thread pool?
Every pool thread is blocked waiting on work that only the same pool can do, so nothing is left to make progress — deadlock by exhaustion. The classic case: a task submits a subtask to its own pool and blocks on Future.get(); with enough such tasks every thread parks on get() while the subtasks sit in the queue forever. Long-blocking I/O, or one slow tenant monopolising a shared pool, starves it the same way. Cures: never join on your own pool, and isolate workloads in separate pools.
Common mistakes
- ✗Confusing starvation with a pool that is merely too small, and just raising the size
- ✗Submitting a subtask to your own pool and then blocking on
Future.get() - ✗Assuming
Future.get()releases the worker thread back to the pool while it waits
Follow-up questions
- →Why does
ForkJoinPool.join()avoid the starvation a plain pool suffers on nesting? - →How does isolating workloads into separate pools contain one slow tenant?