Appearance
Callable, Future, and CompletableFuture in Java
The Problem That Started Everything
Imagine you are a restaurant manager. You send a chef to the kitchen to cook a complex dish. The chef disappears into the kitchen and you just stand there with no idea whether the dish is ready, whether something went wrong, or when you can expect it. That is exactly the problem Java developers faced when they first started running tasks in background threads.
In the previous chapter you learned about ThreadPoolExecutor. You saw how you can submit a task to a thread pool and the main thread keeps running without waiting. That is asynchronous execution. But once you submitted that task, you were essentially flying blind. You had no way to ask: is the task done? Did it throw an exception? What was the result?
That is the exact gap that Future, Callable, and ultimately CompletableFuture were built to fill. This chapter covers all three, explains how they fit together, and walks you through every method you need to know for interviews and real projects.
Why Future Exists
When your main thread submits a task to a ThreadPoolExecutor, the pool grabs a thread and starts working on that task in the background. The main thread continues executing its own code. That background thread is working independently, producing some result or perhaps throwing an error.
At some later point in time, the main thread might want to know: has that task finished? What did it return? Did it fail? Before Future existed, there was simply no clean answer to these questions. The thread reference was gone and the main thread had moved on.
The Future interface solves this by giving the caller a handle on the asynchronous task. When you call the submit method on a ThreadPoolExecutor, instead of getting nothing back, you get a Future object. Think of it like a ticket at a dry cleaner. You drop off your clothes and they hand you a ticket. You walk away and live your life. Later you come back with the ticket and use it to ask: is my order ready? Can I pick it up now? The ticket is your Future object.
The Five Methods of Future
The Future interface gives you exactly five methods to interact with a background task.
cancel(boolean mayInterruptIfRunning)
This method asks the thread pool to cancel the task you submitted. You pass true to allow the running thread to be interrupted. If the task has already completed, cancel returns false because there is nothing left to cancel. If the task is waiting in the queue or still in progress, the pool will attempt to stop it and return true.
isCancelled()
This is a simple status check. It returns true only if the task was successfully cancelled before it completed normally. If the task finished on its own and then you called cancel, isCancelled returns false.
isDone()
This returns true if the task is finished in any way: normal completion, an exception was thrown, or the task was cancelled. The word "done" means the task is no longer running, regardless of how it ended. While the task is still in progress, isDone returns false.
get()
This is the blocking method. When your main thread calls get on a Future, the main thread stops and waits until the background task finishes. Only after the task is completely done does get return the result. If you have a task that takes twenty minutes, calling get will block your main thread for twenty minutes. That is a very important thing to keep in mind.
get(long timeout, TimeUnit unit)
This is the timeout variant of get. Instead of waiting forever, you tell the Future: I will wait at most three seconds. If the task finishes within those three seconds, great, you get the result. If the task is still running after three seconds, Java throws a TimeoutException. You catch that exception and decide what to do next. Maybe you log it. Maybe you try again later. The choice is yours.
Seeing Future in Action
Here is a concrete example that demonstrates all five methods:
java
import java.util.concurrent.*;
public class FutureExample {
public static void main(String[] args) throws InterruptedException {
// Create a thread pool with one thread
ExecutorService pool = new ThreadPoolExecutor(
1, 1, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
// Submit a Runnable task and hold the Future reference
Future<?> futureObject = pool.submit(() -> {
try {
// This task sleeps for 7 seconds to simulate heavy work
Thread.sleep(7000);
System.out.println("Background task completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Main thread checks status immediately after submitting
System.out.println("Is task done? " + futureObject.isDone()); // prints false
try {
// Wait only 2 seconds. Task takes 7, so TimeoutException fires
futureObject.get(2, TimeUnit.SECONDS);
} catch (TimeoutException e) {
System.out.println("Timeout exception happened after 2 seconds");
} catch (ExecutionException e) {
System.out.println("Task threw an exception: " + e.getMessage());
}
// Now wait indefinitely for the task to finish
try {
futureObject.get(); // blocks until task finishes
} catch (ExecutionException e) {
System.out.println("Task threw an exception: " + e.getMessage());
}
// Now check again
System.out.println("Is task done now? " + futureObject.isDone()); // prints true
System.out.println("Was it cancelled? " + futureObject.isCancelled()); // prints false
pool.shutdown();
}
}Notice what happens here. The main thread submits the task and immediately checks isDone. The task sleeps for seven seconds so isDone returns false right away. Then main tries to get the result but only waits two seconds. The TimeoutException fires. Then main calls the no argument get and blocks until the full seven seconds are up. After that, isDone is true and isCancelled is false because the task completed normally.
How Future Works Under the Hood
When you call submit on a ThreadPoolExecutor, something interesting happens internally. Java wraps your Runnable inside a FutureTask object. FutureTask implements the RunnableFuture interface, which in turn extends both Runnable and Future. So FutureTask is simultaneously a Runnable that the thread pool can execute AND a Future that you can query for results.
FutureTask also maintains an internal state. The state goes through transitions like NEW, COMPLETING, NORMAL, EXCEPTIONAL, CANCELLED, and INTERRUPTING. The thread pool keeps updating this state as the task progresses. When you call isDone, it checks this state. When you call get, it waits for the state to reach a terminal value.
This is why you can hold a Future reference and check on it later. The FutureTask object lives on the heap, and both the background thread and your main thread have access to it.
Runnable vs Callable: The Critical Difference
Now you understand Future. But you may have noticed something. When you use a Runnable, the get method returns null. Every single time. That is because Runnable's run method has a void return type. It cannot return a value.
ThreadPoolExecutor actually has three overloaded versions of submit:
submit(Runnable task)
submit(Runnable task, T result)
submit(Callable<T> task)Version 1: submit(Runnable)
Use this when you do not need a result back. The Future type becomes Future<?> with a wildcard because Java does not know what type to put there. When you call get on this Future, it always returns null. You can still use the Future to check isDone, isCancelled, and to block with get until the task completes.
java
Future<?> f = pool.submit(() -> System.out.println("Just doing work"));
Object result = f.get(); // result is always nullVersion 2: submit(Runnable, T result)
This is a creative workaround for when you want to use a Runnable but still get something useful from get. You pass a shared object to the submit call. The Runnable updates that shared object during its execution. When the task finishes, get returns that same object.
java
List<Integer> sharedOutput = new ArrayList<>();
// Pass the shared object to the Runnable through the constructor
MyRunnable myTask = new MyRunnable(sharedOutput);
// The T result parameter is sharedOutput
Future<List<Integer>> future = pool.submit(myTask, sharedOutput);
// This blocks until the task finishes
List<Integer> result = future.get();
// result and sharedOutput point to the same list, now populated
System.out.println(result.get(0)); // prints 300This works because Java passes the sharedOutput reference into the FutureTask. When the Runnable runs and mutates the list, it mutates the same list that the FutureTask holds a reference to. So get returns that same reference with its updated contents.
This is a workaround though. The cleaner solution is Callable.
Version 3: submit(Callable)
Callable is an interface with a single method called call. Unlike Runnable's run method, call has a return type. It also declares that it can throw a checked exception, which is a bonus because you do not need to wrap exceptions inside the task.
java
Future<List<Integer>> future = pool.submit(() -> {
List<Integer> output = new ArrayList<>();
output.add(300);
return output; // Callable returns a value directly
});
List<Integer> result = future.get();
System.out.println(result.get(0)); // prints 300Much cleaner. No shared objects, no workarounds, no confusion. The task computes a value and returns it. The Future holds that value. You call get and you receive it.
The one sentence summary: Runnable cannot return a value. Callable can return a value and can throw checked exceptions.
The Problem with Future
Future is useful, but it has a serious limitation: the only way to get the result is to call get, which blocks the calling thread. You cannot say "when the task is done, automatically call this function." You cannot chain operations together. If task A's output feeds into task B, you would need to block waiting for A, then submit B, then block waiting for B. Your main thread spends most of its time sleeping.
There is also no built in way to handle errors gracefully in a chain. If the background task throws an exception, it gets wrapped in an ExecutionException when you call get. You catch it there, but by that point you have already blocked.
Future also has no allOf or anyOf capability. If you have ten background tasks running in parallel, there is no clean way to say "notify me when all ten are done" without blocking on each one individually.
CompletableFuture was introduced in Java 8 specifically to solve all of these problems.
CompletableFuture: The Grown Up Future
CompletableFuture implements the Future interface. That means everything Future can do, CompletableFuture can do too. But it also implements CompletionStage, which gives it the ability to chain operations, handle errors, and combine multiple futures together without blocking.
Think of Future as a basic TV remote with only a power button. CompletableFuture is a universal remote with channels, volume, streaming apps, and programmable macros. Same basic idea, vastly more capability.
Here is the key mental model: every method on CompletableFuture that starts with "then" attaches a follow up action. When the previous stage completes, the next stage automatically starts. You never need to call get just to pass a result to the next step.
supplyAsync: Starting an Asynchronous Operation
supplyAsync is how you kick off an asynchronous computation with CompletableFuture. It takes a Supplier (a functional interface that takes nothing and returns a value) and runs it in a background thread.
java
ExecutorService pool = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// This runs in a background thread from the pool
System.out.println("Running in: " + Thread.currentThread().getName());
return "Task Completed";
}, pool);
// You can still use get if you want to block and fetch the result
String result = future.get();
System.out.println(result); // prints Task CompletedIf you do not pass an executor, CompletableFuture uses the common ForkJoinPool by default. That pool is shared by the whole JVM (parallel streams use it too) and has a fixed size of one fewer than the number of CPU cores. The downside is you have no control over it, and a slow or blocking task (such as a database or HTTP call) ties up a thread that everyone else needs. For I/O work, or whenever you need to control the thread count, always pass your own executor.
runAsync: When You Do Not Need a Result
runAsync is the equivalent of supplyAsync but for tasks that do not return anything. You pass a Runnable instead of a Supplier. The resulting CompletableFuture is of type Void.
java
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Doing work with no return value");
}, pool);
future.get(); // blocks until done, returns nullthenApply: Transforming the Result
thenApply lets you transform the result of a CompletableFuture into something else. It works like the map operation on a stream. The previous stage finishes, hands you its result, you do something with it, and you return a new value. The whole thing returns a new CompletableFuture containing the transformed value.
java
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("supplyAsync on: " + Thread.currentThread().getName());
return "Hello";
}, pool).thenApply(value -> {
System.out.println("thenApply on: " + Thread.currentThread().getName());
return value + " World"; // transforms "Hello" to "Hello World"
});
System.out.println(future.get()); // prints Hello WorldCritical thread behavior: thenApply does not hand the work to a new thread. It usually runs on the same thread that completed supplyAsync, right after it. (If supplyAsync has already finished when you attach thenApply, the thread that calls thenApply runs it instead.)
thenApplyAsync: If you want a different thread to handle the thenApply step, use thenApplyAsync. This releases the thread that completed supplyAsync back to the pool and picks another thread for the next step.
java
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("supplyAsync on: " + Thread.currentThread().getName());
return "Hello";
}, pool).thenApplyAsync(value -> {
System.out.println("thenApplyAsync on: " + Thread.currentThread().getName());
return value + " World";
}); // no pool passed, so ForkJoinPool picks up this stepRun this and you will see supplyAsync runs on pool one thread one, while thenApplyAsync runs on a ForkJoinPool thread. Two different threads handled the two stages.
thenAccept: Consuming Without Returning
thenAccept is designed to be the last step in a chain. It consumes the result of the previous stage but returns nothing. Under the hood it returns CompletableFuture<Void>. Because it returns Void, chaining more thenApply calls after it does not make sense since there is no value to transform.
java
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
return "Final Result";
}, pool).thenAccept(value -> {
System.out.println("Received and consuming: " + value);
// No return statement. This is the end of the chain.
});
future.get(); // waits for everything to finishUse thenAccept for side effects like saving to a database, logging, or sending a notification. After thenAccept there is nothing left to chain.
thenAcceptAsync follows the same rule as the other Async variants: a new thread handles this step instead of reusing the thread from the previous stage.
thenRun: Running After Completion Without the Result
thenRun is similar to thenAccept but even simpler. It does not receive the result of the previous stage at all. It just runs a Runnable after the previous stage finishes. Use this when you need to trigger an action after completion but you do not care what the result was.
java
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
return "Some result";
}, pool).thenRun(() -> {
// Does not receive the "Some result" value
System.out.println("Previous stage is done. Cleaning up.");
});
future.get();thenCompose: Ordering Dependent Async Operations
Here is where things get interesting. Suppose task B depends on the result of task A. You might think thenApply would work. And sometimes it does. But thenApply expects a Function that returns a plain value, not another CompletableFuture. If you try to use thenApply to start another async task, you end up with a CompletableFuture<CompletableFuture<String>>, which is a nested future and very awkward to work with.
thenCompose is the solution. It flattens that nesting. You pass a Function that returns a CompletableFuture, and thenCompose unwraps it for you so the result is a clean CompletableFuture<String>.
More importantly, thenCompose guarantees ordering. If you chain multiple thenCompose calls, they execute strictly in order: the first completes, then the second starts, then the third. This is different from having multiple independent supplyAsync calls which run in parallel with no ordering guarantee.
java
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
return "Hello";
}, pool).thenCompose(value -> {
// This starts a NEW async operation that depends on "Hello"
return CompletableFuture.supplyAsync(() -> {
return value + " World";
}, pool);
}).thenCompose(value -> {
return CompletableFuture.supplyAsync(() -> {
return value + " All";
}, pool);
});
System.out.println(result.get()); // always prints Hello World All in that orderNo matter how many thenCompose calls you chain, the ordering is always preserved, because each step can only start once the previous step has produced its value. The same holds with thenComposeAsync.
thenCompose vs thenApply: Use thenApply when the next step is a simple synchronous transformation. Use thenCompose when the next step is itself an asynchronous operation that returns a CompletableFuture.
thenCombine: Merging Two Independent Futures
Sometimes you have two completely independent asynchronous tasks running in parallel, and you want to combine their results when both are done. thenCombine is built for exactly this scenario.
java
// Task one runs independently, returns an Integer
CompletableFuture<Integer> taskOne = CompletableFuture.supplyAsync(() -> {
// Simulate some work
return 10;
}, pool);
// Task two runs independently, returns a String
CompletableFuture<String> taskTwo = CompletableFuture.supplyAsync(() -> {
return "K";
}, pool);
// Combine both results when both are done
CompletableFuture<String> combined = taskOne.thenCombine(taskTwo, (intResult, strResult) -> {
// intResult is 10, strResult is "K"
return intResult + strResult; // produces "10K"
});
System.out.println(combined.get()); // prints 10KThread one works on taskOne, thread two works on taskTwo simultaneously. Neither waits for the other. When both complete, thenCombine fires the BiFunction, which receives both results and produces the final combined value.
thenCombineAsync spawns a new thread to run the combining function instead of reusing one of the completing threads.
allOf: Waiting for All Futures to Complete
If you have multiple CompletableFuture instances and you want to wait until all of them are done before proceeding, use allOf. It takes a varargs array of CompletableFuture objects and returns a CompletableFuture<Void> that completes only after every single one of the input futures completes.
java
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Result 1", pool);
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "Result 2", pool);
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "Result 3", pool);
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
// Block until all three are done
all.get();
// Now all futures are guaranteed to be complete
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());Note that allOf returns Void, so you cannot directly get a combined result from it. You call get on each individual future after the allOf completes.
A practical pattern is to collect results into a list after allOf finishes:
java
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.get();
List<String> results = List.of(f1.join(), f2.join(), f3.join());anyOf: Racing Multiple Futures
anyOf is the opposite of allOf. It completes as soon as ANY one of the provided futures completes. You use it when you send the same request to multiple services and want to use whichever responds first.
java
CompletableFuture<Object> first = CompletableFuture.anyOf(f1, f2, f3);
// Completes as soon as the fastest of f1, f2, f3 finishes
Object result = first.get();
System.out.println("First to finish: " + result);anyOf returns CompletableFuture<Object> because the futures might have different types. You will need to cast the result to the appropriate type.
exceptionally: Recovering From Errors
When a CompletableFuture stage throws an exception, that exception propagates through the chain. All following thenApply, thenAccept, and thenCompose stages are skipped. The exception arrives at your get call wrapped in an ExecutionException (inside the chain, handlers usually receive it wrapped in a CompletionException).
But what if you want to recover gracefully from an error mid chain? exceptionally lets you define a fallback function that runs only if an exception occurred. If no exception occurred, exceptionally is skipped entirely.
java
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Something went wrong");
return "Success";
}, pool).exceptionally(ex -> {
System.out.println("Caught: " + ex.getMessage());
return "Fallback Value"; // return a default instead of crashing
});
System.out.println(result.get()); // prints Fallback ValueUse exceptionally at the end of your chain or at any point where you want to insert a safety net.
handle: Processing Both Success and Failure
handle is more flexible than exceptionally. It always runs, regardless of whether the previous stage succeeded or failed. You receive two parameters: the result (null if an exception occurred) and the exception (null if everything succeeded). You then return a value based on whichever you have.
java
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
return "Success Result";
}, pool).handle((value, ex) -> {
if (ex != null) {
System.out.println("Error: " + ex.getMessage());
return "Default";
}
return value.toUpperCase();
});
System.out.println(result.get()); // prints SUCCESS RESULTThink of handle as a try catch finally block for a single stage in your chain. It gives you full control over the outcome regardless of what happened in the previous step.
Common Pitfalls
Not calling get or join when you need the result: CompletableFuture chains are lazy in the sense that they all run asynchronously. If your main method returns before get is called, the background threads may be killed by the JVM before they finish.
Using thenApply when you need thenCompose: If your thenApply function calls another async method that returns a CompletableFuture, you end up with CompletableFuture<CompletableFuture<T>>. Calling get on that gives you the inner future, not the result. Switch to thenCompose.
No executor with supplyAsync in production code: The common ForkJoinPool is shared by all code in the JVM including parallel streams. If your task is long running or blocking, it starves other unrelated code. Always use a dedicated executor in production systems.
Forgetting that get throws checked exceptions: Every call to get requires a try catch block for InterruptedException and ExecutionException. In lambda chains this gets verbose. Use join inside lambdas and save get for the outermost call in your main flow.
Calling get on a future that itself is blocked on another get: This can cause a deadlock if the thread pool is exhausted. Prefer the non blocking async chain style and use get only at the very end where you actually need the result to proceed.
Putting It All Together
Here is a real world style example that uses several CompletableFuture methods together. Imagine fetching user data and order data in parallel, combining them, and handling errors:
java
ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> {
// Simulate fetching user from database
return "User:Alice";
}, pool);
CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> {
// Simulate fetching orders from database
return "Order:12345";
}, pool);
// Combine both results when both are ready
CompletableFuture<String> combined = userFuture.thenCombine(orderFuture,
(user, order) -> user + ", " + order
);
// Transform the combined result
CompletableFuture<String> processed = combined.thenApply(data -> {
return "Processed: [" + data + "]";
});
// Handle any errors gracefully
CompletableFuture<String> safe = processed.exceptionally(ex -> {
return "Error occurred: " + ex.getMessage();
});
// Print the final result
safe.thenAccept(System.out::println);
// Wait for everything to finish before exiting
safe.get();
pool.shutdown();Both userFuture and orderFuture run simultaneously. thenCombine waits for both and merges them. thenApply transforms the merged string. exceptionally catches any failures. thenAccept prints the final result. The whole chain is non blocking until the final get at the end.
Spring's @Async methods can return a CompletableFuture: see @Async Part 2.
Check yourself
- What problem does
Futuresolve? Name its five methods. - What is the difference between
get()andget(timeout, unit)? - What are the three
submitoverloads, and what doesget()return for a plainRunnable? - What is the difference between
RunnableandCallable? - What are the limitations of
FuturethatCompletableFuturefixes? - Which pool does
supplyAsyncuse without an executor, and why pass your own for I/O? - What is the difference between
thenApply,thenComposeandthenCombine? - How do
allOf,anyOf,exceptionallyandhandlediffer?
Answers
- It gives the caller a handle on a background task.
cancel,isCancelled,isDone,get(),get(timeout, unit). get()blocks until the task finishes; the timed version throwsTimeoutExceptionif the task is not done in time.submit(Runnable)(get returnsnull),submit(Runnable, T result)(get returns that object),submit(Callable<T>)(get returns the computed value).Runnable.run()returns nothing and cannot throw checked exceptions;Callable.call()returns a value and can throw checked exceptions.- Getting the result blocks, you cannot chain follow-up steps, there is no built-in error handling in a chain, and there is no way to combine futures (all/any).
- The common
ForkJoinPool, shared by the whole JVM. Blocking calls there starve every other async task and parallel stream. thenApplytransforms the result with a normal function;thenComposechains a step that itself returns aCompletableFuture(no nesting);thenCombinemerges the results of two independent futures.allOfcompletes when all futures complete;anyOfwhen the first one does.exceptionallyruns only on failure and supplies a fallback;handlealways runs and receives both the result and the exception.