Callable, Future & CompletionService
How to submit a task that returns a real result and can fail with a checked exception, how to collect that result safely with Future, and how ExecutorCompletionService and fan-out/fan-in patterns handle many concurrent results without head-of-line blocking.
Learning objectives
- Explain what Callable adds over Runnable and when each is the right tool
- Use Future's blocking, timed, and non-blocking methods to retrieve or cancel a task's result
- Retrieve results in completion order with ExecutorCompletionService instead of submission order
- Build a fan-out/fan-in pipeline that runs independent tasks concurrently and merges their results
- Spot the specific bugs that quietly turn parallel code back into sequential code
◆ Story
Picture dropping a car off at a repair shop. A bad shop just says "leave it, we'll get to it" — you don't know when it's done, whether it even succeeded, or how to check without physically going back. A good shop hands you a claim ticket instead: a number you can check on any time, come back later to collect the finished work with, or use to cancel the job outright if your plans change. Runnable is the bad shop. Callable and Future are the ticket system.
Every task submitted to an ExecutorService as a Runnable does real work, but its run() method returns void and cannot declare a checked exception. If something goes wrong inside a Runnable, the thread running it either swallows the problem silently or crashes in a way the original caller has no direct way to observe. That's a real limitation once a background task's outcome actually matters — loading a file, calling a downstream service, computing a value someone is waiting on.
Callable<T> fixes both problems with one small change to the contract. It has exactly one abstract method, V call() throws Exception, which means a task built on it can hand back a typed result and can propagate a checked failure instead of hiding it. Submitting a Callable to an executor returns a Future<T> immediately — before the task has necessarily even started, let alone finished. That's the whole design: a Future is not the answer, it's a handle you hold onto so you can decide later when, or whether, to collect the answer.
| Aspect | Runnable | Callable<T> |
|---|---|---|
| Return value | none (void) | a real value, T |
| Checked exceptions | must be caught inside run() | can be thrown from call() and travel back to the caller |
What submit() gives you | a Future<?> that only reports completion | a Future<T> that reports completion and carries a result |
| Typical use | fire-and-forget side effects | anything whose outcome the caller needs |
Nothing about this is exotic once you see it clearly — it's the shape almost every asynchronous operation eventually takes: kick off the work, get a handle back immediately, and decide later exactly when to deal with the result.
A Callable<T> can be written three ways, and all three are functionally identical to the executor: a named class (useful when the task carries real state or gets reused across many submissions), an anonymous class (rarely worth it in modern Java), or a lambda (the default choice for almost every real submission, since Callable is a functional interface). executor.submit(callable) hands the task to a pool thread and returns a Future<T> without blocking the calling thread at all — the submission call returns instantly, whether or not the task has started running yet.
Future.get() is where the calling thread actually pays for the result: it blocks until the task completes, however long that takes. If the task completed normally, get() returns its value. If the task threw, get() doesn't rethrow that exact exception — it wraps it in an ExecutionException, and the original failure is reachable only through getCause(). Forgetting that wrapping step is a common source of logs that say "ExecutionException" with no useful detail underneath.
Beyond get(), Future exposes a small but complete API: isDone() answers instantly, true or false, whether the task has finished — including finishing via an exception or a cancellation, not just success. isCancelled() narrows that down to specifically "was this cancelled before completion." cancel(mayInterruptIfRunning) attempts to stop the task; if it's already running and you pass true, the executor sends an interrupt, which only actually halts anything if the task's own code checks for interruption.
◆ Under the hood
submit(callable) doesn't hand your Callable to a thread directly. It wraps it in a FutureTask, which implements both Runnable and Future at once — the pool runs the FutureTask as a Runnable, and the exact same object, upcast to Future, is what gets handed back to you. That dual identity is why a single object can both "be the thing that runs" and "be the thing you call .get() on" — most code never needs to construct a FutureTask directly, but recognizing the name helps when reading library internals.
💻 Code example
package com.crackedlabs.concurrency.callablefuture; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public class CallableStylesDemo { // Named class style: useful when the task has real state or gets // reused across many submissions. Most verbose of the three styles. static class PriceLookup implements Callable<Integer> { @Override public Integer call() { return 4200; // price in cents } } public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(3); // 1. Named class Future<Integer> f1 = pool.submit(new PriceLookup()); // 2. Anonymous class -- rarely worth it once lambdas are available Future<Integer> f2 = pool.submit(new Callable<Integer>() { @Override public Integer call() { return 1500; // shipping fee in cents } }); // 3. Lambda -- the default choice for almost every real submission Future<Integer> f3 = pool.submit(() -> 900); // tax in cents // Each get() blocks until that specific task's result is ready. // This says nothing about which task actually finished first -- // only that all three are done by the time this line completes. int total = f1.get() + f2.get() + f3.get(); System.out.println("Order total (cents): " + total); pool.shutdown(); } }
Submit ten independent tasks and loop over their futures calling get() in submission order, and you've created a subtle performance trap called head-of-line blocking. If task one happens to be the slow one — ten seconds, say — you sit blocked on it for the full ten seconds, even if tasks two through ten each finished in under a second and are simply waiting for you to notice. ExecutorCompletionService exists specifically to remove that trap: it wraps an executor and an internal blocking queue, and as each submitted task finishes, its result lands on that queue in completion order. Calling take() always hands you the next task to finish — the fastest remaining one — regardless of which order anything was submitted in.
There's one discipline that comes with this: the number of times you call take() must exactly match the number of tasks you submitted. Submit three tasks and call take() only twice, and the third result sits unretrieved in the internal queue forever — not an error, just silently lost. Call take() a fourth time when only three were submitted, and that call blocks forever, since no fourth completion will ever arrive.
A closely related pattern is fan-out/fan-in: firing off several independent, slow operations at once and merging their results, so total latency approaches the slowest single operation instead of their sum. The rule that makes this actually work is simple to state and easy to get backwards under pressure: submit everything first, and only then start calling get() on anything. Call get() on the first task before submitting the second, and the second task doesn't even begin running until the first is completely done — get() blocks the calling thread, so it can't go submit more work while it's parked waiting. The parallel version silently degrades into a sequential one, with no exception or warning that anything went wrong.
◆ Under the hood
ExecutorCompletionService doesn't add new concurrency of its own — the executor still runs each task on whichever pool thread is free. What it adds is a completion-ordered queue sitting between "task finishes" and "you find out," so a fast finisher never has to wait behind a slow one just because it happened to be submitted first.
💻 Code example
package com.crackedlabs.concurrency.callablefuture; import java.util.concurrent.CompletionService; import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public class CompletionOrderDemo { public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(4); CompletionService<String> completionService = new ExecutorCompletionService<>(pool); // Slow task submitted FIRST: simulates a warehouse system that // takes a while to report back. completionService.submit(() -> { Thread.sleep(600); return "Inventory check complete"; }); // Fast task submitted SECOND: simulates a quick in-memory price // lookup. This is the pair that proves completion order is not // the same as submission order. completionService.submit(() -> { Thread.sleep(80); return "Price lookup complete"; }); // take() blocks until the NEXT task finishes -- the fast one // will be retrieved first even though it was submitted second. for (int i = 0; i < 2; i++) { Future<String> finished = completionService.take(); System.out.println("Finished: " + finished.get()); } pool.shutdown(); } }
▲ Common mistake
Calling get() on a future immediately after submitting it, before submitting the next task in what was supposed to be a parallel batch. This is by far the most common way fan-out/fan-in code quietly stops being parallel — get() blocks the caller, so the next submit() call never even happens until the first task is entirely finished. The total time silently balloons from "roughly the slowest task" back to "the sum of every task," and nothing about the code looks wrong at a glance.
A second trap involves timeouts. Calling future.get(500, TimeUnit.MILLISECONDS) and catching TimeoutException tells you the wait gave up — it does not mean the task stopped. The task keeps running in the background exactly as before. If you no longer need the result once a timeout fires, you must explicitly call future.cancel(true) yourself; otherwise you've abandoned the result while the work behind it keeps consuming a thread for no reason.
A third trap is specific to ExecutorCompletionService: mismatching the submission count and the take() count. Submitting three tasks but only looping take() twice leaves one result stranded in the internal queue forever. Looping take() more times than tasks were submitted blocks the calling thread forever, waiting for a completion that will never arrive. There's no automatic way to detect this mismatch — the count has to be tracked correctly by the calling code.
Finally, a subtler but common bug: catching ExecutionException from get() and logging it directly instead of unwrapping it with getCause(). Every exception thrown inside a Callable gets wrapped this way, so logging the wrapper alone buries the actual failure (a NullPointerException, an IOException, whatever the real cause was) one layer deeper than most log-scanning tools or humans will bother to look.
cancel(true) and cancel(false) only differ in behavior for a task that's already running. cancel(false) only prevents a task that hasn't started yet from ever starting — if it's already running, this call does nothing and returns false. cancel(true) additionally sends an interrupt to the running task's thread, but that interrupt only actually stops anything if the task's own code checks Thread.interrupted() or lets an InterruptedException propagate rather than swallowing it. A Callable that ignores interruption entirely — say, one stuck in a tight CPU-bound loop with no blocking calls and no interruption check — simply keeps running to completion regardless of cancel(true).
isDone() is a genuinely common source of confusion: it returns true for a task that completed successfully, a task that completed by throwing an exception, and a task that was cancelled — all three count as "done" in the API's sense. Checking isDone() and assuming it means "succeeded" is a real bug; the only way to actually know which of the three happened is to call get() (and be ready to catch ExecutionException or CancellationException) or check isCancelled() explicitly first.
A FutureTask submitted as a plain Runnable (via executor.execute(futureTask) or a bare Thread) still gives you a working Future to call .get() on afterward — this is the specific case where constructing a FutureTask directly is worth doing, since it's the one building block that's simultaneously a Runnable an older API can run and a Future you can collect a result from.
▲ Edge case
Submitting a plain Runnable (not a Callable) via executor.submit(runnable) still returns a Future<?> — but calling .get() on it just returns null once the task finishes, unless you used the overload submit(Runnable, T result), in which case get() returns that fixed result value regardless of what the Runnable actually did internally.
Spring's @Async methods can declare a return type of Future<T> (or, more commonly today, CompletableFuture<T>), letting a controller or service kick off background work and collect the result through exactly this same handle-based model, backed by a configured TaskExecutor.
API gateways and backend-for-frontend layers routinely need to assemble one response from several independent, slower downstream calls — a user's profile from one service, their recent orders from another, a recommendation feed from a third. The fan-out/fan-in shape (submit everything, then collect everything) is the standard way to keep total latency close to the slowest single call instead of their sum, before reaching for a more composable tool like CompletableFuture.
Batch-processing frameworks that split a large job into independent chunks often use ExecutorCompletionService specifically so that the first chunk to finish can be written out, logged, or merged immediately, instead of the whole pipeline stalling behind whichever chunk happened to be handed to the slowest worker thread.
Calling a third-party HTTP API or a slow downstream database with a hard budget is a natural fit for future.get(timeout, unit) — often combined with an explicit cancel(true) on timeout and a circuit breaker that starts short-circuiting future calls once timeouts happen too often.
Parallel test runners that execute many independent test cases concurrently often collect per-test pass/fail results as they complete rather than in a fixed order, so a slow, hanging test doesn't block reporting on every faster test that already finished — the same head-of-line-blocking problem ExecutorCompletionService solves, one layer up in a test harness instead of a production request path.
Q: Why can't Runnable just be used everywhere Callable is used today?
A: Runnable.run() returns void and declares no checked exceptions, so a task built on it has no way to hand back a computed result and no clean way to let a checked failure travel back to whoever submitted it. Callable<T>'s call() method returns a typed value and is declared to throw Exception, fixing both limitations.
Q: If a Callable throws an exception, what does calling get() on its Future actually throw?
A: get() throws ExecutionException, not the original exception directly. The real failure is reachable through ExecutionException.getCause() — skipping that unwrapping step is a common way to lose useful detail in logs.
Q: Why does submitting task B right after task A, but before calling get() on A, matter so much for performance?
A: Because get() blocks the calling thread. Calling it on task A's future before task B is even submitted means task B's submission — and therefore its execution — is delayed until task A finishes, silently turning a parallel fan-out into a sequential one with no error or warning.
Q: What problem does ExecutorCompletionService solve that plain Futures don't?
A: Head-of-line blocking. Iterating a list of Futures and calling get() in submission order forces you to wait for each task in that order, even if a later-submitted task actually finished first. ExecutorCompletionService's take() always returns the next task to complete, regardless of submission order.
Q: Does a TimeoutException from a timed get() call stop the underlying task?
A: No. It only means the calling thread stopped waiting. The task keeps running in the background exactly as before — stopping it requires an explicit call to cancel(true) after the timeout.
Want a visual for this concept?
Generate a diagram tailored to “Callable, Future & CompletionService” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →