Java Callable and Future Tutorial
Published Updated Java 12 min read
What Callable adds over Runnable, and the four things Future cannot do — no callbacks, no composition, no manual completion, no exception handling without blocking — with the CompletableFuture equivalent for each.
Callable and Future are the 2004 answer to “run this and give me the result later”. They are still
the right tool for a bounded set of jobs, and they have four specific limitations that motivated
CompletableFuture a decade later.
This covers what each does, then those limits side by side with what replaced them, which is the more
useful way to know Future today. Submitting work and sizing pools is covered separately in
ExecutorService and thread pools.
Written against Java 17.
Callable against Runnable
Runnable task = () -> log.info("side effect only");
Callable<Integer> job = () -> {
Thread.sleep(100); // checked exception, no try/catch needed
return 42;
};
Two differences, and the second matters more than people expect.
Callable returns a value. Runnable.run() is declared void, so the only way out is a side
effect or a shared variable.
Callable may throw a checked exception. Callable.call() declares throws Exception;
Runnable.run() declares nothing. So every checked exception inside a Runnable has to be caught and
wrapped on the spot:
// Runnable: forced to handle it here
Runnable r = () -> {
try {
Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
// Callable: propagates to whoever calls get()
Callable<String> c = () -> Files.readString(path);
That is why Callable is the better default for anything doing real work, the exception travels to the
caller instead of being swallowed or wrapped at the point it occurred.
Both are functional interfaces, so both take a lambda. Executors.callable(runnable) adapts one to the
other when an API demands it.
Future
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> future = pool.submit(job);
future.isDone(); // has it finished, either way?
Integer value = future.get(); // blocks until done
Integer soon = future.get(2, TimeUnit.SECONDS); // TimeoutException
future.cancel(true); // interrupt if running
future.isCancelled();
get() blocks the calling thread and rethrows whatever the task threw, wrapped:
try {
Integer value = future.get(2, TimeUnit.SECONDS);
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // the exception the task actually threw
log.error("task failed", cause);
} catch (TimeoutException e) {
future.cancel(true); // still running — stop it
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag, always
throw new CancellationException("interrupted while waiting");
}
All three catches earn their place.
ExecutionException wraps the real cause. Logging the ExecutionException itself tells you nothing —
always unwrap with getCause().
TimeoutException does not cancel the task. It only means you stopped waiting; the task is still
running and still consuming a thread. Cancel it explicitly unless you want the result later.
InterruptedException clears the interrupt flag when it is thrown. Not restoring it means code further
up the stack cannot tell it was interrupted, and a cancellation quietly stops propagating. Thread .currentThread().interrupt() in that catch block is not optional ceremony.
Cancellation is cooperative
future.cancel(true); // interrupt the thread if the task has started
future.cancel(false); // only prevent it starting; let a running task finish
cancel(true) sets the interrupt flag and throws InterruptedException from blocking calls that
support it. It cannot stop a task that never checks:
// uncancellable: nothing observes the interrupt
pool.submit(() -> {
while (true) {
crunch();
}
});
// cancellable
pool.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
crunch();
}
return null;
});
After a successful cancel, get() throws CancellationException. A RuntimeException, so unlike
ExecutionException the compiler will not remind you to handle it.
invokeAll and invokeAny
List<Callable<String>> tasks = urls.stream()
.map(u -> (Callable<String>) () -> fetch(u))
.toList();
List<Future<String>> all = pool.invokeAll(tasks, 10, TimeUnit.SECONDS);
String first = pool.invokeAny(tasks, 5, TimeUnit.SECONDS);
invokeAll blocks until every task completes, is cancelled, or the timeout expires, and returns
futures in the same order as the input, not completion order. With a timeout, unfinished tasks are
cancelled, so iterate defensively:
for (Future<String> f : all) {
try {
results.add(f.get());
} catch (CancellationException e) {
results.add(null); // timed out
} catch (ExecutionException e) {
log.warn("one task failed", e.getCause());
}
}
invokeAny returns the first successful result and cancels the rest: the hedged-request pattern: ask
three replicas, take whoever answers first.
The four things Future cannot do
This is the part worth remembering, because each one has a direct replacement.
1. No callback. There is no “call me when it is done”. Your options are to block on get() or to
poll isDone() in a loop, and both waste the calling thread.
// Future: block
String result = future.get();
render(result);
// CompletableFuture: continue on completion, without blocking
CompletableFuture.supplyAsync(() -> fetch(url))
.thenAccept(this::render);
2. No composition. Two dependent calls mean blocking between them, which serialises work that could overlap:
// Future: the calling thread waits twice
String userId = pool.submit(() -> lookupUser(email)).get();
Profile p = pool.submit(() -> loadProfile(userId)).get();
// CompletableFuture: a pipeline, no blocking
CompletableFuture.supplyAsync(() -> lookupUser(email))
.thenCompose(id -> CompletableFuture.supplyAsync(() -> loadProfile(id)));
Combining independent results is worse still. With Future you block on both and then merge by hand;
thenCombine expresses it directly.
3. No manual completion. A Future is created by an executor and completed only by its task. You
cannot hand one out and fulfil it later, which is exactly what an event-driven or callback-based API
needs.
CompletableFuture<String> pending = new CompletableFuture<>();
messageBroker.onReply(id, pending::complete); // completed from elsewhere
pending.completeExceptionally(new TimeoutException()); // or failed from elsewhere
That single capability is why CompletableFuture can adapt a callback API into something composable.
4. No exception handling without blocking. You only discover a failure by calling get() and
catching ExecutionException. There is no way to attach a recovery step:
CompletableFuture.supplyAsync(() -> fetch(url))
.exceptionally(e -> cachedFallback()) // recover
.orTimeout(2, TimeUnit.SECONDS) // Java 9+
.whenComplete((v, e) -> metrics.record(e == null));
So when is Future still right?
Genuinely, in two cases.
A bounded batch you wait for anyway. If the calling thread has nothing else to do until every
result is in, invokeAll is clearer than assembling CompletableFuture.allOf and unwrapping it:
List<Future<Report>> futures = pool.invokeAll(reportTasks, 30, TimeUnit.SECONDS);
Working with an API that returns one. ExecutorService, older libraries and some JDK APIs hand you
a Future. If you need to compose from there, adapt at the boundary rather than restructuring
everything:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
try {
return legacyFuture.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CompletionException(e);
} catch (ExecutionException e) {
throw new CompletionException(e.getCause());
}
}, pool);
Note that this consumes a thread to wait, which is the cost of the adapter. Acceptable at a boundary, not as a pattern.
On Java 21, structured concurrency is the third option for the fan-out-then-join shape, and it handles cancellation better than either:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> lookupUser(email));
Supplier<Config> cfg = scope.fork(() -> loadConfig());
scope.join().throwIfFailed();
return combine(user.get(), cfg.get());
} // any failure cancels the siblings automatically
It was a preview API in 21; check its status for your release before relying on it.
Frequently asked questions
What is the difference between Runnable and Callable?
Callable returns a value and may throw a
checked exception; Runnable returns nothing and may not. That second difference is why Callable is
better for real work, the exception reaches the caller.
Why is my exception an ExecutionException?
get() wraps whatever the task threw. Call
getCause() to reach the real one; logging the wrapper alone tells you nothing.
Does a TimeoutException cancel the task?
No. It only means you stopped waiting. The task keeps running and holding a thread, cancel it explicitly.
Why should I re-interrupt in the InterruptedException catch?
Because the exception clears the
flag. Without Thread.currentThread().interrupt(), code above you cannot tell it was interrupted and
cancellation stops propagating.
Can I always cancel a running task?
No. cancel(true) sets the interrupt flag; a task that never
checks isInterrupted() and never makes an interruptible call cannot be stopped.
What does cancel(false) do?
Prevents a task that has not started from starting, and lets a running one finish.
Does invokeAll return futures in completion order?
No, in the order of the input list. With a
timeout, unfinished tasks are cancelled and their get() throws CancellationException.
Can I attach a callback to a Future?
No. That is its main limitation. Use CompletableFuture and
thenApply/thenAccept.
Can I complete a Future myself?
Not a plain Future. CompletableFuture has complete and
completeExceptionally, which is what allows adapting callback-based APIs.
Should I use Future or CompletableFuture in new code?
CompletableFuture for anything composed,
chained, or with a non-blocking failure path. Future with invokeAll for a bounded batch you were
going to wait for anyway.
Where should I go next?
CompletableFuture covers the replacement API in full, and ExecutorService and thread pools covers submitting and sizing.