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

Java example
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
Run
javac FixedPoolStarvationDemo.java
java FixedPoolStarvationDemo
Inspect while stuck
jps
jstack <pid>
jcmd <pid> Thread.print
FixedPoolStarvationDemo.java
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
Commands
jps
jstack <pid>
jcmd <pid> Thread.print
Expected dump shape
"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
FixedPoolStarvationFixed.java
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.