Definition
Thread starvation is when a thread is perpetually denied the resources it needs to make progress – usually CPU time or a lock – because other threads monopolize them. It happens when some threads hold locks far too long, when higher-priority threads crowd out lower-priority ones, or when a thread pool is too small so tasks queue up and effectively never run in time. You diagnose it from a thread dump that shows many threads stuck in WAITING or BLOCKED states while only a few threads hold resources or run.
Understanding the Concepts
What causes thread starvation?
Starvation comes from a few distinct causes. The first is long-held locks: if one thread holds a lock while doing slow work, like a network call or a big computation, every other thread that needs that lock is blocked and starves. The second cause is an undersized thread pool: if you have a pool of a few threads but hundreds of tasks that each block on I/O, all threads get stuck waiting and new tasks queue indefinitely. The third cause is thread priority abuse: if higher-priority threads dominate the CPU, lower-priority threads never get scheduled.
How does starvation appear in banking code?
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class PaymentService {
// BAD: single pool for both fast and slow operations
private final ExecutorService pool = Executors.newFixedThreadPool(4);
public void processTransfer(String fromId, String toId, BigDecimal amount) {
pool.submit(() -> {
// SLOW: calls external bank API, takes 5 seconds
externalBankApi.send(fromId, toId, amount);
});
}
public void checkBalance(String accountId) {
pool.submit(() -> {
// FAST: local database lookup, takes 10ms
return accountRepo.getBalance(accountId);
// But this task waits in the queue behind slow transfers!
});
}
}
All four pool threads are stuck on slow external API calls. The quick balance checks are queued behind them and effectively starved of a thread to run on. Response times for balance checks grow from 10 ms to several seconds.
Thread-pool deadlock by starvation
A particularly nasty variant is when tasks in a bounded pool submit sub-tasks to the same pool and then wait for them. All threads can end up waiting for sub-tasks that can never start because no thread is free.
// DANGEROUS: pooled task submits to the same pool and waits
pool.submit(() -> {
Future<?> subtask = pool.submit(() -> computeInterest(accountId));
subtask.get(); // blocks this thread waiting for a thread from the same pool!
});
// If all threads do this, every thread is blocked waiting for work
// that can never start. The pool makes no progress.
The fix is to never block a pooled thread on work that must run in the same pool, or to use separate pools.
How do you diagnose starvation?
Take a thread dump and look at the distribution of thread states. If you see many threads in WAITING (parked, waiting on a condition or a pool's work queue) or BLOCKED (waiting for a lock) while only one or a few threads are RUNNABLE holding resources, that pattern points to starvation.
jstack <pid> | grep -c "WAITING" # count waiting threads
jstack <pid> | grep -c "BLOCKED" # count blocked threads
jstack <pid> | grep -c "RUNNABLE" # count active threads
# Many WAITING/BLOCKED + few RUNNABLE = starvation
For a pool, watch the active worker count against the queue depth on a dashboard. A growing queue with all workers blocked is the signature.
How do you fix starvation?
Fix 1: Separate pools for fast and slow work (bulkhead pattern)
package com.bank.service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class PaymentServiceFixed {
// Separate pools: slow work cannot starve fast work
private final ExecutorService transferPool = Executors.newFixedThreadPool(8);
private final ExecutorService balancePool = Executors.newFixedThreadPool(4);
public void processTransfer(/* ... */) {
transferPool.submit(() -> externalBankApi.send(/* ... */));
}
public void checkBalance(String accountId) {
balancePool.submit(() -> accountRepo.getBalance(accountId));
}
}
Fix 2: Proper pool sizing using Little's Law: pool size = throughput x latency. If you handle 100 requests/sec and each takes 200 ms, you need at least 20 threads.
Fix 3: Fair locks – new ReentrantLock(true) grants access in arrival order, preventing a thread from being skipped indefinitely. The trade-off is lower throughput because the scheduler cannot optimize thread selection.
What should a senior engineer know?
- Distinguish starvation from deadlock and livelock. In deadlock, threads are blocked on each other in a cycle forever. In starvation, threads could run if they got a turn, but they keep being passed over.
- Virtual threads (Java 21+) reduce starvation risk for I/O-bound workloads because blocking a virtual thread does not block a platform thread.
- In banking at National Bank of India, separating fast and slow work into different pools is the single most effective habit Rajesh uses for preventing starvation in the payment service.
Key Takeaway
Thread starvation is when a thread is perpetually denied what it needs to make progress, usually CPU time or a lock, because other threads monopolize those resources. It is different from deadlock: in a deadlock specific threads are blocked on each other in a cycle forever, but in starvation the threads could run perfectly well if they ever got a turn, they just keep being passed over. The common causes are locks held far too long while a thread does slow work, higher-priority threads crowding out lower-priority ones, and, most often in my experience, a thread pool that is too small, so tasks pile up in the queue and effectively never get a thread to run on in time.
I diagnose it from a thread dump by looking at the distribution of thread states. The signature is many threads sitting in WAITING or BLOCKED, parked on a lock or on a pool's work queue, while only a few threads are actually running and holding the resources. For a thread pool specifically, I look at the active worker count against the queue depth: if every worker is busy and the queue just keeps growing, the waiting tasks are being starved. Pool metrics like active count, queue size, and task latency make this obvious on a dashboard.
In my banking work at National Bank of India, this bit us in the payment service. We had a thread pool of only a couple of threads, and under load hundreds of transfer tasks arrived, each making a slow downstream call, so all the pool threads stayed busy on slow work and everything else queued. The real pain was that quick balance checks were submitted to the same pool and got stuck behind the slow transfers, so a thirty-second-equivalent quick lookup was waiting seconds. I fixed it two ways: first I sized the pool correctly for the real throughput and latency using Little's Law instead of guessing, and second I applied the bulkhead pattern by giving fast operations their own separate pool so a slow transfer could never starve a quick balance check again. Where ordering fairness mattered, I also used a fair ReentrantLock so no thread could be skipped indefinitely. Separating fast and slow work into different pools is the single most effective habit I have for preventing starvation.
Remember one sentence: What is thread starvation: some threads never get CPU time because other threads hold locks too long or there are too few threads in the pool: diagnose via thread dump showing many WAITING/BLOCKED threads is fundamental knowledge for Java interviews and production banking work.
---