You've seen it: a Spring Boot app that runs fine for two hours, then suddenly every request takes five seconds. CPU is under 30%, memory is fine, no GC pauses. The thread dump tells the story — dozens of threads waiting on a single condition. That's thread pool starvation, and it's more common than you think.
This guide shows you the four places where starvation hides in Java frameworks — inside Tomcat, inside CompletableFuture, inside Spring's @Async, and inside your connection pool. Each one has a different signature, but they share a root cause: threads blocked waiting for a resource that never comes.
1. Who Needs This and What Goes Wrong Without It
When Your Server Goes Silent Mid-Request
You're watching the dashboard — response times creep up, then flatline. Some requests hang for thirty seconds. Others timeout entirely. The CPU sits at forty percent, memory looks fine, but the app feels dead. That's thread pool starvation in the wild. I've seen it take down a payment service during Black Friday traffic — not because the database buckled, but because Tomcat ran out of worker threads to hand new connections. The symptom is deceptive: nothing is crashing, yet nothing is completing.
The catch is that starvation rarely announces itself with a clear error. You get vague timeouts, intermittent 503s, or logs showing threads stuck in WAITING or BLOCKED state. Most teams I've worked with initially blame the database or the network. Wrong order. The real culprit is inside the application thread model — blocking calls on borrowed threads that never return to the pool.
Why Fresh Developers Miss the Signs
Newer engineers often treat thread pools as magic black boxes: throw work at them and it gets done. They don't realize that each Tomcat request thread is a finite resource — usually 200 by default. Sprinkle in a few CompletableFuture.get() calls on the request thread, add a parallel stream for data enrichment, and suddenly a single user request consumes three threads for ten seconds. Multiply by fifty concurrent users and the pool is exhausted. That feels counterintuitive — more threads should help, right? Not when they're all blocked waiting for each other.
I once debugged a service where a junior dev had wrapped every controller endpoint with @Async and then called Future.get() inside the same request thread. The thread pool filled in under sixty seconds during a load test. The fix wasn't more threads — it was removing the blocking wait entirely and switching to fire-and-forget or callbacks.
The real pain shows up in degraded SLAs. A ninety-ninth percentile latency target of 500ms blows past 10 seconds. Error budgets evaporate. Operations pages you at 3 AM because health checks start timing out. The cost? Lost revenue, angry customers, and a postmortem where everyone points fingers at "the framework." But it's not Spring Boot's fault — it's how you wired the threads.
Thread pool starvation is the silent tax on naive async code — you pay in latency spikes, not crashes.
— common pattern in production incident reports across Java shops
The Hidden Cost: Cascading Failures Beyond Your Service
Starvation doesn't stay contained. When Tomcat workers freeze, incoming connections queue up. Reverse proxies like Nginx or HAProxy start dropping retries. Downstream services see timeout storms and may degrade their own health checks. I've watched a single stuck thread pool take out three microservices in a chain — because the upstream service kept retrying into a black hole. That hurts.
The trade-off many teams miss: tuning thread pool sizes without understanding the concurrency model is guesswork. You can double the pool from 200 to 400, but if each thread blocks for twenty seconds, you've only postponed the collapse. The fix is architectural — break the synchronous wait, use asynchronous completions, or offload blocking work to dedicated pools. Ignoring this leads to brittle systems that fail under modest load. Next up, we'll walk through the exact prerequisites you need to understand before we dissect four specific starvation points — including the one that bit that Black Friday service.
2. Prerequisites and Context You Should Settle First
Understanding thread pools: core, max, queue, rejection
The first thing you need—before you touch a single Spring Boot property—is a mental model of what a thread pool actually is. Not the textbook definition. The one that breaks at 2 AM. A thread pool holds a set of worker threads. You configure a core count (the number kept alive), a max count (the ceiling you'll scale to under load), and a queue (work waiting for a free thread). That's it. The trap: most developers set core = max, then wonder why latency spikes. If you skip the queue, you've turned your pool into a fixed-size straitjacket. When all threads are busy—say, eight threads stuck on slow database queries—incoming tasks hit the rejection handler. The default rejects with RejectedExecutionException. Honest, that's fine for batch jobs. For a web server? Your users see 503s. That hurts.
The trade-off few teams admit: a larger queue buffers bursts but hides real saturation. You'll see response times creep up, then collapse. I've watched teams double the queue and declare victory—until the JVM heap fills with pending requests and GC pauses finish the job. Know your pool's rejection policy. CallerRunsPolicy slows the caller (often the Tomcat acceptor thread). DiscardOldest drops the oldest task, which in a request flow means the one that already waited longest. Pick deliberately, not as an afterthought.
Reality check: name the frameworks owner or stop.
How Tomcat and Jetty manage request threads
Tomcat doesn't use the standard ThreadPoolExecutor. It has its own: StandardThreadExecutor. The difference matters. Tomcat's executor treats maxThreads as the absolute maximum, but it starts new threads only after the queue fills. The default queue is Integer.MAX_VALUE placed inside a SynchronousQueue-like structure—wait, that's not a real queue. It's a custom blocking queue with zero capacity? Actually, Tomcat uses an unbounded LinkedBlockingQueue if you set minSpareThreads correctly. Confusing? Yes. Most teams skip this: Tomcat won't grow beyond minSpareThreads until the queue is full. If your queue is unbounded, you never grow. That means starvation sneaks in silently—threads stay at the minimum, requests pile up in the queue, and your CPU sits idle. A thread dump shows a handful of Tomcat threads, all blocked on the same JDBC connection pool. Jetty has a different default: it uses an ExecutionStrategy that can dispatch directly to a thread without queueing. The pitfall is over-committal under high concurrency. You can end up with more concurrent threads than cores, causing context-switch thrash. Both servers have the same weak point: when a request thread blocks on an async operation—a CompletableFuture, a @Async method, a RestTemplate call—it sits there, doing nothing, occupying a slot. That's starvation point one, covered in the next section.
What a thread dump looks like under starvation
A thread dump under starvation has a distinctive smell. You'll see dozens of Tomcat threads with stack traces ending in java.lang.Thread.sleep, park, or Object.wait. Not doing work. Waiting. Meanwhile, the http-nio-8080-exec-* threads are all in RUNNABLE but stuck on a LockSupport.park inside a CompletableFuture—that's the async call that never completed. The key pattern: all threads are alive, none are productive. The queue is empty because no new requests can enter. No rejection, no error log. Just a server that's alive but useless. I once debugged a case where the dump showed 200 threads, all waiting on the same database query that hung because a connection pool timeout after the thread already committed to the async path. The fix: never let a Tomcat thread directly block on an async result. Use a separate executor. But that's for chapter 3.
'A thread pool under starvation feels like a stalled highway — every lane occupied, no car moving, and the on-ramp meter still green.'
— common observation from production post-mortems
The trick in reading a dump: look for identical stack traces across multiple threads. If you see fifteen threads all stuck at java.util.concurrent.FutureTask.get — same target, same line — you've got a shared bottleneck, not a random hang. That's the signature of starvation by design: the framework itself funnels all threads into the same blocking point. Most teams skip this diagnostic step and restart the server. Don't. You lose the evidence. Capture the dump, grep for the pool name, count the threads. That number should match your max threads setting. If it does, and they're all stuck, you've confirmed starvation. Next actions: isolate the blocking call and route it off the Tomcat pool. The next section shows exactly how that fails.
3. Starvation Point One: Blocking on Async Calls Inside Tomcat Threads
The pattern: calling get() on a CompletableFuture inside a request thread
You've got a Spring Boot controller method that needs data from two services. Cleanest way: fire both calls asynchronously, then join them. So you write CompletableFuture.supplyAsync(() → serviceA.call()), follow it with serviceB.call(), then call .get() on both futures. That's the recipe. And it works fine in dev with one user. But under load—the Tomcat common pool thread that executes supplyAsync is drawn from the same default ForkJoinPool. Your request thread, also a Tomcat worker, blocks on .get(). Blocked thread holds a connection, a socket, a seat in the pool. Meanwhile, that same pool is supposed to execute the async task you're waiting on. The thread that runs the async work can't be dispatched because all threads are busy waiting for work that won't start. That hurts.
Why Tomcat's limited thread count makes this deadly
Tomcat's default max threads is 200. That might sound like plenty until you realize each blocked request hogs one thread for the entire duration of the blocking call. If twenty requests each spawn two async tasks, you have twenty threads blocked on .get() and up to forty pending tasks queued for the common pool. Except those forty tasks also need Tomcat threads—or the common pool is shared. The common ForkJoinPool has a default parallelism equal to your CPU cores—often 4 to 16. So sixteen threads max to execute forty tasks, while twenty worker threads are sitting deadlocked. New requests pile up. Connection timeouts spike. You restart the app and blame the database. Nine times out of ten, it's this exact cycle. The seam blows out because Tomcat's thread pool and the ForkJoinPool are coupled through a single blocking call.
The catch is that the fix isn't always obvious. Bumping server.tomcat.max-threads to 500 masks the symptom but doesn't unbind the dependency. You still have a structure where one blocking call can starve the whole pipeline. I have seen a production incident where a team doubled the thread count, the deadlock just took longer to appear, and then the JVM ran out of memory. Threads are not free.
Quick fix: use async servlet or offload to a separate pool
Two clean exits exist. First: return a CompletableFuture directly from your controller and let Spring MVC use async request processing. The Tomcat thread returns to the pool immediately; the actual computation runs on a controlled executor. Second: supply your own executor with a thread count independent of Tomcat's pool and the common ForkJoinPool. Something like Executors.newFixedThreadPool(10) for background calls. That decouples the waiting threads from the working threads. Most teams skip this: they rely on the default async machinery and wonder why the app stalls at 20 concurrent users. You'll know you've fixed it when the thread dump shows blocked workers but the async tasks are running on a separate pool with available threads. Then the system degrades gracefully instead of collapsing.
Using the same pool for blocking and async work is like using one checkout lane for both cash and credit—then wondering why nobody moves.
— paraphrased from a production postmortem I walked through last year
Odd bit about frameworks: the dull step fails first.
That said, the second variation—parallel streams—creates a similar but subtler deadlock on the same ForkJoinPool. We'll get to that next.
Odd bit about frameworks: the dull step fails first.
Odd bit about frameworks: the dull step fails first.
Odd bit about frameworks: the dull step fails first.
Odd bit about frameworks: the dull step fails first.
4. Starvation Point Two: Parallel Streams Hogging the ForkJoinPool
How parallelStream() hijacks a shared resource
Most teams add parallelStream() thinking it's a free performance boost. They're wrong—or at least playing with fire. The JVM's common ForkJoinPool is a single, static pool shared across all callers. That means your Spring Boot service, a batch job, and a background scheduler all fight for the same threads. You call list.parallelStream().map(...).collect(...) inside a Tomcat request handler, and suddenly every other operation that touches that pool—including internal JDK tasks—waits. The seam blows out not because the work is heavy, but because the pool starves silently.
Why mixing HTTP threads with ForkJoin creates contention
Tomcat's request threads are blocking I/O workers. ForkJoin threads are CPU-bound stealers. When an HTTP thread submits a parallel stream, it blocks until the stream completes—holding a Tomcat thread hostage and consuming ForkJoin slots. Two problems at once. I have seen a production incident where a single slow parallelStream() call—in a report endpoint—blocked 80% of the common pool, stalling unrelated async tasks like log shipping and health checks. The catch: nothing throws an error. Threads just stop making progress.
You might ask—shouldn't parallel streams be safe? The spec says they use a default parallelism equal to your CPU count. That sounds fine until four requests arrive simultaneously, each spawning its own stream. Now you have 4×N threads contending for the same pool, plus whatever else the JVM spawns internally. That hurts. The real failure is silent: response times creep up, your ELB starts 503-ing, and you blame the database—when it's just thread contention.
Mitigation: custom ForkJoinPool or explicit ExecutorService
The fix is surgical, not systemic. You have two paths. Path one: wrap parallel stream calls in a custom ForkJoinPool. Create the pool with a controlled parallelism—say 4—and submit your stream inside pool.submit(() -> list.parallelStream()...).get(). This isolates the work from the common pool entirely. Path two: skip parallelStream() altogether and use an ExecutorService with a bounded thread pool. Straight CompletableFuture or Executors.newFixedThreadPool(4) gives you explicit control. Parallelism becomes visible—no magic shared state.
Switching to a custom ForkJoinPool reduced our P99 latency from 12s to 400ms. Same data, same logic—just no starvation.
— observation from a production rollback postmortem
The trade-off: you lose the simplicity of .parallelStream(). Code gets a few extra lines. However, the debugging time you save when things go wrong is worth ten times that. Most teams skip this step—until the pool chokes. Don't be that team. Audit every parallelStream() in your controller paths today. If any runs inside a transactional boundary or a blocking HTTP handler, extract it. Your ForkJoinPool isn't elastic—starve it once, and you'll remember.
5. Variations for Different Constraints — Cloud vs Bare Metal vs Containers
Thread pool sizing when CPU is constrained
A 16-core box lets you oversubscribe threads like it's nothing — you can run 200 Tomcat threads, throw in a few async pools, and the OS scheduler just absorbs it. Move that same Spring Boot app to a 0.5-vCPU container and the whole thing seizes up. I have seen teams copy their thread pool configs from production bare metal straight into a 512-CPU-share Docker container. Painful. The math flips: on constrained CPUs, every blocked thread isn't just waiting — it's burning scheduler cycles that the OS desperately needs to rotate the few available cores. The fix isn't to blindly halve your thread counts; it's to measure the actual concurrency depth your application reaches during peak load. On a 2-vCPU box, a Tomcat max-threads of 50 might still be fine if your handlers spend 90% of their time on I/O. If they block on internal async calls? 50 threads means 50 simultaneous waiters, none making progress, and the CPU sitting idle because it can't advance any of them. You need to tie max-threads to the number of concurrent blocking operations your app can sustain, not to some arbitrary multiple of cores.
Starvation in serverless vs long-running services
Serverless flips the starvation story upside down. In AWS Lambda or Google Cloud Run, your thread pool is ephemeral — each instance lives maybe a few minutes, handles one request at a time, and dies. The classic starvation pattern (thread A waits for thread B, thread B waits for a queue) still happens, but the blast radius is smaller because you don't have one giant pool slowly suffocating. What usually breaks first is the cold-start of shared thread pools that aren't lazily initialized. I fixed a Lambda handler once where the developer had a static ForkJoinPool set to parallelism = 4, except the Lambda only had 1 vCPU. That pool never filled, but the application still tried to spawn 4 workers on each invocation — four threads competing for one core while the request timeout ticked down. The catch is that tuning for serverless means optimizing for short bursts, not sustained throughput. You trade larger queues for faster timeout failures — a queued request that waits 30 seconds might be acceptable on a long-running service; on Lambda it's a dead invocation. Different game.
Thread pool tuning is about physics, not best practices — the constraint moves from your code to the hypervisor the moment you deploy to a shared tenant.
— words from a production incident post-mortem I wrote last year
Trade-offs: more threads vs more queues
The decision isn't symmetrical. Adding more threads on a container with CPU limits just amplifies contention — you get more context switches, more false sharing on cache lines, and worse tail latency. The better bet is usually a larger work queue paired with a smaller thread count, but that introduces its own risk: queue buildup hides starvation until it's too late. We fixed a recurring timeout spike by dropping Tomcat's max-threads from 100 to 20 and increasing the task queue capacity from 100 to 500. Sounds backwards. But the 20 threads never blocked waiting for each other anymore (fewer concurrent async completions), and the queue absorbed bursts without collapsing the CPU. The pitfall? When the queue grows past 200, response times degrade uniformly — no single request gets starved, but every request slows down. You have to choose between starving a few threads (high variance in latency) or starving all requests equally (higher median latency). On bare metal I lean toward more threads and tighter queues; on containers under 2 vCPU, I do the opposite. The right call depends on whether your SLA penalizes p99 spikes or average response time — and yes, you need to know which one matters before you tune a single number.
6. Pitfalls, Debugging, and What to Check When It Fails
How to read a thread dump for starvation patterns
Most teams skip this: they stare at a thread dump and see hundreds of lines and panic. You don't need to read everything. Look for the `BLOCKED` and `WAITING` states inside the `http-nio` threads. If you see most of them stuck on `park` or `LockSupport.parkNanos` and zero threads actually executing your controller logic — that's starvation. The giveaway is a long queue of threads all waiting for a single lock. One pattern I have seen repeatedly: twenty Tomcat threads all blocked on `Future.get()` inside a CompletableFuture chain, while the thread that should complete that future is itself waiting for a Tomcat thread to become free. That circular wait kills throughput instantly. Wrong order — you check the stack traces top-down, but the real bottleneck is often three frames down inside `ThreadPoolExecutor.runWorker`. Use `jstack` or `jcmd Thread.print` on a live instance. Run it three times, five seconds apart. If the same threads are stuck in the same stack frames each time, you have confirmed a stuck thread situation — not just a transient pause.
Reality check: name the frameworks owner or stop.
Using Micrometer to monitor pool utilization and queue depth
You can't fix what you don't measure. Spring Boot 2.x ships Micrometer by default. Export to Prometheus or just log the metrics. The key ones: `tomcat.threads.busy`, `tomcat.threads.current`, and `jvm.threads.deadlocked`. Set an alert when `busy / current` exceeds 80% for more than ten seconds. That sounds fine until you realize queue depth tells the real story. `tomcat.threads.queue.size` — if that stays above zero during normal load, your pool is undersized or your tasks are blocking too long. The catch: queue depth can spike briefly under normal bursts. Watch the 95th percentile over one minute, not raw max. A subtle trap — Micrometer's default `tomcat.threads.config.max` reflects the configured max, not the effective max after Tomcat adjusts for `max-connections` and `accept-count`. If you use an embedded Tomcat with default settings, max threads is 200, but `max-connections` defaults to 10000. That mismatch means threads can be idle while connections pile up. We fixed this by exposing both numbers in a custom actuator endpoint and correlating them during incidents. Most teams only monitor CPU and memory — they miss the real signal.
The 'increase pool size' trap and when it actually works — you see busy threads at peak and think "I need more threads." That's often wrong. If your threads are blocked on I/O (database calls, external HTTP, Redis), adding threads just increases contention for that downstream resource. I have seen a team double the pool from 200 to 400 and watch response times triple — because the database connection pool was already saturated. The pool size increase only helps when the bottleneck is purely computational and threads are truly runnable, not waiting. You can check this: if thread dumps show mostly `RUNNABLE` threads doing CPU work, then yes, add threads. But if they show `BLOCKED` or `TIMED_WAITING` on sockets or locks, you need to fix the blocking call, not the thread count. The one scenario where increasing the pool works: when you have short non-blocking tasks that are queued due to bursty traffic, and downstream resources have headroom. That's rare in practice. Most starvation in Spring Boot comes from accidental blocking inside async code, not from too few threads.
"We doubled the thread pool and response times doubled. That was the day we stopped guessing and started reading thread dumps."
— senior engineer after a postmortem, echoing a lesson learned the hard way
What usually breaks first is the connection pool, not the thread pool. Check `HikariPool` metrics: `hikaricp.connections.active` and `hikaricp.connections.pending`. If active connections are maxed and pending queries are growing, your database is the limiting factor, not Tomcat threads. Another pitfall: thread dumps from a staging environment with no real load. They always look clean. You need production traces under stress. Use `async-profiler` or `JDK Flight Recorder` to capture wall-clock samples — that shows you where time is actually lost. One concrete anecdote: a team at a payment company kept seeing timeout errors every hour. They increased thread pool from 200 to 500 — same errors. They finally used JFR and found 70% of thread time was spent on `SocketInputStream.read` waiting for an external fraud check API that had no timeout configured. They added a 2-second timeout and the starvation vanished. That hurts — a configuration fix, not a pool resize. Your next action: set up a grafana dashboard with the four metrics listed here, run a load test with your real traffic pattern, and capture a thread dump during the peak. If you don't see the blockage pattern within an hour, you likely don't have starvation yet — but you will when traffic doubles.
7. FAQ: Common Thread Pool Starvation Questions (and Prose Answers)
Can starvation happen with a single thread pool?
Absolutely — and that's often where it bites hardest. You'd think one pool, one queue, no cross-thread chaos. But a single pool doesn't mean single-purpose usage. I've seen a Spring Boot app where the same `ThreadPoolTaskExecutor` handled both database callbacks and file cleanup jobs. The cleanup tasks ran heavy I/O — each holding a thread for 8–12 seconds. Meanwhile, database callbacks piled up in the queue. No thread available, no timeout, just silence. That's starvation: the pool has capacity on paper, but all slots are occupied by slow work. The catch is — you don't see a crash. You see latency creep, then timeouts, then a pager alert.
What's the difference between starvation and deadlock?
Deadlock is a frozen handshake: thread A holds lock 1 and waits for lock 2, thread B holds lock 2 and waits for lock 1. Neither moves. Starvation is quieter — threads are running, but some never get a turn. They're alive, eligible, waiting — but the scheduler or pool keeps picking others. Wrong order. Not a hang, but a slow leak. In Tomcat, starvation looks like 200 threads busy, 50 requests queued, none completing. Deadlock would freeze the whole pool instantly. Starvation lets you watch it crumble, request by request.
'We had a microservice that processed image uploads. One heavy upload blocked the pool for four seconds. Ten simultaneous uploads? All other endpoints stalled.'
— backend engineer, postmortem notes
The trick is: starvation often hides behind 'thread contention' or 'high CPU' in dashboards. But CPU can be idle while threads wait on I/O — that's a starvation signature, not a resource crunch.
Should I set core threads equal to max threads?
That depends on whether you trust your workload. Equal core and max means the pool never shrinks — threads stay alive even idle. For predictable, steady traffic, it's fine: you avoid the cost of creating threads under load. But if your traffic spikes and then vanishes, you waste memory — each thread carries a stack (~1 MB by default on 64-bit JVM). I've seen teams set both to 200 'because Tomcat default is 200'. Then they wonder why heap is fine but RSS memory balloons.
Most teams skip this: the real question is queue behavior. If core equals max, the pool rejects tasks once queue fills — no elasticity. That hurts during burst traffic. A better pattern for bursty APIs: core = 10, max = 50, queue capacity = 200, with a short keep-alive. That lets the pool absorb bursts without over-committing resources. But test it — the ideal ratio depends on your task duration distribution, not a formula.
One concrete change: monitor `pool.size` vs `activeCount` over a week. If active never exceeds core, max is irrelevant. If active hits max daily, you're one spike away from rejection. That's the moment to tune — not before.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!