Thread-Pool Starvation and Queue Collapse
When every worker thread is blocked and the queue grows without limit: reading it from a thread dump, why an unbounded queue turns a slowdown into an outage, and isolating with bulkheads.
On this page
Starvation is the quieter cousin of deadlock: no cycle, no JVM warning, and every thread technically doing something. The service simply stops making progress while every dashboard says it is alive.
Key Takeaways
- The signature is all N workers sharing one stack, with the queue depth climbing.
- An unbounded queue turns a downstream slowdown into unbounded latency and an
OutOfMemoryError. - Nested pools — a task that waits for another task on the same pool — deadlock with no lock involved.
- Queue depth is the leading indicator. Alert on it, not on rejections.
- Bulkheads contain the failure to the dependency that caused it.
What it looks like
"order-worker-1" #31 waiting on condition [0x00007f2a1c0]
java.lang.Thread.State: TIMED_WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:252)
at com.zaxxer.hikari.util.ConcurrentBag.borrow(ConcurrentBag.java:151)
at com.acme.orders.OrderService.enrich(OrderService.java:112)
"order-worker-2" ... identical
"order-worker-3" ... identical
... all sixteen identical ...Sixteen workers, all parked waiting for a database connection. Nothing is deadlocked — each thread will proceed the moment a connection frees — but none can, so the pool does no work at all. Every task submitted behind them waits.
The JVM reports no deadlock, because there is no cycle in the wait-for graph. You have to notice the pattern yourself: N identical stacks where N is the pool size.
Why the queue makes it worse
// LinkedBlockingQueue with capacity Integer.MAX_VALUE.
// Never rejects, so maximumPoolSize is never reached and tasks accumulate.
ExecutorService pool = Executors.newFixedThreadPool(16);At a thousand requests per second with a five-minute downstream outage, that queue holds three hundred thousand tasks. Each holds a request object, headers, a security context and whatever the task captured — commonly 2–10KB. That is 600MB–3GB of queued work, and the heap fails long before the dependency recovers.
There is a second, subtler harm: even after the dependency recovers, the service spends minutes working through a backlog of requests whose clients timed out long ago. The work is wasted and the recovery is delayed.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
16, 32, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // bounded
namedThreadFactory("order-worker-"),
new ThreadPoolExecutor.CallerRunsPolicy()); // backpressureSizing the queue is a latency decision, not a memory one: queueSize / throughput is the worst-case
wait. At 80 requests per second, a queue of 200 means a task at the back waits 2.5 seconds. If your
client times out at 3 seconds, a longer queue is pure waste.
The nested-pool deadlock
ExecutorService pool = Executors.newFixedThreadPool(8);
public Report build(String id) throws Exception {
return pool.submit(() -> {
// Running on a pool thread, and blocking on work that must ALSO
// run on a pool thread.
Future<Section> section = pool.submit(() -> loadSection(id));
return new Report(section.get());
}).get();
}Once eight outer tasks are in flight, all eight threads are blocked in section.get() and no thread
remains to execute any inner task. This is a permanent deadlock that the JVM cannot detect.
The same shape appears with parallelStream() inside a task already running on the common
ForkJoinPool, and with a @Async method calling another @Async method on the same executor.
Three fixes: use separate pools for the two levels; restructure so no task waits on the same pool
(thenCompose rather than get()); or use virtual threads, where blocking does not consume a limited
resource.
Bulkheads
@Configuration
public class ExecutorConfig {
// Sized independently. A slow payment gateway cannot starve inventory.
@Bean("paymentExecutor")
ThreadPoolTaskExecutor paymentExecutor() {
return executor("payment-", 8, 8, 50);
}
@Bean("inventoryExecutor")
ThreadPoolTaskExecutor inventoryExecutor() {
return executor("inventory-", 16, 16, 100);
}
// Optional enrichment: small, and allowed to fail fast.
@Bean("recommendationExecutor")
ThreadPoolTaskExecutor recommendationExecutor() {
return executor("recs-", 4, 4, 10);
}
private ThreadPoolTaskExecutor executor(String prefix, int core, int max, int queue) {
var e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(core);
e.setMaxPoolSize(max);
e.setQueueCapacity(queue);
e.setThreadNamePrefix(prefix);
e.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
e.setWaitForTasksToCompleteOnShutdown(true);
e.setAwaitTerminationSeconds(30);
return e;
}
}Now a recommendations outage costs four threads and ten queued tasks. Checkout is untouched. The named thread prefixes also mean the next thread dump identifies the culprit in one glance.
The lighter-weight alternative is a Semaphore per dependency on a shared pool — Resilience4j's
Bulkhead — which limits concurrency without the memory cost of separate pools. Both are covered in
CountDownLatch, Semaphore and friends.
Monitoring
@Bean
MeterBinder poolMetrics(ThreadPoolTaskExecutor paymentExecutor) {
return registry -> {
ThreadPoolExecutor tp = paymentExecutor.getThreadPoolExecutor();
Gauge.builder("executor.queue.depth", tp, e -> e.getQueue().size())
.tag("pool", "payment").register(registry);
Gauge.builder("executor.active", tp, ThreadPoolExecutor::getActiveCount)
.tag("pool", "payment").register(registry);
};
}| Signal | Meaning |
|---|---|
| Queue depth rising | Arrival rate exceeds service rate — act now |
| Active = pool size, sustained | No headroom left |
| Rejections above zero | Already shedding load; the damage has started |
| Task duration p99 climbing | A dependency is slowing before anything else shows it |
Queue depth is the metric to page on. Rejections are a lagging indicator — by the time they appear, users are seeing errors.
The incident, end to end
- Symptom. p99 latency climbs from 180ms to 30 seconds over ten minutes. CPU at 12%. No errors yet.
- Thread dump. All 16
order-workerthreads parked inConcurrentBag.borrow— waiting for a database connection. - Look one layer down. The connection pool is exhausted because a query lost its index after a schema migration and now takes eight seconds. See The N+1 query and the slow endpoint.
- Why total outage rather than slow? The executor queue was unbounded, so every arriving request queued behind the slow ones. Heap climbed towards the limit.
- Mitigate. Recreate the index. Latency recovers in ninety seconds.
- Fix. Bound the queue at 200, switch to
CallerRunsPolicy, split payment and order executors. - Guardrail. Alert on
executor.queue.depthabove 50 for two minutes, and a load test in CI that asserts the service returns 503s rather than filling the heap when a dependency is stubbed slow.
Step 4 is the insight worth carrying into an interview: the root cause was a missing index, but the reason it became an outage rather than a slowdown was the unbounded queue. Distinguishing the trigger from the amplifier is what senior incident analysis looks like.
What gets asked
"What happens when a downstream service gets slow?" is the setup. The answer walks the chain: workers block, throughput collapses, the queue grows, and — depending on whether it is bounded — you either shed load or exhaust the heap. Then name the two defences: bound the queue with a rejection policy, and bulkhead per dependency.
Frequently Asked Questions
How do I tell thread-pool starvation from a deadlock?
Why is an unbounded queue so dangerous?
What is a bulkhead?
Related tutorials
- HikariCP Connection-Pool ExhaustionThe incident where every request times out waiting for a connection: how to read the HikariCP exception, find the leak with leakDetectionThreshold, and why a bigger pool usually makes it worse.
- The N+1 Query and the Endpoint That Got SlowWhy a lazy association turns one request into a thousand queries, how to detect N+1 in tests rather than production, JOIN FETCH versus EntityGraph, and the MultipleBagFetch and pagination traps.
- Debugging a 100% CPU Spike in ProductionThe exact command sequence that turns a pinned CPU into a line number: top -H, converting the thread id to hex, matching nid in a thread dump, and the four causes it usually turns out to be.
- Latency Spikes: Proving It Was (or Was Not) GCA method for attributing p99 latency: correlating GC logs with request timings, why safepoint pauses hide outside GC, coordinated omission in load tests, and the causes that are not GC at all.