JDK Thread Pool invokeAll Blocking Issue with Silent Rejection Policies

JDK Thread Pool Rejection Policies

The JDK standard thread pool provides four rejection policies:

  • AbortPolicy: Discards the task and throws a RejectedExecutionException — the default policy.
  • DiscardOldestPolicy: Discards the oldest unprocessed task in the queue and attempts to execute the current task.
  • CallerRunsPolicy: The calling thread executes the rejected task directly.
  • DiscardPolicy: Silently discards the task without throwing any exception.

The focus here is on the last policy: DiscardPolicy. At first glance, it simply does nothing:

public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
}

This apparent simplicity hides a critical design flaw.

Bug Report: JDK-8286463

The issue is documented at: JDK-8286463

The title states: ThreadPoolExecutor.invokeAll may block forever when using DiscardPolicy with more tasks than capacity.

This bug builds upon an earlier issue (JDK-8160037) involving shutdownNow() and invokeAll(). Understanding the first bug helps clarify the second.

Reproducing the Issue

Consider the following test case:

public class InvokeAllBugDemo {
    public static void main(String[] args) throws InterruptedException {
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int taskId = i;
            tasks.add(() -> {
                System.out.println("task " + taskId);
                Thread.sleep(500);
                return null;
            });
        }

        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread invoker = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("invokeAll completed");
        });
        invoker.start();

        Thread.sleep(800);
        executor.shutdownNow();
    }
}

With a standard fixed thread pool (core size 2, unbounded queue), calling shutdownNow() and properly handling the return value allows the program to exit normally:

List<Runnable> pending = executor.shutdownNow();
for (Runnable r : pending) {
    if (r instanceof Future) {
        ((Future<?>) r).cancel(false);
    }
}

This works because shutdownNow() returns unexecuted tasks, and since these are wrapped as FutureTask objects, they can be explicitly cancelled.

The Real Problem: DiscardPolicy

Replace the executor with a bounded configuration:

ExecutorService executor = new ThreadPoolExecutor(
    1,
    1,
    1L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1),
    new ThreadPoolExecutor.DiscardPolicy()
);

This configuration accepts at most 2 tasks (1 thread + 1 queue slot). Submitting 10 tasks means 8 will trigger DiscardPolicy.

The complete test case:

public class InvokeAllBugDemo {
    public static void main(String[] args) throws InterruptedException {
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int taskId = i;
            tasks.add(() -> {
                System.out.println("task " + taskId);
                Thread.sleep(500);
                return null;
            });
        }

        ExecutorService executor = new ThreadPoolExecutor(
            1, 1, 1L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(1),
            new ThreadPoolExecutor.DiscardPolicy()
        );

        Thread invoker = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("invokeAll completed");
        });
        invoker.start();

        Thread.sleep(800);
        System.out.println("calling shutdownNow");
        List<Runnable> runnables = executor.shutdownNow();
        for (Runnable r : runnables) {
            if (r instanceof Future) {
                ((Future<?>) r).cancel(false);
            }
        }
        System.out.println("shutdown complete");
    }
}

Running this code shows that invokeAll never completes. The program blocks indefinitely because:

  1. Only 2 tasks can be processed (1 thread + 1 queue slot)
  2. 8 tasks are silently discarded by DiscardPolicy
  3. shutdownNow() only returns queued tasks — the discarded futures remain in an unknown state
  4. invokeAll internally calls Future.get() on every submitted task, waiting for completion
  5. Discarded futures never complete and never signal an error

Root Cause Analysis

Inside AbstractExecutorService.invokeAll():

public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) {
    List<Future<T>> futures = new ArrayList<>(tasks.size());
    for (Callable<T> t : tasks) {
        futures.add(executor.submit(t));  // submit and wrap in Future
    }

    for (Future<T> f : futures) {
        if (!f.isDone()) {
            f.get();  // blocks until done
        }
    }
    return futures;
}

The method submits all tasks and then blocks on Future.get() for each one. When a task is silently rejected:

  • Its Future never executes
  • No exception is thrown
  • get() blocks forever

Compare this behavior with AbortPolicy:

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    throw new RejectedExecutionException();
}

This exception propagates through the submit() call, allowing invokeAll to catch it and interrupt the waiting futures. DiscardPolicy provides no such mechanism.

The Official Response

The JDK maintainers acknowledged the issue. Martin Buchholz's response was essentially: DiscardPolicy is rarely used in production and users should avoid it. The behavior was classified as a potential "feature" rather than a bug worth fixing.

This stance ignores a fundamental design problem: Future objects created by invokeAll should be cancellable or throwable by external threads, but DiscardPolicy makes this impossible.

The Design Flaw

The core issue is that invokeAll creates futures that become inaccessible once silently rejected. A future's completion state should be determinable by external code, but discarded futures exist in a zombie state — not completed, not cancelled, not interruptible.

This violates the contract of the Future interface, which assumes that any submitted task either completes normally, completes exceptionally, or can be cancellde. DiscardPolicy breaks this assumption without providing any notification mechanism.

Custom rejection handlers that silently suppress tasks exhibit the same problem. The issue is not just with DiscardPolicy itself but with any policy that discards tasks without signaling completion.

Technical Summary

Scenario Behavior Detectable?
AbortPolicy + invokeAll Exception thrown, blocked futures interrupted Yes
CallerRunsPolicy + invokeAll Calling thread executes task, completes normally Yes
DiscardOldestPolicy + invokeAll Oldest task discarded, current executes Partial
DiscardPolicy + invokeAll Task silently dropped, get() blocks forever No

The fundamental problem: a future that can never reach any terminal state cannot be safely monitored by invokeAll. Silent rejection policies create unreachable futures, and invokeAll has no defense against this scenario.

Recommended mitigation: Never use silent rejection policies (DiscardPolicy, custom handlers that suppress exceptions) when using invokeAll or similar bulk execution methods. Use AbortPolicy with proper exception handling, or implement custom policies that at minimum log or propagate rejection events.

Tags: java ThreadPool Concurrency bug invokeAll

Posted on Wed, 26 Aug 2026 16:28:32 +0000 by praxiz