By WebNest Studio Engineering Team
Java - Core Tutorial
Callable, Future, and CompletableFuture
The java.util.concurrent package gives Java richer tools than raw Thread and Runnable for running work asynchronously and getting a result back. Callable extends the idea of a background task to one that can return a value and throw checked exceptions, Future represents the eventual result of that task, and CompletableFuture builds on both to let you chain and combine asynchronous operations without blocking the calling thread at every step.
Why it matters
A Runnable cannot return a result. Callable and Future fix that, but Future.get() blocks the calling thread. CompletableFuture goes further: it lets you describe what should happen when a result arrives and combine several results, without blocking. It is the basis of most asynchronous Java code written today.
Callable vs Runnable
Runnable's single method, run(), returns void and cannot throw any checked exception. Callable<V>'s single method, call(), returns a value of type V and is allowed to throw a checked Exception. This makes Callable the right choice whenever a background task needs to produce a result (a computed number, a fetched record) or may naturally fail with a checked exception (such as an I/O operation), which Runnable simply cannot express.
Submitting a Callable and Getting a Future
An ExecutorService (obtained, for example, from Executors.newSingleThreadExecutor() or Executors.newFixedThreadPool(n)) manages a pool of worker threads and can run tasks on them. Calling executor.submit(callable) schedules the Callable to run on one of those threads and immediately returns a Future<V> — a handle representing a result that may not exist yet. The calling thread can continue doing other work while the task runs in the background.
Future.get() and Blocking
Calling future.get() retrieves the task's result once it is available. If the task has already finished, get() returns immediately; if it is still running, get() blocks — the calling thread pauses — until the task completes (or an overloaded, timed version of get(timeout, unit) gives up after a timeout and throws a TimeoutException instead). If the Callable itself threw an exception, get() wraps it in an ExecutionException and rethrows that. Because plain Future only offers this blocking retrieval, and no built-in way to chain further work onto a Future's eventual result, composing multiple dependent async steps with raw Future quickly becomes awkward.
CompletableFuture: Composing Async Work Without Blocking
CompletableFuture<T> implements Future<T> but adds a large API for describing what should happen next, without forcing the calling thread to block and wait first. CompletableFuture.supplyAsync(supplier) starts an asynchronous computation and returns immediately. thenApply(function) transforms the eventual result once it arrives. thenCombine(otherFuture, function) waits for two independent CompletableFutures and combines their results together. exceptionally(function) supplies a fallback value if the computation completed with an exception instead of a result, letting you recover gracefully rather than propagating the failure. These methods return new CompletableFutures, so calls can be chained fluently to describe an entire asynchronous pipeline before any blocking call is made.
Examples
Submitting a Callable to an ExecutorService and blocking on Future.get()
import java.util.concurrent.*;
public class CallableFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<Integer> task = () -> {
Thread.sleep(100);
return 10 * 5;
};
Future<Integer> future = executor.submit(task);
System.out.println("Task submitted, doing other work...");
Integer result = future.get(); // blocks until the task finishes
System.out.println("Result: " + result);
executor.shutdown();
}
}
Task submitted, doing other work...
Result: 50
Composing async operations with CompletableFuture: thenCombine and exceptionally
import java.util.concurrent.CompletableFuture;
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<Integer> priceFuture = CompletableFuture.supplyAsync(() -> 200);
CompletableFuture<Integer> taxFuture = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> totalFuture = priceFuture
.thenCombine(taxFuture, (price, tax) -> price + tax)
.thenApply(total -> total * 1);
System.out.println("Total: " + totalFuture.get());
CompletableFuture<Integer> safeFuture = CompletableFuture
.supplyAsync(() -> 10 / 0)
.exceptionally(ex -> -1);
System.out.println("Safe result: " + safeFuture.get());
}
}
Total: 220
Safe result: -1
Common Mistakes
- Using Runnable when a background task actually needs to return a value or throw a checked exception — Callable exists exactly for that case.
- Calling future.get() right after submit() with no other work in between, which defeats the purpose of running the task asynchronously since the calling thread just blocks immediately.
- Forgetting to call shutdown() (or shutdownNow()) on an ExecutorService, leaving its threads alive and preventing the JVM from exiting normally.
- Chaining CompletableFuture stages without ever calling exceptionally(), handle(), or whenComplete() — an unhandled exception inside the chain silently propagates until something finally calls get() and gets an ExecutionException.
Exercise
Build a small asynchronous pipeline with CompletableFuture: produce 20 asynchronously, double it, then add the result of a second asynchronous task that produces 2. Print the final value.
Expected output: 42
import java.util.concurrent.CompletableFuture;
public class AsyncPipeline {
public static void main(String[] args) {
CompletableFuture<Integer> first = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> second = CompletableFuture.supplyAsync(() -> 2);
// TODO: double the first value, then combine it with the second by adding them
// TODO: wait for the result with join() and print it
}
}
Show hints
thenApplytransforms a result;thenCombinemerges two futures with a two-argument function.Integer::sumis a ready-made function that adds two Integers.
Show solution
import java.util.concurrent.CompletableFuture;
public class AsyncPipeline {
public static void main(String[] args) {
CompletableFuture<Integer> first = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> second = CompletableFuture.supplyAsync(() -> 2);
CompletableFuture<Integer> result = first
.thenApply(value -> value * 2)
.thenCombine(second, Integer::sum);
System.out.println(result.join()); // 42
}
}
Quick quiz
1. How does Callable differ from Runnable?
- A. Callable runs faster
- B. Callable returns a value and can throw a checked exception
- C. Callable cannot be used with a thread pool
- D. Runnable returns a value
Show answer
B. Callable's call() method returns a result and is declared to throw Exception. Runnable's run() returns void.
2. What does Future.get() do if the task has not finished yet?
- A. Returns null
- B. Throws an exception immediately
- C. Blocks the calling thread until the result is ready
- D. Cancels the task
Show answer
C. get() waits for completion. A version with a timeout throws TimeoutException if the wait is too long.
3. What is exceptionally() used for on a CompletableFuture?
- A. To throw an exception
- B. To run a stage on another thread
- C. To cancel the future
- D. To supply a fallback value if an earlier stage failed
Show answer
D. exceptionally receives the exception and returns a replacement result, letting the pipeline recover.
Interview questions
What is the difference between Future and CompletableFuture?
Show answer
A Future represents a result that will be available later, and the only way to use it is to call get(), which blocks. A CompletableFuture can be completed manually, lets you attach callbacks that run when the result arrives, chains and combines several asynchronous steps, and handles errors within the chain — all without blocking a thread.
What is the difference between thenApply() and thenCompose()?
Show answer
thenApply takes a function that turns the result into another value, like map. thenCompose takes a function that returns another CompletableFuture and flattens it, like flatMap. Use thenCompose when the next step is itself asynchronous; with thenApply you would end up with a future inside a future.
Key Points to Remember
- Callable returns a value from call() and can throw checked exceptions; Runnable returns void and cannot.
- ExecutorService.submit(callable) runs the task asynchronously and immediately returns a Future representing its eventual result.
- Future.get() blocks the calling thread until the result is ready (or a timeout elapses with the overloaded version), and wraps task exceptions in an ExecutionException.
- CompletableFuture supports non-blocking composition of async steps via thenApply, thenCombine, and graceful failure handling via exceptionally.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs plain Java 17. Frameworks such as Spring, and databases, are not available here. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering