Executors, Futures & Starvation

Single-Thread Executor Self-Deadlock

Single-Thread Executor Self-Deadlock: practice a Java concurrency bug with symptoms like One task waits forever, No crash, Queue never drains. Inspect...

  • Single-thread executors
  • ExecutorService
  • Self-deadlock
  • Java
  • Beginner

Production symptoms

  • One task waits forever
  • No crash
  • Queue never drains

Failure scenario

Code

Java example
ExecutorService orderedExecutor = Executors.newSingleThreadExecutor();

orderedExecutor.submit(() -> {
    Future<String> child = orderedExecutor.submit(() -> "child");
    return child.get();
});

Prod Symptoms

An ordered worker handles one partition, tenant, account, or event stream. One task queues follow-up work to the same worker and then waits for it.

Key signal: The ordering guarantee is the trap: later work cannot run until the current task returns.

  • One partition or ordered stream stops making progress
  • Later work for the same executor stays queued
  • The JVM stays alive and no exception is thrown
  • The single worker is WAITING in Future.get or CompletableFuture.join
  • Queue age grows while completed-task count stays flat

Run Locally

  • parent starts on the only ordered worker
  • parent submits child behind itself
  • parent blocks on child.get before returning the worker
  • child never runs because it is queued after the blocked parent

What to look for

  • The single worker waiting in Future.get
  • A queued task submitted by the waiting task
  • No other worker available to make progress
Run
javac SingleThreadExecutorSelfDeadlockDemo.java
java SingleThreadExecutorSelfDeadlockDemo
Inspect while stuck
jps
jstack <pid>
jcmd <pid> Thread.print
SingleThreadExecutorSelfDeadlockDemo.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class SingleThreadExecutorSelfDeadlockDemo {
    public static void main(String[] args) throws Exception {
        ExecutorService orderedExecutor = Executors.newSingleThreadExecutor();

        Future<String> parent = orderedExecutor.submit(() -> {
            System.out.println("parent running on single worker");
            Future<String> child = orderedExecutor.submit(() -> {
                System.out.println("child running");
                return "child result";
            });
            System.out.println("parent waiting for child");
            return child.get();
        });

        Thread.sleep(1_000);
        System.out.println("parent done = " + parent.isDone());
    }
}

Note: The only executor thread waits for work that can only run after the current task finishes.

Diagnosis and fix

Explanation

The executor has exactly one worker. That worker is blocked waiting for a task that is queued behind the current task.

Key signal: Do not block inside an ordered executor task on later work queued to that same ordered executor.

  • Single-thread executors run tasks one at a time
  • The parent task must finish before the child can start
  • The parent does not finish because it waits for the child
  • The child cannot start because the parent is still occupying the worker
  • This is an executor-ordering self-deadlock, not a monitor deadlock

How to Diagnose

The dump usually shows the only worker blocked in Future.get.

  • Identify the executor's single worker thread or ordered partition worker
  • Look for it waiting in FutureTask.get or CompletableFuture.join
  • Inspect whether that task submitted more work to the same executor before waiting
  • Check queue length, queue age, and completed-task count if executor metrics are exposed
  • Search for nested submit/get patterns in code using newSingleThreadExecutor
  • Remember that a thread dump will not show the queued child task unless the executor exposes queue details
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 SingleThreadExecutorSelfDeadlockDemo.lambda$main$1(SingleThreadExecutorSelfDeadlockDemo.java:...)

How to Fix

  • Do the child work directly when it must run in the same serialized context
  • Use callbacks or composition from outside the ordered worker instead of blocking inside it
  • Use a separate executor only when the child work is independent and safe to run elsewhere
  • Do not switch to a larger pool if single-thread ordering is part of the correctness contract
  • Use timeouts only to limit damage; they do not remove the dependency cycle
  • Keep single-thread executors for sequencing, not nested blocking orchestration
SingleThreadExecutorFixed.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class SingleThreadExecutorFixed {
    public static void main(String[] args) throws Exception {
        ExecutorService orderedExecutor = Executors.newSingleThreadExecutor();

        Future<String> parent = orderedExecutor.submit(() -> {
            System.out.println("parent running on single worker");
            String child = childWork();
            return "parent got " + child;
        });

        System.out.println(parent.get());
        orderedExecutor.shutdown();
    }

    private static String childWork() {
        return "child result";
    }
}

Note: Because the child work must happen in the same serialized context, calling it directly avoids queueing behind yourself.