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:
- Only 2 tasks can be processed (1 thread + 1 queue slot)
- 8 tasks are silently discarded by
DiscardPolicy shutdownNow()only returns queued tasks — the discarded futures remain in an unknown stateinvokeAllinternally callsFuture.get()on every submitted task, waiting for completion- 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
Futurenever 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.