Why this matters
- Most real tasks produce a result or fail, and
Runnablecan express neither — it cannot return a value and cannot throw a checked exception. - A
Futurethat silently holds an exception nobody ever callsget()on is a genuinely common way to lose an error. - Understanding why
Futureis limited is what makesCompletableFuturefeel like a solution rather than extra API.
Callable
// Runnable: no result, no checked exception
Runnable task = () -> System.out.println("done");
// Callable: returns a value and may throw
Callable<Integer> job = () -> {
Thread.sleep(100); // a checked exception, allowed here
return 42;
};
| Aspect | Runnable | Callable |
|---|---|---|
| Method | void run() | V call() throws Exception |
| Returns a value | No | Yes |
| Checked exceptions | Cannot throw any | May throw anything |
| Added in | Java 1.0 | Java 5 |
| Submit with | execute() or submit() | submit() |
Method
Runnablevoid run()CallableV call() throws ExceptionReturns a value
RunnableNoCallableYesChecked exceptions
RunnableCannot throw anyCallableMay throw anythingAdded in
RunnableJava 1.0CallableJava 5Submit with
Runnableexecute() or submit()Callablesubmit()
Callable is the one to reach for whenever a task produces a result or can fail meaningfully.
Future
Submitting a Callable returns a handle to a result that does not exist yet.
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
Future<Integer> future = pool.submit(() -> expensiveComputation());
doSomethingElse(); // runs in parallel
Integer result = future.get(); // blocks until the result is ready
Integer bounded = future.get(2, TimeUnit.SECONDS); // or gives up
}
The five methods
get()— blocks until the task completes, then returns the value.get(timeout, unit)— the same, but throwsTimeoutExceptionon expiry. Prefer this form.isDone()— true once finished, whether by success, exception or cancellation.cancel(mayInterruptIfRunning)— prevents a queued task from starting; interrupts a running one if the flag is true and the task cooperates.isCancelled()— true if cancellation succeeded.
Exceptions arrive wrapped
Future<Integer> future = pool.submit(() -> { throw new IllegalStateException("failed"); });
try {
future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // the IllegalStateException
log.error("Task failed", cause);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Anything the task throws is wrapped in ExecutionException. Always unwrap with getCause() before logging,
or the message says nothing useful.
Why Future is limited
// This is the fundamental problem: you must block to find out anything
Future<User> user = pool.submit(() -> fetchUser(id));
Future<Orders> orders = pool.submit(() -> fetchOrders(id));
User u = user.get(); // the thread sits idle here
Orders o = orders.get(); // and again here
// There is no way to say "when both are ready, combine them, without blocking"
What it cannot do
- No completion callback. You cannot register "run this when the result arrives"; you can only poll or block.
- No chaining. Feeding one result into the next task means blocking in between.
- No combining. Waiting for two futures means two sequential blocking calls.
- No manual completion. You cannot complete a
Futurefrom outside the task. - Clumsy error handling. Recovery means catching
ExecutionExceptionand unwrapping it.
CompletableFuture, covered in the Java 8 module, addresses every one of these. It is worth knowing this list
because it is exactly the motivation for that API.
invokeAll and invokeAny
Submitting a batch is cleaner than a loop of submit calls.
List<Callable<Integer>> jobs = List.of(
() -> fetchFromCache(),
() -> fetchFromDatabase(),
() -> fetchFromApi());
// Waits for every task; returns futures that are all already done
List<Future<Integer>> all = pool.invokeAll(jobs);
for (Future<Integer> future : all) {
System.out.println(future.get()); // no longer blocks
}
// Returns the first successful result and cancels the rest
Integer fastest = pool.invokeAny(jobs);
invokeAny is useful when several sources can answer the same question and you want whichever responds first.
invokeAll accepts a timeout, after which unfinished tasks are cancelled.
ScheduledExecutorService
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.schedule(() -> sendReminder(), 5, TimeUnit.MINUTES); // once
scheduler.scheduleAtFixedRate(this::collectMetrics, 0, 1, TimeUnit.MINUTES);
// Starts every minute regardless of how long a run takes
scheduler.scheduleWithFixedDelay(this::pollQueue, 0, 1, TimeUnit.SECONDS);
// Waits one second AFTER each run finishes
Fork/Join
For a problem that splits recursively into independent subproblems, ForkJoinPool adds work stealing: an
idle thread takes queued work from a busy thread's deque.
public class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] values;
private final int from, to;
SumTask(long[] values, int from, int to) {
this.values = values; this.from = from; this.to = to;
}
@Override
protected Long compute() {
if (to - from <= THRESHOLD) {
long sum = 0;
for (int i = from; i < to; i++) sum += values[i];
return sum; // small enough: just do it
}
int mid = (from + to) / 2;
SumTask left = new SumTask(values, from, mid);
SumTask right = new SumTask(values, mid, to);
left.fork(); // queue the left half
long rightResult = right.compute(); // compute the right half here
return rightResult + left.join(); // then collect the left
}
}
long total = ForkJoinPool.commonPool().invoke(new SumTask(values, 0, values.length));
The details that matter
RecursiveTask<V>returns a value;RecursiveActiondoes not.- Fork one half, compute the other on the current thread. Forking both wastes a thread doing nothing but waiting.
- The threshold matters. Too small and coordination overhead dominates; too large and parallelism is lost. Benchmark it.
- The common pool is shared across the JVM and sized to cores minus one. Parallel streams use it too.
- Never block inside a fork/join task. A blocking call occupies a pool thread that cannot be stolen from, and with the common pool you are stalling parallel streams elsewhere in the application.
Common misreadings
- "
Runnablecan return a value through a field." It can, but then you have to coordinate reading it safely.Callableexists for exactly this. - "
future.get()runs the task." The executor already started it.get()only waits. - "
cancel(true)kills the task." It interrupts the thread. A task that ignores interruption keeps running. - "
isDone()means success." It is also true after an exception or a cancellation. - "
scheduleAtFixedRateandscheduleWithFixedDelayare equivalent." They diverge sharply when a run overruns its period. - "A scheduled task recovers from an exception." It does not — all future executions are cancelled silently.
- "Fork/join is faster for any parallel work." It is for recursive CPU-bound division. Blocking inside it is actively harmful.
Quick recall
Everything you need if you only revisit this box.
Callable<V>returns a value and may throw checked exceptions;Runnabledoes neither.submitreturns aFuture. Preferget(timeout, unit)over the unboundedget().- Task exceptions are wrapped in
ExecutionException— unwrap withgetCause(), and remember that aFuturenobody reads hides the failure entirely. Futurecannot notify, chain, combine or be completed externally. That list is the motivation forCompletableFuture.invokeAllwaits for all;invokeAnyreturns the first success and cancels the rest.- For polling use
scheduleWithFixedDelay, and always catch exceptions inside a scheduled task or it stops permanently. - Fork/join uses work stealing: fork one half, compute the other, tune the threshold, and never block inside a task.
Test yourself
Answer these before moving on — recall is what makes it stick.