Skip to content
CalliCoder

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.