Executors, Futures & Starvation
Fixed Thread Pool Starvation via Nested Future.get()
Fixed Thread Pool Starvation via Nested Future.get(): practice a Java concurrency bug with symptoms like Tasks stop progressing, Queue grows, CPU not...
- Pool starvation
- ExecutorService
- Starvation
- Java
- Intermediate
Production symptoms
- Tasks stop progressing
- Queue grows
- CPU not maxed
Failure scenario
Code
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
pool.submit(() -> {
Future<Result> child = pool.submit(() -> loadChild());
return child.get();
});
}
Prod Symptoms
A service uses a fixed thread pool for request or batch work. Under load, some requests stop finishing even though the JVM stays up.
- Active worker count stays at the configured pool size
- Executor queue depth grows and does not drain
- Completed-task count stops increasing
- CPU remains low because workers are waiting
- Request latency rises until upstream timeouts fire
- Increasing the pool size helps temporarily, but the failure returns at higher concurrency
Run Locally
- Both parent tasks start
- Each parent submits a child task
- Each parent blocks on child.get
- No worker is free to run either child task
What to look for
- Pool workers waiting in FutureTask.get
- Queued child tasks that never start
- Executor metrics showing active threads at max and queue not draining
javac FixedPoolStarvationDemo.java
java FixedPoolStarvationDemo
jps
jstack <pid>
jcmd <pid> Thread.print
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FixedPoolStarvationDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
List<Future<String>> parents = new ArrayList<>();
CountDownLatch parentsStarted = new CountDownLatch(2);
for (int i = 1; i <= 2; i++) {
final int id = i;
parents.add(pool.submit(() -> {
System.out.println("parent " + id + " running");
parentsStarted.countDown();
parentsStarted.await();
Future<String> child = pool.submit(() -> {
System.out.println("child " + id + " running");
return "child-" + id;
});
System.out.println("parent " + id + " waiting for child");
return child.get();
}));
}
Thread.sleep(1_000);
for (Future<String> parent : parents) {
System.out.println("parent done = " + parent.isDone());
}
}
}
Note: A barrier ensures both parent tasks occupy the two workers before either submits child work.
Diagnosis and fix
Explanation
This is a thread-starvation deadlock. Every worker is occupied by a parent task waiting for child work queued to the same executor.
Key signal: Available CPU does not imply available executor capacity. Blocked tasks still occupy workers.
- The fixed pool has a bounded number of workers
- Parent tasks consume all available workers
- Each parent submits a child task to the same pool
- Child tasks remain queued because no worker is free
- Parents cannot complete until their children run
- The JVM does not report a Java-level deadlock because there is no monitor ownership cycle
How to Diagnose
Combine thread dumps with executor metrics and task-dependency review.
- Look for most or all pool workers waiting in Future.get(), join(), or invokeAll()
- Confirm that those tasks submitted dependent work to the same executor
- Check active count, maximum pool size, queue depth, and completed-task count
- Correlate parent-start logs with child tasks that were submitted but never started
- Remember that a thread dump shows blocked workers but not the queued task graph
- Distinguish this from CPU saturation: the pool is full while CPU remains available
jps
jstack <pid>
jcmd <pid> Thread.print
"pool-1-thread-1" #... WAITING (parking)
at java.util.concurrent.FutureTask.get(FutureTask.java:...)
at FixedPoolStarvationDemo.lambda$main$1(FixedPoolStarvationDemo.java:...)
"pool-1-thread-2" #... WAITING (parking)
at java.util.concurrent.FutureTask.get(FutureTask.java:...)
How to Fix
- Do not block a pool task on dependent work submitted to the same bounded pool
- Execute child work directly when it belongs to the same unit of work
- Prefer asynchronous composition that releases the worker while dependencies are pending
- Wait for final results from an external coordinator, not from a worker needed to run those results
- Use separate executors only for independent workloads with explicitly planned capacity
- Use timeout as damage containment, not as a fix for the dependency cycle
- Do not rely on a larger pool or unbounded queue to fix starvation
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedPoolStarvationFixed {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);
List<CompletableFuture<String>> results = new ArrayList<>();
for (int i = 1; i <= 2; i++) {
final int id = i;
results.add(CompletableFuture
.supplyAsync(() -> loadChild(id), pool)
.thenApply(child -> "parent " + id + " got " + child));
}
for (CompletableFuture<String> result : results) {
System.out.println(result.join());
}
pool.shutdown();
}
private static String loadChild(int id) {
System.out.println("child " + id + " running");
return "child-" + id;
}
}
Note: The coordinator thread waits for final results. Pool workers run child work and continuations without blocking on queued work from the same pool.