Asynchronous Programming in Java
Learn how Java applications perform work without unnecessarily blocking the calling thread using CompletableFuture, asynchronous tasks, executors, composition, exception handling, and asynchronous pipelines.
What is Asynchronous Programming?
Asynchronous programming allows an application to start an operation and continue doing other work instead of waiting synchronously for that operation to finish.
Java provides several concurrency APIs. For asynchronous
workflows, CompletableFuture is one of the most
important APIs.
Start Task
↓
Continue Other Work
↓
Task Completes
↓
Process Result
Synchronous vs Asynchronous
| Synchronous | Asynchronous |
|---|---|
| Caller waits for the operation. | Caller can continue other work. |
| Execution is sequential from the caller's perspective. | Work may complete later. |
| Simple for straightforward operations. | Useful for composing independent or delayed tasks. |
CompletableFuture
CompletableFuture represents a future result that
can be completed and composed with other asynchronous
operations.
import java.util.concurrent.CompletableFuture;
CompletableFuture<String> future =
CompletableFuture.supplyAsync(
() -> "Welcome CIIT Institute👀"
);
System.out.println(
future.join()
);
runAsync()
Use runAsync() when the asynchronous operation does
not produce a result.
CompletableFuture<Void> future =
CompletableFuture.runAsync(
() -> {
System.out.println(
"Task is running"
);
}
);
future.join();
supplyAsync()
Use supplyAsync() when the asynchronous operation
returns a result.
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(
() -> 10 + 20
);
System.out.println(
future.join()
);
thenApply()
thenApply() transforms the result of a completed
asynchronous stage.
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(
() -> 10
);
CompletableFuture<Integer> result =
future.thenApply(
value -> value * 2
);
System.out.println(
result.join()
);
thenAccept()
thenAccept() consumes the result without producing
another result.
CompletableFuture
.supplyAsync(
() -> "Core Java"
)
.thenAccept(
value ->
System.out.println(value)
)
.join();
thenRun()
thenRun() executes an action after a previous
stage completes but does not receive its result.
CompletableFuture
.supplyAsync(
() -> "Task completed"
)
.thenRun(
() ->
System.out.println(
"Next action"
)
)
.join();
thenCombine()
thenCombine() combines the results of two
independent stages.
CompletableFuture<Integer> first =
CompletableFuture.supplyAsync(
() -> 10
);
CompletableFuture<Integer> second =
CompletableFuture.supplyAsync(
() -> 20
);
CompletableFuture<Integer> result =
first.thenCombine(
second,
(a, b) -> a + b
);
System.out.println(
result.join()
);
thenCompose()
thenCompose() is useful when the next asynchronous
operation depends on the result of the previous one.
CompletableFuture<String> first =
CompletableFuture.supplyAsync(
() -> "User"
);
CompletableFuture<String> result =
first.thenCompose(
value ->
CompletableFuture.supplyAsync(
() ->
value + " Details"
)
);
System.out.println(
result.join()
);
allOf()
allOf() creates a CompletableFuture that completes
when all supplied futures complete.
CompletableFuture<String> one =
CompletableFuture.supplyAsync(
() -> "One"
);
CompletableFuture<String> two =
CompletableFuture.supplyAsync(
() -> "Two"
);
CompletableFuture.allOf(
one,
two
).join();
System.out.println(
one.join()
);
System.out.println(
two.join()
);
anyOf()
anyOf() completes when any one of the supplied
futures completes.
CompletableFuture<String> first =
CompletableFuture.supplyAsync(
() -> "First"
);
CompletableFuture<String> second =
CompletableFuture.supplyAsync(
() -> "Second"
);
Object result =
CompletableFuture.anyOf(
first,
second
).join();
System.out.println(result);
exceptionally()
exceptionally() provides a fallback result when
the asynchronous computation completes exceptionally.
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(
() -> {
throw new RuntimeException(
"Something went wrong"
);
}
)
.exceptionally(
error -> 0
);
System.out.println(
future.join()
);
handle()
handle() receives both the successful result and
possible exception and can transform either case.
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(
() -> 100
);
CompletableFuture<String> result =
future.handle(
(value, error) -> {
if (error != null) {
return "Failed";
}
return "Value: " + value;
}
);
System.out.println(
result.join()
);
whenComplete()
whenComplete() can be used to observe completion
and inspect the result or exception.
CompletableFuture<String> future =
CompletableFuture.supplyAsync(
() -> "Success"
);
future.whenComplete(
(value, error) -> {
if (error != null) {
System.out.println(
"Error: " + error
);
} else {
System.out.println(
"Result: " + value
);
}
}
).join();
Async Variants
Many CompletableFuture methods have an
Async variant, such as
thenApplyAsync() and
thenAcceptAsync().
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(
() -> 10
);
CompletableFuture<Integer> result =
future.thenApplyAsync(
value -> value * 5
);
System.out.println(
result.join()
);
Using an Executor
An explicit Executor can be supplied when you need control over the execution environment.
ExecutorService executor =
Executors.newFixedThreadPool(4);
CompletableFuture<String> future =
CompletableFuture.supplyAsync(
() -> "Running in executor",
executor
);
System.out.println(
future.join()
);
executor.shutdown();
Delayed Completion
Modern Java provides APIs such as
CompletableFuture.delayedExecutor() for delayed
execution scenarios.
Executor delayed =
CompletableFuture.delayedExecutor(
2,
TimeUnit.SECONDS
);
CompletableFuture<String> future =
CompletableFuture.supplyAsync(
() -> "Completed later",
delayed
);
System.out.println(
future.join()
);
Building an Asynchronous Pipeline
CompletableFuture<String> result =
CompletableFuture.supplyAsync(
() -> "java"
)
.thenApply(
String::toUpperCase
)
.thenApply(
value ->
"Language: " + value
);
System.out.println(
result.join()
);
Non-Blocking Composition
Asynchronous APIs are most useful when operations are composed instead of repeatedly blocking the current thread waiting for every intermediate result.
CompletableFuture<String> user =
getUserAsync();
CompletableFuture<String> result =
user.thenCompose(
this::getUserDetailsAsync
);
result.thenAccept(
System.out::println
);
join() vs get()
| Method | Behavior |
|---|---|
join() |
Waits for completion and wraps failures in an unchecked CompletionException. |
get() |
Waits for completion and uses checked exceptions such as ExecutionException. |
Exception Handling in Async Code
Exceptions occurring during asynchronous computation are
represented in the completion stage and can be handled using
methods such as exceptionally(),
handle(), and whenComplete().
CompletableFuture<String> future =
CompletableFuture.supplyAsync(
() -> {
if (true) {
throw new RuntimeException(
"Database error"
);
}
return "Success";
}
)
.exceptionally(
error -> "Fallback"
);
System.out.println(
future.join()
);
Real-World Example
Consider an application that retrieves a user and then loads that user's orders.
CompletableFuture<User> userFuture =
getUserAsync(101);
CompletableFuture<List<Order>> ordersFuture =
userFuture.thenCompose(
user ->
getOrdersAsync(
user.getId()
)
);
ordersFuture.thenAccept(
orders ->
System.out.println(
"Orders: " + orders.size()
)
);
Asynchronous Programming Best Practices
- Prefer composition over unnecessary blocking.
-
Use
thenApply()for synchronous transformation of a stage result. -
Use
thenCompose()when the next operation itself returns a CompletableFuture. -
Use
thenCombine()for independent operations whose results need to be combined. - Handle asynchronous failures explicitly.
- Use a suitable Executor when the default execution model is not appropriate.
- Avoid blocking operations inside shared or latency-sensitive executor threads.
Common Mistakes
-
Calling
join()orget()immediately after starting every task, which can remove much of the benefit of asynchronous composition. - Ignoring exceptions from asynchronous operations.
- Using an inappropriate executor for blocking work.
- Sharing mutable state between asynchronous tasks without proper synchronization.
Interview Questions
runAsync() performs an asynchronous
action without returning a result, while
supplyAsync() returns a result.
thenCompose() chains dependent
asynchronous operations and avoids creating a
nested CompletableFuture.
thenCombine() combines the results of
two independent CompletionStage computations.
exceptionally(),
handle(), and
whenComplete().
get() uses
checked exceptions, while join()
reports failures using unchecked completion
exceptions.
allOf() creates a completion stage
that completes when all supplied futures have
completed.
Summary
Java asynchronous programming provides APIs for starting, composing, combining, and handling asynchronous tasks. CompletableFuture is central to this model, with methods such as runAsync, supplyAsync, thenApply, thenCompose, thenCombine, allOf, anyOf, exceptionally, handle, and whenComplete.