Introduction
When you hand a task to a thread pool, you rarely want to forget about it. You want its result, or at least to know whether it failed. Since the task runs on another thread, the result is not available when submit() returns. Java solves this with Future: a handle to the outcome of an asynchronous computation.
This article covers what Future represents, how to read its result with and without a timeout, how to cancel a task, and the state methods added in recent JDKs. It also shows where Future falls short. It builds on ExecutorService in Java, which creates most Future objects you will meet.
1. What Is a Future?
A Future<V> represents the result of a task that may not have finished yet. Its Javadoc describes it as “the result of an asynchronous computation” and lists the methods for checking completion, waiting for it, and retrieving the result.
The core methods are:
| Method | Purpose |
|---|---|
get() | Blocks until the task completes, then returns its result |
get(long, TimeUnit) | Same, but gives up after the timeout |
isDone() | Returns true once the task completed normally, failed, or was cancelled |
cancel(boolean) | Attempts to cancel the task |
isCancelled() | Returns true if the task was cancelled before it completed |
You usually get a Future from ExecutorService.submit():
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "report ready";
});
System.out.println("Done yet? " + future.isDone());
System.out.println("Result: " + future.get());
System.out.println("Done yet? " + future.isDone());
executor.shutdown();
Done yet? false
Result: report ready
Done yet? true
The first isDone() call returns false because the task is still sleeping. get() blocks the calling thread until the task finishes.
2. Getting the Result
2.1 Blocking With get()
get() has three possible outcomes:
- It returns the value when the task completes normally.
- It throws
ExecutionExceptionwhen the task threw an exception. The original exception is available throughgetCause(). - It throws
CancellationExceptionwhen the task was cancelled.
It also throws InterruptedException if the waiting thread is interrupted. The ExecutionException wrapper trips up many developers, because the exception you want is the cause, not the exception you catch:
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> {
if (true) throw new IllegalStateException("Database unavailable");
return 42;
});
try {
future.get();
} catch (ExecutionException e) {
System.out.println("Caught: " + e);
System.out.println("Cause: " + e.getCause());
}
executor.shutdown();
Caught: java.util.concurrent.ExecutionException: java.lang.IllegalStateException: Database unavailable
Cause: java.lang.IllegalStateException: Database unavailable
2.2 Waiting With a Timeout
An unbounded get() can hang your thread forever if the task never finishes. The overload with a timeout throws TimeoutException instead. The task keeps running, so you decide what to do next, for example cancel it.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(5_000);
return "too slow";
});
try {
future.get(200, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
System.out.println("Timed out, cancelling");
future.cancel(true);
}
executor.shutdown();
Timed out, cancelling
3. Cancelling a Task
cancel(boolean mayInterruptIfRunning) attempts to cancel the task:
- If the task has not started, it never runs.
- If it is running and you pass
true, the executing thread is interrupted. If you passfalse, a running task is left to finish. - If the task already completed,
cancel()returnsfalseand has no effect.
After a successful cancel(), isDone() and isCancelled() both return true, and get() throws CancellationException.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
System.out.println("Task interrupted");
}
});
Thread.sleep(100);
System.out.println("cancel() returned " + future.cancel(true));
System.out.println("isCancelled: " + future.isCancelled());
try {
future.get();
} catch (CancellationException e) {
System.out.println("get() threw CancellationException");
}
executor.shutdown();
cancel() returned true
isCancelled: true
get() threw CancellationException
Task interrupted
The order of the last two lines can vary, because the cancelled task prints from its own thread. Also note that interruption is cooperative: the task only stops if it responds to the interrupt, as the Thread.sleep() call does here. A task stuck in a busy loop that never checks Thread.currentThread().isInterrupted() keeps running after cancel(true).
4. Inspecting a Completed Future
Calling get() forces you to handle two checked exceptions even when you know the task is done. Since Java 19, Future has three methods for that case:
state()returns aFuture.State:RUNNING,SUCCESS,FAILEDorCANCELLED.resultNow()returns the result of a task that completed normally, and throwsIllegalStateExceptionotherwise.exceptionNow()returns the exception of a task that failed, and throwsIllegalStateExceptionotherwise.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> ok = executor.submit(() -> 7 * 6);
Future<Integer> failed = executor.submit(() -> {
throw new IllegalArgumentException("bad input");
});
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(ok.state() + " -> " + ok.resultNow());
System.out.println(failed.state() + " -> " + failed.exceptionNow());
SUCCESS -> 42
FAILED -> java.lang.IllegalArgumentException: bad input
Because the executor was shut down and awaited first, both tasks are guaranteed to be complete when these methods are called. exceptionNow() returns the original exception directly, with no ExecutionException wrapper.
5. FutureTask
FutureTask<V> is the standard implementation of Future. It also implements Runnable, so you can run it on a plain Thread or pass it to an executor. This is useful when you want a Future without an ExecutorService:
FutureTask<Integer> task = new FutureTask<>(() -> {
return 10 + 20;
});
new Thread(task).start();
System.out.println("Result: " + task.get());
Result: 30
ExecutorService.submit() wraps your task in a FutureTask for you, so most code never creates one directly.
6. Limitations of Future
Future is deliberately small, and that has costs:
- Blocking retrieval. The only way to get the value is to wait for it with
get(), which ties up the calling thread. - No completion callbacks. You cannot say “run this when the result arrives”.
- No composition. You cannot chain two futures or combine several without blocking on each one.
- No manual completion. You cannot complete a
Futureyourself from outside the task.
CompletableFuture, which implements Future, addresses each of these gaps: it lets you attach callbacks, chain and combine futures, and complete a future yourself. CompletableFuture in Java covers it in detail.
7. Best Practices
7.1 Always Use a Timeout in Production Code
A plain get() can block a request thread indefinitely if a downstream task hangs. Prefer get(timeout, unit) and decide explicitly what a timeout means for your caller.
7.2 Unwrap ExecutionException
Treat ExecutionException as a wrapper. Inspect getCause() to decide how to react, and rethrow or translate the cause instead of the wrapper.
7.3 Restore the Interrupt Flag
When get() throws InterruptedException, either propagate it or call Thread.currentThread().interrupt() before handling it. Throwing InterruptedException clears the thread’s interrupt flag, so catching it without doing either hides a shutdown request from the rest of your code.
To see the difference, you need a task that is still running and a caller thread that gets interrupted while it waits in get(). The helper runAndInterrupt() starts the caller on its own thread, interrupts it 100 ms later, and waits for it to end:
Future<String> slowTask = executor.submit(() -> {
Thread.sleep(5_000);
return "slow result";
});
void runAndInterrupt(Runnable caller) throws InterruptedException {
Thread thread = new Thread(caller);
thread.start();
Thread.sleep(100); // let the caller block in get()
thread.interrupt(); // for example, a shutdown request
thread.join();
}
The following two callers wait on slowTask. The first swallows the interrupt, and the second restores it:
// Don't: swallow the interrupt
runAndInterrupt(() -> {
try {
slowTask.get();
} catch (InterruptedException e) {
// swallowed: nothing done here
} catch (ExecutionException e) {
throw new IllegalStateException(e.getCause());
}
System.out.println("Swallowed: interrupt still visible? " + Thread.currentThread().isInterrupted());
});
// Do: restore the interrupt
runAndInterrupt(() -> {
try {
slowTask.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
} catch (ExecutionException e) {
throw new IllegalStateException(e.getCause());
}
System.out.println("Restored: interrupt still visible? " + Thread.currentThread().isInterrupted());
});
Swallowed: interrupt still visible? false
Restored: interrupt still visible? true
With the flag restored, any code that runs after the catch block can still see the shutdown request, for example, a loop that checks isInterrupted() or the next blocking call. When the interrupt is swallowed, the request is lost.
Both callers discard the exception object, and that is fine: an InterruptedException carries no useful information beyond the interrupt itself, so restoring the flag keeps everything that matters. When the method signature allows it, rethrowing InterruptedException is even better, because it leaves the decision to the caller. Here the Runnable lambda cannot throw a checked exception, so restoring the flag is the right choice.
7.4 Make Tasks Respond to Interruption
cancel(true) is only a request: it interrupts the thread running the task, and the task decides what to do about it. Check the interrupt status in long loops and call interruptible blocking methods so cancellation takes effect.
To see the difference, you need a task that is cancelled while it is still busy. The helper cancelAfter100Ms() submits a task, calls cancel(true) after 100 ms, and waits until the task has really ended. The helper busyWork() simulates one item of CPU work: it keeps the CPU busy for about 10 ms with no blocking call that could react to the interrupt:
void cancelAfter100Ms(Runnable task) throws InterruptedException {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(task);
Thread.sleep(100);
future.cancel(true);
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
}
void busyWork() {
long end = System.nanoTime() + 10_000_000;
while (System.nanoTime() < end) {
// spin
}
}
The following two tasks each process 50 items. The first ignores the interrupt, and the second checks it before each item:
// Don't: ignore the interrupt
cancelAfter100Ms(() -> {
for (int i = 0; i < 50; i++) {
busyWork(); // CPU work that never checks the flag
}
System.out.println("Ignoring task: processed all 50 items despite cancel(true)");
});
// Do: check the flag
cancelAfter100Ms(() -> {
for (int i = 0; i < 50; i++) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("Cooperative task: stopped early after cancel(true)");
return;
}
busyWork();
}
System.out.println("Cooperative task: processed all 50 items");
});
Ignoring task: processed all 50 items despite cancel(true)
Cooperative task: stopped early after cancel(true)
For the first task, cancel(true) still returns true and isCancelled() reports true, yet the task keeps using its thread until the loop ends. Only the task that checks the flag actually stops.
7.5 Don’t Call get() Inside the Same Pool
If a task running in a fixed-size pool blocks on a Future for another task submitted to that same pool, all threads can end up waiting and no thread is left to run the awaited task. This is a form of thread starvation deadlock.
A pool with a single thread makes it easy to see:
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<String> outer = pool.submit(() -> {
Future<String> inner = pool.submit(() -> "inner result"); // queued behind outer
return inner.get(1, TimeUnit.SECONDS); // the only thread waits for itself
});
try {
System.out.println("Outer result: " + outer.get());
} catch (ExecutionException e) {
System.out.println("Outer task failed: " + e.getCause());
}
Outer task failed: java.util.concurrent.TimeoutException
The inner task sits in the queue because the pool’s only thread is busy running the outer task, which is waiting for the inner one. The timeout makes the failure visible: with a plain get(), the program would hang forever. A larger pool only moves the limit: with N threads, N outer tasks running at once deadlock the same way.
The fix is to make sure the awaited task never competes with its caller for the same threads. Here, the inner task is submitted to a separate executor:
ExecutorService outerPool = Executors.newFixedThreadPool(1);
ExecutorService innerPool = Executors.newFixedThreadPool(1);
Future<String> fixedOuter = outerPool.submit(() -> {
Future<String> inner = innerPool.submit(() -> "inner result"); // runs on its own pool
return inner.get(1, TimeUnit.SECONDS);
});
System.out.println("Outer result: " + fixedOuter.get());
Outer result: inner result
Running the inner work directly in the outer task, or chaining the steps with CompletableFuture instead of blocking, avoids the problem as well.
Conclusion
A Future is a handle to a result that is computed elsewhere. You read it with get(), preferably with a timeout, cancel it with cancel(), and inspect a finished one with state(), resultNow() and exceptionNow() on Java 19 and later. Remember that task failures arrive wrapped in ExecutionException and that cancellation relies on the task cooperating with interruption. When blocking retrieval becomes a limitation, CompletableFuture is the next step.
You can find the complete code for this article on GitHub
