Future, Callable, CompletableFuture: Blocking, Timing Out, and Not Blocking At All
Part 2 of 8 in Java Concurrency: Deep Dive
A Callable submitted to an ExecutorService returns a Future, and calling .get() on that Future blocks the calling thread until a result is ready (or a timeout you gave it runs out). CompletableFuture gives you a different option: chain callbacks onto it instead of blocking. This post runs two small Java programs, each compiled and run for real, to show both of those behaviors actually happening.
Future.get(): blocking, and timing out
FutureTimeoutDemo submits a Callable that sleeps 2000ms then returns a string, to a newFixedThreadPool(2). Part 1 calls future.get(500, TimeUnit.MILLISECONDS) on a fresh future for that same slow task. Part 2 submits another instance of the same task and calls future.get() with no timeout.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public class FutureTimeoutDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<String> future1 = pool.submit(() -> {
Thread.sleep(2000);
return "slow result";
});
long start1 = System.nanoTime();
try {
future1.get(500, TimeUnit.MILLISECONDS);
System.out.println("part 1: got a result (unexpected)");
} catch (TimeoutException e) {
long elapsed = (System.nanoTime() - start1) / 1_000_000;
System.out.println("part 1: get(500ms timeout) threw TimeoutException after " + elapsed + "ms");
}
Future<String> future2 = pool.submit(() -> {
Thread.sleep(2000);
return "slow result";
});
long start2 = System.nanoTime();
String result = future2.get();
long elapsed2 = (System.nanoTime() - start2) / 1_000_000;
System.out.println("part 2: get() with no timeout returned \"" + result + "\" after blocking " + elapsed2 + "ms");
pool.shutdown();
}
}
Real javac+java output, JDK 25 (Zulu):
part 1: get(500ms timeout) threw TimeoutException after 500ms
part 2: get() with no timeout returned "slow result" after blocking 2000ms
Part 1’s get(500, TimeUnit.MILLISECONDS) threw TimeoutException at almost exactly 500ms — the task was still sleeping (it needs 2000ms), so the timeout fired before a result existed. Part 2’s plain get() had no timeout to trip: it blocked for the full 2000ms the task actually took, then returned the real result.
A blocking Future.get() vs a CompletableFuture chain
ChainVsBlockingDemo runs the same 1000ms task two ways. First, submitted to an ExecutorService as a Callable, with the main thread calling future.get() right after submitting it. Second, as CompletableFuture.supplyAsync(...).thenApply(...), with a thenAccept callback chained on instead of a blocking call, timing when the main thread’s next line of code actually runs in each case.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ChainVsBlockingDemo {
public static void main(String[] args) throws Exception {
System.out.println("--- blocking Future.get() ---");
ExecutorService pool = Executors.newSingleThreadExecutor();
long start = System.nanoTime();
Future<Integer> future = pool.submit(() -> {
Thread.sleep(1000);
return 21;
});
Integer blockingResult = future.get();
long afterBlockingGet = (System.nanoTime() - start) / 1_000_000;
System.out.println("main thread resumed at " + afterBlockingGet + "ms with result " + (blockingResult * 2));
pool.shutdown();
System.out.println();
System.out.println("--- CompletableFuture chain ---");
long start2 = System.nanoTime();
CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return 21;
}).thenApply(v -> v * 2);
long afterSubmit = (System.nanoTime() - start2) / 1_000_000;
System.out.println("main thread continued at " + afterSubmit + "ms, without waiting for the result");
cf.thenAccept(v -> {
long callbackAt = (System.nanoTime() - start2) / 1_000_000;
System.out.println("callback ran at " + callbackAt + "ms with result " + v);
});
cf.join();
}
}
Real javac+java output, JDK 25 (Zulu):
--- blocking Future.get() ---
main thread resumed at 1002ms with result 42
--- CompletableFuture chain ---
main thread continued at 8ms, without waiting for the result
callback ran at 1007ms with result 42
In the blocking version, the line right after future.get() didn’t execute until 1002ms — the call sat there for the whole 1000ms the task took. In the CompletableFuture version, the line right after building the chain ran at 8ms — the main thread didn’t wait. The thenAccept callback itself still ran once the 1000ms task completed, at 1007ms, but that happened without the main thread blocking to get there.
Takeaway
future.get() blocked the calling thread for as long as the task actually took (2000ms with no timeout, or threw TimeoutException at the 500ms mark when the task hadn’t finished yet), while chaining onto a CompletableFuture let the main thread’s next line run at 8ms instead of waiting the full 1000ms — the callback still ran, but on its own schedule instead of the calling thread’s.