The Problem with Basic Future
When discussing asynchronous programming, the Future interface immediately comes to mind. However, JDK's Future has a significant limitation that many developers overlook.
When you call future.get() on the client side, it blocks the current thread until the result is available. This is essentially a half-baked implementation of asynchronous programming. The thread waits until the task completes, which defeats the purpose of non-blocking execution.
Let's examine how this works in practice with the standard thread pool API.
Thread Pool Submission Methods
The ThreadPoolExecutor provides several ways to submit tasks:
Execute Method
The execute() method submits a Runnable task with no return value:
public class ThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 5, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10)
);
pool.execute(() -> {
System.out.println("Task executed");
});
Thread.currentThread().join();
}
}
Submit Methods
The submit() method returns a Future and comes in three overloaded forms:
public class ThreadPoolDemo {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 5, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10)
);
// Submit Callable with return value
Future<String> result1 = pool.submit(() -> {
System.out.println("Processing task");
return "completed";
});
System.out.println("Result: " + result1.get());
// Submit Runnable with no result
Future<?> result2 = pool.submit(() -> {
System.out.println("Running task");
});
System.out.println("Result: " + result2.get()); // Returns null
// Submit Runnable with a result object
AtomicInteger counter = new AtomicInteger();
Future<AtomicInteger> result3 = pool.submit(() -> {
counter.set(12345);
}, counter);
System.out.println("Result: " + result3.get().get());
Thread.currentThread().join();
}
}
Looking at the source code reveals that all three submit methods internally delegate to execute(). The returned Future objects serve only as containers for results.
Limitations of Basic Future
The standard Future has two major drawbacks:
-
Blocking on get(): Calling
get()blocks until the result is ready. There's alsoget(timeout, unit)for time-limited waiting, but it still blocks. -
Hidden Exceptions: Any exception thrown during task execution gets wrapped and hidden. You won't know about it until calling
get().
Future<String> future = executor.submit(() -> {
// Exception is swallowed here
throw new RuntimeException("Task failed");
});
// Exception revealed only when calling get()
System.out.println(future.get()); // ExecutionException thrown
These limitations make basic Future unsuitable for true asynchronous programming.
Guava's ListenableFuture
Google Guava extends the JDK Future with ListenableFuture, introducing callback-based asynchronous handling.
Using addListener
The addListener() method registers a callback that executes when the task completes:
public class GuavaFutureDemo {
public static void main(String[] args) throws Exception {
ListeningExecutorService executor =
MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
ListenableFuture<String> future = executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " - Starting work");
TimeUnit.SECONDS.sleep(5);
return "Work finished";
});
future.addListener(() -> {
try {
System.out.println(Thread.currentThread().getName()
+ " - Result: " + future.get());
} catch (Exception e) {
e.printStackTrace();
}
}, executor);
System.out.println(Thread.currentThread().getName()
+ " - Can do other work while waiting");
Thread.currentThread().join();
}
}
Output shows the callback executes in a separate thread without blocking the main thread.
Using FutureCallback
A more elegant approach uses FutureCallback with explicit success and failure handlers:
public class GuavaFutureDemo {
public static void main(String[] args) throws Exception {
ListeningExecutorService executor =
MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
ListenableFuture<String> future = executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " - Starting work");
TimeUnit.SECONDS.sleep(5);
return "Work finished";
});
Futures.addCallback(future, new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
System.out.println(Thread.currentThread().getName()
+ " - Success: " + result);
}
@Override
public void onFailure(Throwable t) {
System.out.println(Thread.currentThread().getName()
+ " - Failed: " + t.getMessage());
}
}, executor);
System.out.println(Thread.currentThread().getName()
+ " - Can do other work while waiting");
Thread.currentThread().join();
}
}
This callback-based approach represents true asynchronous programming—the caller continues executing while the task runs in the background.
CompletableFuture in JDK 8
JDK 8 introduced CompletableFuture, which implements both Future and CompletionStage. The CompletionStage interface represents a single stage of a possibly asynchronous computation. Multiple stages can be chained together, where each stage automatically triggers upon completion of the previous one.
Basic Usage
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println(Thread.currentThread().getName() + " - Processing");
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Processing complete";
});
future.whenComplete((result, exception) -> {
if (exception == null) {
System.out.println(Thread.currentThread().getName()
+ " - Success: " + result);
} else {
System.out.println(Thread.currentThread().getName()
+ " - Error occurred");
exception.printStackTrace();
}
});
System.out.println(Thread.currentThread().getName()
+ " - Main thread continues working");
Thread.currentThread().join();
}
}
By default, CompletableFuture uses ForkJoinPool.commonPool() for execution. You can also specify a custom executor.
Exception Handling
The handle() method provides elegant exception handling without explicit try-catch blocks:
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
CompletableFuture.supplyAsync(() -> {
System.out.println(Thread.currentThread().getName() + " - Processing");
throw new RuntimeException("Something went wrong");
}).handleAsync((result, exception) -> {
if (exception != null) {
System.out.println(Thread.currentThread().getName()
+ " - Handling error: " + exception.getCause());
return exception.getCause();
} else {
return result;
}
}).thenApplyAsync((result) -> {
System.out.println(Thread.currentThread().getName()
+ " - Chain continues: " + result);
return result;
});
System.out.println(Thread.currentThread().getName()
+ " - Main thread continues working");
Thread.currentThread().join();
}
}
Chaining Operations
CompletableFuture excels at composing multiple asynchronous operations:
CompletableFuture.supplyAsync(() -> fetchUserId())
.thenCompose(userId -> fetchUserDetails(userId))
.thenCompose(details -> fetchUserOrders(details))
.whenComplete((orders, error) -> {
if (error != null) {
System.out.println("Failed to fetch orders");
} else {
System.out.println("Orders: " + orders);
}
});
Summary
| Feature | JDK Future | Guava ListenableFuture | CompletableFuture |
|---|---|---|---|
| Blocking get() | Yes | Yes | Yes |
| Callback support | No | Yes | Yes |
| Exception handling | Only on get() | Via callback | handle() method |
| Chaining stages | No | Limited | Full support |
| JDK version | 1.5 | External library | 1.8 |
The evolution from basic Future to ListenableFuture to CompletableFuture represents the natural progression toward genuine asynchronous programming. Each step removes blocking operations and provides more flexible ways to handle results and errors.