Asynchronous Programming in Java
1. What is synchronous programming?
First understand synchronous programming.
Suppose you have three operations:
taskA();
taskB();
taskC();Execution is:
Main Thread
|
|---- taskA()
| ↓
| finishes
|
|---- taskB()
| ↓
| finishes
|
|---- taskC()
↓
finishesIf taskA() takes 5 seconds:
taskA → 5 sec
taskB → 2 sec
taskC → 1 secTotal = 8 secThe thread executing taskA() is blocked/waiting until it finishes.
This is synchronous execution.
2. What is asynchronous programming?
Asynchronous programming means:
Start a task and allow the current thread to continue doing other work without waiting for that task to finish.
For example:
Main Thread
|
|---- Start taskA()
|
|---- Continue taskB()
|
|---- Continue taskC()
|
| taskA finishes
|______________________↓The important point is:
Start task
↓
Don't wait
↓
ContinueThe result can be handled later.
3. Why do we need asynchronous programming?
Imagine your application needs to call three external APIs:
Payment API
Email API
Notification APIEach takes 2 seconds.
Synchronous
Payment API
↓ 2 sec
Email API
↓ 2 sec
Notification API
↓ 2 sec
Total ≈ 6 secIf these operations are independent, we don’t necessarily need to wait for each one.
Asynchronous
┌── Payment API ── 2 sec ──┐
│ │
Start ───────┼── Email API ─── 2 sec ───┼── Continue
│ │
└── Notification ─ 2 sec ──┘The tasks can execute concurrently.
The tasks can execute concurrently.
Total can be close to:
≈ 2 secondsrather than 6 seconds, assuming enough threads/resources and that the calls can safely run concurrently.
4. Is asynchronous the same as multithreading?
No.
This is an important interview concept.
Multithreading
Multiple threads execute tasks.
Thread 1 → Task A
Thread 2 → Task B
Thread 3 → Task CAsynchronous programming
The important idea is:
The caller doesn’t have to wait for the operation to complete.
Often asynchronous programming uses multiple threads, but asynchronous programming and multithreading are not synonymous.
For example:
Caller
|
| submit task
↓
Executor
|
↓
Worker ThreadThe caller can continue.
5. First approach — Creating a Thread
The simplest way to execute something asynchronously is:
Thread thread = new Thread(() -> {
System.out.println("Running asynchronously");
});
thread.start();
System.out.println("Main thread continues...");Flow:
┌── Worker Thread
│ ↓
Main Thread ─┤ Task
│
↓
Continue workThe important thing is:
thread.start();Not:
thread.run();start() vs run()
This is a common interview question.
thread.start();creates/schedules execution on a separate thread.
Whereas:
thread.run();is simply a normal method call and executes on the current thread.
6. Why don’t we create threads manually?
Suppose we have 1000 requests.
If every request creates a new thread:
Request 1 → Thread 1
Request 2 → Thread 2
Request 3 → Thread 3
...
Request 1000 → Thread 1000This is inefficient.
Threads consume memory and have creation/context-switching overhead.
Instead, we use a thread pool.
7. ExecutorService
Java provides:
ExecutorServiceIt manages a pool of worker threads.
Example:
ExecutorService executor =
Executors.newFixedThreadPool(5);Now we have approximately:
ExecutorService
Thread 1
Thread 2
Thread 3
Thread 4
Thread 5We submit tasks:
executor.submit(() -> {
System.out.println("Task 1");
});The executor assigns the task to an available worker thread.
8. Why Thread Pool?
Suppose we have:
100 tasksand:
5 threadsThe executor manages the work:
Executor
|
┌──────┼──────┐
↓ ↓ ↓
Thread1 Thread2 Thread3
↓ ↓ ↓
Task1 Task2 Task3Remaining tasks stay in a queue.When Thread 1 finishes Task 1:
Thread 1
↓
Task 1 complete
↓
Task 6This avoids creating 100 threads.
9. ExecutorService Example
ExecutorService executor =
Executors.newFixedThreadPool(3);
executor.submit(() -> {
System.out.println("Task 1");
});
executor.submit(() -> {
System.out.println("Task 2");
});
executor.submit(() -> {
System.out.println("Task 3");
});
executor.shutdown();Conceptually:
Main Thread
|
↓
ExecutorService
|
├── Thread 1 → Task 1
├── Thread 2 → Task 2
└── Thread 3 → Task 310. What if I need the result?
ExecutorService.submit() can return a Future.
Future<String> future =
executor.submit(() -> {
return "Hello";
});Later:
String result = future.get();But there is an important problem.
future.get() is blocking.
If the result isn’t ready:
future.get()
↓
WAIT
↓
result available
ExecutorService executor =
Executors.newSingleThreadExecutor();
Callable<Integer> task = () -> {
Thread.sleep(2000);
return 10 + 20;
};
Future<Integer> future = executor.submit(task);
System.out.println("Doing other work...");
Integer result = future.get();
System.out.println(result);
Output:
Doing other work...
Then after ~2 seconds:
30So although we submitted the task asynchronously, calling get() immediately can make our code synchronous again.
join() vs get()
Both wait for the result:
future.join();
future.get();
Main difference:
get()
→ checked exceptions
→ InterruptedException
→ ExecutionException
join()
→ unchecked CompletionException11. CompletableFuture
This is where:
CompletableFuturebecomes very useful.
It allows us to:
- Execute asynchronously
- Receive results asynchronously
- Chain operations
- Combine operations
- Handle exceptions
- Compose multiple asynchronous operations
12. Basic CompletableFuture
CompletableFuture<Void> future =
CompletableFuture.runAsync(() -> {
System.out.println("Running asynchronously");
});
System.out.println("Main thread continues");runAsync() is used when we don't need a return value.
13. supplyAsync()
If we need a result:
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
return "Hello";
});Difference:
runAsync()
↓
No result
supplyAsync()
↓
Returns result14. How do we get the result without blocking?
Instead of:
future.get();we can attach a callback:
future.thenAccept(result -> {
System.out.println(result);
});Meaning:
When the result becomes available, execute this code.
Flow:
supplyAsync()
|
| running
|
↓
result available
|
↓
thenAccept()15. thenApply()
Suppose the first asynchronous operation returns a value.
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Priyanka");We want to transform it:
CompletableFuture<Integer> result =
future.thenApply(name -> name.length());Flow:
supplyAsync()
↓
"Priyanka"
↓
thenApply()
↓
8thenApply() is useful when:
I have a result and want to transform it.
16. thenAccept()
If you don’t need to return another value:
future.thenAccept(name -> {
System.out.println(name);
});Think:
thenApply → transform result
thenAccept → consume result17. thenRun()
If you don’t care about the result but want to run another action:
future.thenRun(() -> {
System.out.println("Task completed");
});Think:
thenApply → result → new result
thenAccept → result → action
thenRun → ignore result → action18. Chaining asynchronous operations
Suppose:
Step 1 → Get User
Step 2 → Get Orders
Step 3 → Process OrdersWe can write:
CompletableFuture
.supplyAsync(() -> getUser())
.thenApply(user -> getOrders(user))
.thenAccept(orders -> processOrders(orders));Flow:
getUser()
↓
User
↓
getOrders(user)
↓
Orders
↓
processOrders()Each step can use the result from the previous step.
Now learn below concept in deep.
What is Completable Future?
Why CompletableFuture??
How to create CompletableFuture?
How to use runAsync and supplyAsync with useCase??
What is Completable Future?
Using Asynchronous programming you can write non blocking code where concurrently you can run N no of task in separate thread without blocking main thread.
When the task is completed, it notifies to the main thread (Whether the task was completed or failed).
Why CompletableFuture??
There are different ways to implement asynchronous programming in java using any of the below mechanisms for example you can use Futures, ExecutorService, Callback Interfaces, Thread Pool etc.
- It cannot be manually completed.
- Multiple Futures cannot be chained together.
- We can not combine multiple future together.
- No proper exception handling mechanism.
public class WhyNotFuture {
static void main() throws ExecutionException, InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(10);
try {
Future<List<Integer>> future = executor.submit(() -> {
delay(1);
return Arrays.asList(1, 2, 3, 4, 5);
});
List<Integer> list = future.get();
System.out.println(list);
} finally {
executor.shutdown();
}
}
private static void delay(int min){
try{
TimeUnit.MINUTES.sleep(min);
} catch(InterruptedException e){
e.printStackTrace();
}
}
}
submit() returns a Future
submit(Runnable task)
submit(Runnable task, T result)
submit(Callable<T> task)
Runnable itself doesn't return anything, but this overload lets you specify a value that will become the Future's result.
Example
ExecutorService executor = Executors.newFixedThreadPool(2);
Runnable task = () -> {
System.out.println("Task is running");
};
Future<String> future = executor.submit(task, "Task Completed");
String result = future.get();
System.out.println(result);
Runnable succeeds → f2.get() → "Task Completed" ✅
Runnable fails → f2.get() → ExecutionException ❌
java.util.concurrent.ExecutionException
Caused by: java.lang.ArithmeticException: / by zeroOutput:
Task is running
Task Completedfuture.get() is the important part
public T get() throws InterruptedException, ExecutionExceptionThis is where Future becomes blocking current thread.
get() means:
“Give me the result. If the task hasn’t finished yet, WAIT.
So your main thread does:
submit()
↓
Future returned
↓
future.get()
↓
WAIT ⏳
↓
Task completes
↓
Result returned
↓
list = [1,2,3,4,5]Since your task does:
delay(1);the future.get() will potentially block for approximately 1 minute.
Why is this called “Why Not Future”?
The key problem with Future is:
future.get();
You can submit work asynchronously, but eventually you often end up doing:
get()
which blocks the current thread.
5. Future does NOT mean non-blocking
This is an important interview point:
Future represents an asynchronous computation, but Future.get() is blocking.6. Future has some useful operations
future.get(); //Wait indefinitely. block current thread
future.get(10, TimeUnit.SECONDS); //Wait at most 10 seconds. // block for particular time
future.isDone(); // Check whether the task has completed. no block
future.isCancelled(); // Check whether the task was cancelled. no block
future.cancel(true); //Attempt to cancel the task. no block
This also doesn't wait for the task to finish.
One subtle point: cancel(true) is a cancellation request, not a guarantee that the underlying code immediately stops.
cancel(false) //→ Don't interrupt the running task.
Java 19 :
resultNow() // Get result without waiting
exceptionNow() // Get exception without waiting
resultNow() → "Give me the result NOW, don't wait."
exceptionNow() → "Give me the exception NOW, don't wait."
state() // Get RUNNING/SUCCESS/FAILED/CANCELLED
enum State {
/**
* The task has not completed.
*/
RUNNING,
/**
* The task completed with a result.
* @see Future#resultNow()
*/
SUCCESS,
/**
* The task completed with an exception.
* @see Future#exceptionNow()
*/
FAILED,
/**
* The task was cancelled.
* @see #cancel(boolean)
*/
CANCELLED
}
default State state() {
if (!isDone())
return State.RUNNING;
if (isCancelled())
return State.CANCELLED;
boolean interrupted = false;
try {
while (true) {
try {
get(); // may throw InterruptedException when done
return State.SUCCESS;
} catch (InterruptedException e) {
interrupted = true;
} catch (ExecutionException e) {
return State.FAILED;
}
}
} finally {
if (interrupted) Thread.currentThread().interrupt();
}
}
default V resultNow() {
if (!isDone())
throw new IllegalStateException("Task has not completed");
boolean interrupted = false;
try {
while (true) {
try {
return get();
} catch (InterruptedException e) {
interrupted = true;
} catch (ExecutionException e) {
throw new IllegalStateException("Task completed with exception");
} catch (CancellationException e) {
throw new IllegalStateException("Task was cancelled");
}
}
} finally {
if (interrupted) Thread.currentThread().interrupt();
}
}
What is the difference between submit() and execute() in ExecutorService?
execute()accepts aRunnableand does not return a result. If the task throws an exception, it is propagated to the thread's uncaught-exception handler.submit()accepts aRunnableorCallableand returns aFuture. TheFuturecan be used to retrieve the result, check completion, cancel the task, or retrieve exceptions.- Exceptions thrown by a task submitted using
submit()are captured by theFutureand are observed when callingfuture.get(), wrapped in anExecutionException.
“Can submit() return a result when given a Runnable?"
Get priyanka gupta’s stories in your inbox
Join Medium for free to get updates from this writer.
A good answer is:
Yes.
submit(Runnable)returns aFuturewhose result isnull, whilesubmit(Runnable, T result)returns aFuturecontaining the value supplied as the second argument. For an actual computed result,Callable<T>is generally the appropriate choice.
What happen when submit throw an exception??
// No exception handling
Future<List<Integer>> future4 = executorService.submit(() -> {
System.out.printf("Thread" + Thread.currentThread().getName());
System.out.println(10/0); //ArithmeticException: / by zero
System.out.printf("Throwing Exception");
return Arrays.asList(1,2,3,4,5);
});
executorService.submit(...)
The ExecutorService catches the exception from the task and stores it inside the Future.
future4
|
+-- Result: ❌
|
+-- Exception: ArithmeticException
The exception doesn't automatically propagate to your main thread.
When will you actually see the exception?
When you call:
future4.get();Case 1: Don’t call get()
Future<List<Integer>> future4 = executorService.submit(() -> {
System.out.println(10 / 0);
return Arrays.asList(1, 2, 3);
});
System.out.println("Main thread continues");
The worker thread gets:
ArithmeticException
But the exception is captured by the Future.
The main thread doesn't get that exception, so it continues:Case 2: Call get()
get() immediately throws:
ExecutionException
So:
Worker Thread
|
| ArithmeticException
↓
Future stores exception
|
| get()
↓
Main Thread
|
↓
ExecutionExceptionWithout
get(), an exception thrown by a task submitted withsubmit()remains inside theFuture, so it doesn't directly affect the main thread. Callingget()retrieves the result and, if the task failed, propagates the failure to the calling thread as anExecutionException.
And remember: get() can both block and throw.
Very important Future interview point
Compare these two: execute() and submit()
execute()
executorService.execute(() -> {
System.out.println(10 / 0);
});
Exception can reach the thread's uncaught-exception handling.
submit()
Future<?> future = executorService.submit(() -> {
System.out.println(10 / 0);
});
Exception is captured inside the Future.
You need future.get() to observe it.
--------------------------------------------------
And isDone() won't show the exception
You might think:
future4.isDone();
will tell you there was an exception.
No.
future4.isDone(); // true
future4.isCancelled(); // false
----------------------------------
To retrieve the failure:
try {
future4.get();
} catch (ExecutionException e) {
System.out.println(e.getCause());
}
Output:
java.lang.ArithmeticException: / by zeroExecutor→ A functional interface that provides a simple way to execute tasks asynchronously without managing threads directly.ExecutorService→ ExtendsExecutorand provides task submission,Futurehandling, lifecycle management, and shutdown.Executors→ A utility class that provides factory methods for creatingExecutorServiceand other executor implementations.
Executor is very small; it essentially has one method:
execute(Runnable command) // Executes a task asynchronously.2. ExecutorService — interface
ExecutorService extends Executor.
execute(Runnable command) // Execute a Runnable; no Future returned
submit(Callable<T> task) // Submit Callable and get Future<T>.
submit(Runnable task) // Submit Runnable and get Future<?>.
submit(Runnable task, T result)
// Submit Runnable and get Future containing supplied result.
List<Futute<T>> invokeAll(Collection<? extends Callable<T>> tasks)
// Execute multiple tasks and wait for all to complete.
// whether it ended with a result or an exception.
//blocks the calling thread until all tasks complete.
invokeAll(Collection<? extends Callable<T>> tasks, timeout, unit)
// Execute multiple tasks, waiting only up to timeout.
invokeAny(Collection<? extends Callable<T>> tasks)
// Execute tasks and return result of one successful task.
// It needs one successful result.
// Cancel remaining unfinished tasks
////blocks the calling thread until one tasks success.
invokeAny(Collection<? extends Callable<T>> tasks, timeout, unit)
// Execute tasks and return one successful result within timeout.
invokeAll()
→ Wait for ALL tasks to finish
→ Success OR exception counts as finished
invokeAny()
→ Wait until ANY task succeeds
→ Exception does NOT count as a successful result
// if no success, return ExecutionException
// if timeout before success, TimeoutException
void shutdown() // Stop accepting new tasks; existing tasks continue.
List<Runnable> shutdownNow()
// Attempt to stop running tasks and return waiting tasks.
isShutdown() // Check whether shutdown has been initiated.
isTerminated() // Check whether all tasks have completed after shutdown.
awaitTermination(timeout, unit) // Wait for executor to terminate.Interview-level distinction
invokeAll()
↓
Wait for ALL tasks
↓
Returns List<Future<T>>
invokeAny()
↓
Wait for FIRST SUCCESSFUL task
↓
Returns T directly
↓
Cancel remaining unfinished tasks
invokeAny()-----
List<Callable<String>> tasks = Arrays.asList(
() -> {
Thread.sleep(3000);
return "Task 1";
},
() -> {
Thread.sleep(1000);
return "Task 2";
},
() -> {
Thread.sleep(5000);
return "Task 3";
}
);
String result = executor.invokeAny(tasks);
Suppose:
Task 1 → 3 sec
Task 2 → 1 sec ✅ FIRST SUCCESS
Task 3 → 5 sec
Result:
result = "Task 2";
What happens to the others?
Task 1 → CANCELLED ❌
Task 2 → SUCCESS ✅ → returned
Task 3 → CANCELLED ❌
Important: "first successful", not simply "first completed (success or fails)"
Suppose:
Task 1 → will fails after 1 sec ❌
Task 2 → will succeeds after 3 sec ✅
Task 3 → will succeeds after 5 sec
Then:
invokeAny()
↓
Task 1 fails → ignore
↓
Task 2 succeeds → return Task 2's result
↓
Task 3 is cancelled
So the rule is:
invokeAny() returns the result of the first task that completes successfully and cancels the unfinished tasks.
What if ALL tasks fail?
Then invokeAny() throws:
ExecutionException
Task 1 → fail ❌
Task 2 → fail ❌
Task 3 → fail ❌
↓
ExecutionException
What if timeout occurs?
With:
executor.invokeAny(tasks, 2, TimeUnit.SECONDS);
if no task successfully completes within 2 seconds:
TimeoutException is thrown, and the unfinished tasks are cancelled.
-----------------------------------------------
invokeAll() is basically:
Submit multiple Callable tasks together, wait for all of them to finish, and get a List<Future<T>> containing each task's result/status.
Example
List<Callable<String>> tasks = Arrays.asList(
() -> {
Thread.sleep(3000);
return "Task 1";
},
() -> {
Thread.sleep(1000);
return "Task 2";
},
() -> {
Thread.sleep(2000);
return "Task 3";
}
);
List<Future<String>> futures = executor.invokeAll(tasks);
Suppose:
Task 1 → 3 sec
Task 2 → 1 sec
Task 3 → 2 sec
invokeAll() waits for all three:
0 sec ───────────────────────── 3 sec
│ │ │
T2 T3 T1
done done done
│ │ │
└────────┴───────┘
↓
invokeAll() returns
So invokeAll() itself blocks the calling thread until all tasks complete.
Importantly, the list corresponds to the order of the submitted tasks,
not the order in which they finish.
Submitted:
Task1
Task2
Task3
Returned:
Future1 → Task1 result
Future2 → Task2 result
Future3 → Task3 result
Even though Task2 finished first.
Then:
for (Future<String> future : futures) {
System.out.println(future.get());
}
Since invokeAll() already waited for all tasks,
these get() calls normally don't have to wait for task completion.
What if one task throws an exception?
For example:
Callable<String> task1 = () -> "Success";
Callable<String> task2 = () -> {
throw new RuntimeException("Failed");
};
Callable<String> task3 = () -> "Success";
invokeAll() itself does not throw ExecutionException just because one task failed.
Instead:
Task1 → SUCCESS
Task2 → FAILED
Task3 → SUCCESS
invokeAll()
↓
List<Future<String>>
Then:
futures.get(1).get();
will throw:
ExecutionException
↓
RuntimeException("Failed")
So this is different from invokeAny().
invokeAll()
↓
wait for ALL
↓
List<Future<T>>
↓
future.get()
├── success → result
└── failure → ExecutionException
allOf() doesn't immediately complete when one fails. It waits until all component futures are complete, then:
All succeed
↓
allOf → success
Any fails
↓
allOf → exceptionalinvokeAll() vs invokeAny()
This is very important for interviews:
Both invokeAll() and invokeAny() block the calling thread, but they wait for different conditions.
invokeAll()
↓
Execute ALL tasks
↓
WAIT for ALL
↓
List<Future<T>>
executor.invokeAll(tasks, 2, TimeUnit.SECONDS);
--------------
invokeAny()
↓
Execute multiple tasks
↓
WAIT for FIRST SUCCESS
↓
Return T
↓
Cancel remaining unfinished tasks3. Executors — utility/factory class
Executors doesn't execute your tasks itself; it creates executor implementations.
Important factory methods:
newFixedThreadPool(nThreads)
// Creates a thread pool with a fixed number of threads. return ThreadPoolExecutor
newCachedThreadPool()
// Creates a pool that creates threads as needed and reuses idle threads. return ThreadPoolExecutor
newSingleThreadExecutor()
// Creates an executor with one worker thread.
newScheduledThreadPool(corePoolSize)
// Creates a pool for delayed and periodic task execution.
newSingleThreadScheduledExecutor()
// Creates a scheduled executor with one worker thread.
newWorkStealingPool()
// Creates a work-stealing executor using ForkJoinPool.
newWorkStealingPool(parallelism)
// Creates a work-stealing pool with specified parallelism.
newVirtualThreadPerTaskExecutor() //return ThreadPerTaskExecutorMore accurate hierarchy
Executor
│
↓
ExecutorService (Interface)
│
├──────────────→ ScheduledExecutorService
│ ↑
│ │ implements
│ │
│ ScheduledThreadPoolExecutor
│
│
│
└→ AbstractExecutorService (Abstract Class)
│
├── ThreadPoolExecutor (class)
│ │ extend
│ └── ScheduledThreadPoolExecutor (Class)
│
└── ForkJoinPool (Class) implements ScheduledExecutorService
Executors.newFixedThreadPool(10)
↓
ThreadPoolExecutor
Executors.newScheduledThreadPool(5)
↓
ScheduledThreadPoolExecutor
Executors.newWorkStealingPool()
↓
ForkJoinPool
ExecutorService executor =
Executors.newCachedThreadPool(); return ThreadPoolExecutor
More precisely, Executors.newCachedThreadPool() creates a ThreadPoolExecutor
configured with a SynchronousQueue and an idle-thread keep-alive of 60 seconds.
newCachedThreadPool()
↓
ThreadPoolExecutor
↓
Platform Threads
***newVirtualThreadPerTaskExecutor()
ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
return ThreadPerTaskExecutor
This is not a ThreadPoolExecutor.
newVirtualThreadPerTaskExecutor()
↓
ThreadPerTaskExecutor
↓
Virtual Thread
↓
one new virtual thread per task
1. Fixed Thread Pool
ExecutorService executor = Executors.newFixedThreadPool(10);
//Maximum 10 worker threads, with an unbounded task queue.
ThreadPoolExecutor
│
├── corePoolSize = 5
├── maximumPoolSize = 5
└── workQueue = LinkedBlockingQueue
↑
unbounded queue
Queue: LinkedBlockingQueue
Tasks beyond the 5 active threads go into the queue.
5 worker threads
↓
[T1][T2][T3][T4][T5]
↑
LinkedBlockingQueue
[T6][T7][T8]...
Maintains a fixed number of worker threads;
extra tasks wait in an unbounded queue
Pros
Predictable number of threads.
Prevents unlimited thread creation.
Good for CPU-bound workloads.
Cons
Queue is unbounded.
If tasks arrive faster than they finish, the queue can grow indefinitely.
Can eventually cause memory pressure / OutOfMemoryError.
maximumPoolSize doesn't really help because the queue accepts tasks first.
ExecutorService executor = new ThreadPoolExecutor(
5, // corePoolSize
10, // maximumPoolSize
60, // keepAliveTime
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100) // queue capacity = 100
);
Important interview point ⭐
***ThreadPoolExecutor gives you control over both thread count and queue capacity:
new ArrayBlockingQueue<>(100) // Bounded
new LinkedBlockingQueue<>(100) // Bounded
new LinkedBlockingQueue<>() // Unbounded
new SynchronousQueue<>() // Zero capacity / direct handoff“How would you prevent an executor from accepting unlimited tasks?”
A strong answer is:
Use a
ThreadPoolExecutorwith a boundedBlockingQueuesuch asArrayBlockingQueue, configure an appropriate maximum pool size, and define a suitableRejectedExecutionHandle.
2. Cached Thread Pool
ExecutorService executor = Executors.newCachedThreadPool();
Creates threads when needed, reuses idle threads, and removes threads that have been idle for 60 seconds.
Use: Many short-lived asynchronous tasks.
It can create a large number of threads because it has no fixed upper bound on the number of worker threads.
ThreadPoolExecutor
│
├── corePoolSize = 0
├── maximumPoolSize = Integer.MAX_VALUE
├── keepAlive = 60 seconds
└── workQueue = SynchronousQueue
Queue: SynchronousQueue
This queue has essentially no capacity.
A task must be handed directly to another thread.
Task
↓
SynchronousQueue
↓
Idle worker available?
│
├── YES → give task to worker
│
└── NO → create new worker
Pros
Very good for short-lived asynchronous tasks.
Reuses idle threads.
Doesn't build up a large task queue.
Good when tasks arrive in bursts.
Cons ⚠️
Can create a huge number of threads.
No meaningful upper bound on worker threads (maximumPoolSize = Integer.MAX_VALUE).
Bad choice for long-running tasks or uncontrolled traffic.
Can exhaust system resources.
Interview point
CachedThreadPool prefers creating threads rather than queuing tasks.
***submit(task)
↓
SynchronousQueue
↓
Is an idle worker waiting?
│
┌──┴──┐
YES NO
↓ ↓
reuse create new thread3. Single Thread Executor
ExecutorService executor = Executors.newSingleThreadExecutor();
Uses one worker thread and an unbounded queue; tasks execute sequentially
When tasks must execute one at a time and in submission order.
ThreadPoolExecutor
│
├── corePoolSize = 1
├── maximumPoolSize = 1
└── workQueue = LinkedBlockingQueue
↑
unbounded
Pros
Guarantees only one task executes at a time.
Tasks execute sequentially.
Useful when ordering matters.
Good for serializing access to a resource.
Example:
Task1 → Task2 → Task3 → Task4
↓
Thread 1
Cons
Only one task executes at a time.
One slow task can delay everything behind it.
Unbounded queue can grow if producers are faster than the worker.
Note
newSingleThreadExecutor() provides single-threaded sequential execution with an unbounded queue.4. Scheduled Thread Pool
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
A fixed-size pool that supports delayed and periodic task execution
schedule()
scheduleAtFixedRate()
scheduleWithFixedDelay()
executor.schedule(
() -> System.out.println("Hello"),
5,
TimeUnit.SECONDS
); // start after 5 second
ScheduledThreadPoolExecutor
│
↓
DelayQueue
More precisely, its scheduling mechanism uses a specialized DelayedWorkQueue, which is a BlockingQueue implementation based on a heap.
ScheduledThreadPoolExecutor
│
↓
DelayedWorkQueue
│
├── Task A → 10 sec
├── Task B → 2 sec
└── Task C → 20 sec
The queue orders tasks based on their scheduled execution time.
Pros
Supports delayed execution.
Supports periodic execution.
Efficiently handles scheduled tasks.
Multiple worker threads can execute tasks.
Cons
Scheduling queue is effectively unbounded.
A huge number of scheduled tasks can consume memory.
Long-running tasks can occupy worker threads and delay other tasks.
Doesn't automatically provide application-level rate limiting.
Interview point
ScheduledThreadPoolExecutor uses a delay-based queue rather than a normal FIFO queue.5. Single Thread Scheduled Executor
ScheduledExecutorService executor =
Executors.newSingleThreadScheduledExecutor();
ScheduledThreadPoolExecutor
│
↓
DelayedWorkQueue
│
↓
1 worker
Pros
Sequential execution.
Supports delayed and periodic tasks.
Useful for background jobs where only one execution should happen at a time.
Ordering is easier to reason about.
Cons
One slow task blocks subsequent tasks.
Limited throughput.
Queue is effectively unbounded.6. Work-Stealing Pool
ExecutorService executor = Executors.newWorkStealingPool();
Uses work-stealing and targets parallelism based on available processors.
Thread 1 → [T1][T2][T3]
Thread 2 → [T4]
Thread 3 → [T5][T6]
Thread 2 finishes
↓
steals T2
Many small/independent tasks, especially recursive or fork/join-style workloads.
This is very different from ThreadPoolExecutor.
Instead of one shared queue:
❌ Traditional:
Queue
[T1][T2][T3]
/ | \
W1 W2 W3
ForkJoinPool uses multiple worker-local deques.
Worker 1 → [T1][T2][T3]
Worker 2 → [T4][T5]
Worker 3 → [T6]
If Worker 3 becomes idle:
Worker 3
↓
steals task
↓
Worker 1's deque
Queue/deque
Uses work-stealing queues (WorkQueue) internally.
Pros
Excellent CPU utilization.
Workers can steal work from other workers.
Good for many small independent tasks.
Excellent for fork/join and recursive workloads.
Reduces contention compared with a single shared queue.
Cons
More complex behavior.
Not ideal for blocking I/O tasks.
Task ordering is not simple FIFO.
Work-stealing overhead may not be worthwhile for simple workloads.
Debugging can be more complicated.
ThreadPoolExecutor commonly uses a shared work queue, whereas ForkJoinPool uses multiple worker-local deques and work stealing.7. Thread-Per-Task Executor
Java 21+:
ExecutorService executor = Executors.newThreadPerTaskExecutor(threadFactory);
Executors.newThreadPerTaskExecutor(
Thread.ofPlatform().factory()
);
Creates a new platform thread for every task; the number of threads is unbounded.
Task 1 → Platform Thread 1
Task 2 → Platform Thread 2
Task 3 → Platform Thread 3
Task 4 → Platform Thread 4
There isn't a reusable worker pool with a work queue like ThreadPoolExecutor.
Pros
Simple concurrency model.
Each task gets its own thread.
No thread reuse complexity.
Useful when you explicitly want one platform thread per task.
Cons
Platform threads are expensive.
Large numbers of tasks can exhaust resources.
No bounded thread pool.
Generally inferior to virtual threads for high-concurrency blocking workloads.Creates a new platform thread for every task; the number of threads is unbounded
8. Virtual Thread Per Task Executor ⭐
Java 21+:
ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
Creates a new virtual thread for every task; the number of created threads is unbounded.
Task1 → Virtual Thread 1
Task2 → Virtual Thread 2
Task3 → Virtual Thread 3
Task4 → Virtual Thread 4
Queue?
There is no traditional worker-task queue like ThreadPoolExecutor has.
Virtual threads are scheduled by the JVM's virtual-thread scheduler onto a smaller number of carrier/platform threads.
Conceptually:
Tasks
↓
Virtual Threads
/ / | \ \
VT1 VT2 VT3 VT4 VT5 ...
↓
JVM scheduler
↓
Carrier threads
Pros ⭐
Extremely lightweight compared with platform threads.
Excellent for high-concurrency blocking I/O.
Very simple programming model.
Can support huge numbers of concurrent tasks.
Doesn't require manually tuning a traditional thread pool for each task.
Cons
Not automatically faster for CPU-bound work.
You still need to control external resources such as DB connections, HTTP connection pools, etc.
Some blocking/native operations can have special considerations.
Creating millions of virtual threads doesn't mean your database can handle millions of simultaneous queries.
Interview point ⭐⭐⭐
Don't create a fixed thread pool of virtual threads. Virtual threads are designed around one virtual thread per task, while concurrency should often be controlled at the resource level.Fixed Thread Pool
↓
LinkedBlockingQueue
↓
UNBOUNDED (Don't create more threads)
Cached Thread Pool
↓
SynchronousQueue
↓
NO CAPACITY Don't queue tasks -> Create/reuse threads
Scheduled Thread Pool
↓
DelayedWorkQueue
↓
ORDERED BY DELAY / EXECUTION TIME
ForkJoinPool
↓
WorkQueue / Deque per worker
↓
WORK STEALING
Virtual threads
↓
One thread per task
↓
No traditional worker queue
ThreadPoolExecutor roughly follows this decision process
when you submit a task:
submit(task)
│
↓
Is core thread available?
│ │
YES NO
│ │
↓ ↓
execute put task
in queue
│
↓
queue accepts?
│ │
YES NO
│ │
↓ ↓
wait create
new threadHow to create CompletableFuture object?
1. supplyAsync() — most common for returning a result
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
return "Hello";
});
String result = future.get(); //get() → calling thread waits until the result is available.
supplyAsync()
↓
execute task
↓
"Hello"
↓
CompletableFuture<String>2. runAsync() — async task with no result
CompletableFuture<Void> future =
CompletableFuture.runAsync(() -> {
System.out.println("Doing some work");
});
Use this when you don't need a return value.3. new CompletableFuture<>() — empty future ⭐
CompletableFuture<String> future = new CompletableFuture<>();
At this point it is:
CompletableFuture
↓
EMPTY
You must complete it yourself:
future.complete("Hello");
or exceptionally:
future.completeExceptionally(
new RuntimeException("Failed")
);
This is useful for callbacks/external events, where something else will provide the result later.4. completedFuture() — already completed
CompletableFuture.completedFuture() doesn't execute a task. It creates an already-completed future containing a known value. If I need to execute an asynchronous task, I use supplyAsync() for a result or runAsync() for a task without a result. completedFuture() is useful when I already have the result but my method's contract requires returning a CompletableFuture
CompletableFuture<String> future =
CompletableFuture.completedFuture("Hello");
CompletableFuture
↓
"Hello"
COMPLETED
String str = "Hello";
int length = str.length();
CompletableFuture<Integer> future =
CompletableFuture.completedFuture(length);
public static <U> CompletableFuture<U> completedFuture(U value)
Breakdown
public static
↓
<U> ← generic type
↓
CompletableFuture<U> ← return type
↓
completedFuture(U value) ← inputFor CompletableFuture, there are several ways to get the result:
get()— calling thread waitsjoin()— also waits. String result = future.join();
Main difference:
get()
→ throws checked exceptions
→ ExecutionException / InterruptedException
join()
→ throws unchecked CompletionException
future.get(); // blocking
future.join(); // blocking3. thenApply() — don't manually wait
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Integer> result =
future.thenApply(String::length);
The calling thread does not need to call get() to wait for future.
Calling thread
|
| create future
|
| attach thenApply
|
↓
continues doing other work
4. Multiple tasks — don’t wait for each one
If you need all results
CompletableFuture<Void> all =
CompletableFuture.allOf(f1, f2, f3);
allOf() itself doesn't block. It creates a combined future.
all.thenRun(() -> {
// all three completed
});
Or if you need the actual results:
CompletableFuture<String> f1 =
CompletableFuture.supplyAsync(() -> "A");
CompletableFuture<String> f2 =
CompletableFuture.supplyAsync(() -> "B");
CompletableFuture<String> f3 =
CompletableFuture.supplyAsync(() -> "C");
CompletableFuture<List<String>> result =
CompletableFuture.allOf(f1, f2, f3)
.thenApply(v -> List.of(
f1.join(),
f2.join(),
f3.join()
));
//output. [A, B, C]
if allOf() itself completed exceptionally, this:
.thenApply(v -> ...)
is skipped.
You can handle -----------
CompletableFuture<List<String>> result =
CompletableFuture.allOf(f1, f2, f3)
.thenApply(v -> List.of(
f1.join(),
f2.join(),
f3.join()
))
.exceptionally(ex -> {
System.out.println("Something failed: " + ex.getMessage());
return List.of();
});
Here join() is safe inside the thenApply()
because allOf() has already completed all three
Important:
allOf() waits for all the supplied futures to complete, even if one fails.
So it doesn't mean:
"One failed → immediately return."
If you do:
all.join();
you'll get a CompletionException because the combined future completed exceptionally.CompletableFuture<Integer> result is not the actual Integer. It is a container/promise that will eventually contain the Integer.
If you actually need the Integer
Yes, you need to extract it somehow.
Option 1: get() — blocks
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Integer> result =
future.thenApply(String::length);
Integer value = result.get();
System.out.println(value); // 5Option 2: join() — also blocks
Integer value = result.join();
System.out.println(value); // 5Same basic idea, but join() throws unchecked CompletionException rather than checked ExecutionException/InterruptedException.
But you don’t always need to extract it
This is the important part of asynchronous programming.
If you want to do something with the integer, don’t necessarily call get().
For example:
result.thenAccept(length -> {
System.out.println("Length is " + length);
});
CompletableFuture<String> finalResult =
future
.thenApply(String::length)
.thenApply(length -> "Length = " + length);
CompletableFuture.get()
get() is used to retrieve/wait for the result.
Important: get() blocks the calling thread until the future completes.
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello");
String result = future.get();
CompletableFuture<String>
↓
get()
↓
waits if necessary
↓
"Hello"CompletableFuture.complete()
complete() is used to manually provide the result to the CompletableFuture.
CompletableFuture<String> future =
new CompletableFuture<>();
future.complete("Hello");
CompletableFuture<String>
↓
complete("Hello")
↓
COMPLETED
↓
"Hello"
future.get();
can now receive "Hello".
complete() → PUT the result
get() → GET the result
CompletableFuture<String> future =
new CompletableFuture<>();
boolean first = future.complete("A");
System.out.println(first); // true
boolean second = future.complete("B");
System.out.println(second); // false
System.out.println(future.get()); // AWhen we need complete method?
You use this pattern when some external/manual process will produce the result later, rather than the result being directly produced by supplyAsync().
1. Normal async work → use supplyAsync()
If you control the work:
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
// do work
return "DONE";
});
supplyAsync()
↓
execute work
↓
return result
↓
CompletableFuture completed
You don't need complete() yourself.2. Manual completion → use new CompletableFuture<>()
Use this when something else will tell you when the result is ready.
For example, imagine an external callback:
CompletableFuture<String> future =
new CompletableFuture<>();
externalApi.call(result -> {
future.complete(result);
});
Your code
↓
create CompletableFuture
↓
give it to/capture in callback
↓
External API finishes
↓
callback receives result
↓
future.complete(result)
↓
future is completed3. A common real-world example: callback API
Suppose you have an old asynchronous API:
interface Callback {
void onSuccess(String result); //You don't control when onSuccess() or onError() gets called.
void onError(Throwable error);
}
void callApi(Callback callback) {
// Library internally makes HTTP request
// Later, when response arrives:
callback.onSuccess(response);
// OR if something fails:
callback.onError(exception);
}
You want to expose it as a CompletableFuture:
CompletableFuture<String> callApiAsync() {
CompletableFuture<String> future =
new CompletableFuture<>();
callApi(new Callback() {
@Override
public void onSuccess(String result) {
future.complete(result);
}
@Override
public void onError(Throwable error) {
future.completeExceptionally(error);
}
});
return future;
}
Now callers can use modern CompletableFuture APIs:
callApiAsync()
.thenApply(result -> result.toUpperCase())
.thenAccept(System.out::println);supplyAsync()
→ START async work
thenApply()
→ value → value
thenCompose()
→ value → CompletableFuture<value>
↓
flatten
thenAccept()
→ value → nothing
thenRun()
→ don't need value → run somethingDiff between
thenCompose() = chain dependent async operations
thenCombine() = combine two independent async results
1. thenCombine() → two independent operations
Suppose:
Get User ──────────┐
↓
Combine
↓
User + Account
↑
Get Account ───────┘
Neither operation depends on the other.
CompletableFuture<User> userFuture = getUser();
CompletableFuture<Account> accountFuture = getAccount();
CompletableFuture<UserAccount> result =
userFuture.thenCombine(
accountFuture,
(user, account) -> new UserAccount(user, account)
);Both can execute independently/concurrently.
2. thenCompose() → one depends on the previous result
Suppose:
Get User
↓
Get User OrdersThe second call needs the result of the first.
The second call needs the result of the first.
CompletableFuture<User> userFuture =
getUser();
CompletableFuture<List<Order>> ordersFuture =
userFuture.thenCompose(user ->
getOrders(user.getId())
);
Without thenCompose, you'd get:
CompletableFuture<CompletableFuture<List<Order>>>
thenCompose() flattens it to:
CompletableFuture<List<Order>>thenCompose() → multiple tasks in a chain
You can chain many dependent async operations:
CompletableFuture<String> result =
task1()
.thenCompose(r1 -> task2(r1))
.thenCompose(r2 -> task3(r2))
.thenCompose(r3 -> task4(r3));
Flow:
Task1
↓ result1
Task2(result1)
↓ result2
Task3(result2)
↓ result3
Task4(result3)
Each next task depends on the previous result.thenCombine() → multiple independent tasks
thenCombine() combines two futures at a time, but you can chain it:
CompletableFuture<Result> result =
task1()
.thenCombine(task2(), (r1, r2) -> combine(r1, r2))
.thenCombine(task3(), (combined, r3) -> combine(combined, r3));
similar to below code
CompletableFuture<String> result12 =
task1.thenCombine(
task2,
(r1, r2) -> r1 + r2
);
CompletableFuture<String> result =
result12.thenCombine(
task3,
(combined, r3) -> combined + r3
);
task1 + task2
↓
result12 (CompletableFuture)
↓
combine with task3
↓
result123 (CompletableFuture)
Task1 ─────┐
├──→ Combine12 ─────┐
Task2 ─────┘ │
├──→ Final
Task3 ──────────────────────────┘
The important thing is that task1(), task2(), and task3() can be independent.If you have MANY independent tasks
For example, 10 tasks:
CompletableFuture.allOf(
task1(),
task2(),
task3(),
// ...
task10()
);
is usually more appropriate when you simply want:
Wait until all tasks finish.
Compare thenApply vs thenCompose
supplyAsync() //Starts an asynchronous task and returns a CompletableFuture<T>.
thenApply() //Takes a result and transforms it into a normal value.
thenAccept(). //Takes a result and consumes it; returns CompletableFuture<Void>.
thenRun(). //Runs something after completion, but doesn't need the previous result.
thenRun()
→ Don't need previous result
→ Just run something
→ CompletableFuture<Void>So why thenCompose()?
Imagine:
CompletableFuture<User> userFuture = getUser();
Now you want to call: getOrders(userId)
But getOrders() itself returns:
CompletableFuture<List<Order>>
So:
userFuture.thenApply(user -> getOrders(user.getId()));
gives you:
CompletableFuture<CompletableFuture<List<Order>>>
That's a nested Future ❌.
thenCompose() removes that nesting:
CompletableFuture<List<Order>> ordersFuture =
userFuture.thenCompose(
user -> getOrders(user.getId())
);
Result:
CompletableFuture<List<Order>>thenApply
CompletableFuture<String> result =
future.thenApply(user -> user.getName());
The function user.getName() return : String
CompletableFuture<User>
↓ thenApply
CompletableFuture<String>
****for the same example if i apply thenCompose, This will NOT compile. ❌
Why? Because thenCompose() expects the lambda to return a CompletableFuture,
not a plain String.
CompletableFuture<User>
↓ thenCompose
CompletableFuture<String>
thenCompose
CompletableFuture<List<Order>> result =
future.thenCompose(
user -> getOrders(user.getId())
);
------------
suppose
CompletableFuture<String> getUserName(User user) {
return CompletableFuture.supplyAsync(() -> user.getName());
}
If you used thenCompose() here:
CompletableFuture<String> result =
future.thenCompose(user -> getUserName(user));
CompletableFuture<User>
↓
thenCompose
↓
CompletableFuture<String>
If you used thenApply() here:
CompletableFuture<CompletableFuture<String>> result =
future.thenApply(user -> getUserName(user));
return :
CompletableFuture<CompletableFuture<String>>
But thenCompose() flattens that:
CompletableFuture<CompletableFuture<String>>
↓
thenCompose
↓
CompletableFuture<String>Diff between AnyOf and AllOf method of CompletableFuture
CompletableFuture<Object> any =
CompletableFuture.anyOf(first, second, third);
CompletableFuture<String> f1 = ...;
CompletableFuture<Integer> f2 = ...;
CompletableFuture<Double> f3 = ...;
CompletableFuture<Object> result =
CompletableFuture.anyOf(f1, f2, f3);
Even though all three futures are: CompletableFuture<String>
anyOf() returns: CompletableFuture<Object>
first ──┐
second ──┼──> anyOf() ──> whichever finishes FIRST
third ──┘
And one subtle point: anyOf() doesn't cancel the remaining futures.
They can continue running in the background after the first one completes.
anyOf()
↓
First completion
↓
Success OR Exception
Requirement Method
Give me whoever finishes first anyOf()
Wait for everyone allOf()
First result + eventually all results anyOf() + allOf()
anyOf() = first completed (success OR failure)
Custom CompletableFuture + complete() = first successful
first successful
↓
Ignore failures
↓
Complete shared CompletableFuture
↓
First success winsanyOf() = I don't care which future completes first; give me its result.
allOf() = I need ALL futures to complete.
4. Why does allOf() return Void?
You might expect:
CompletableFuture<String>
but:
CompletableFuture.allOf(first, second, third)
return CompletableFuture<Void>
because allOf() doesn't combine the results.
You cannot do: as There is no combined result.
all.thenAccept(result -> {
// result is Void
});
For example:
first → "A"
second → "B"
third → "C"
allOf() basically tells you:
"All three are finished."
It doesn't automatically give you:
["A", "B", "C"]
You have to retrieve those yourself:
CompletableFuture.allOf(first, second, third)
.thenRun(() -> {
System.out.println(first.join());
System.out.println(second.join());
System.out.println(third.join());
});
Output:
A
B
C
imagine
first → ✅ "A"
second → ❌ Exception
third → ✅ "C"
allOf() will complete exceptionally.
all.join();
will throw an exception.
But there is an important detail:
allOf() still waits for all the supplied futures to complete before it completes
But what if one task failed?
Then this:
all.thenRun(() -> {
System.out.println(first.join());
System.out.println(second.join());
System.out.println(third.join());
});
will not execute, because all completed exceptionally.
all.whenComplete((value, ex) -> {
if (ex != null) {
System.out.println("At least one task failed: " + ex);
return;
}
System.out.println(first.join());
System.out.println(second.join());
System.out.println(third.join());
});
5. Can we avoid join()?
Yes, depending on what you're trying to do.
For example, you can chain directly:
first.thenCombine(second, (a, b) -> ...)
.thenCombine(third, (result, c) -> ...);
Here you don't manually call join() to extract results.
Example:
CompletableFuture<String> result =
first.thenCombine(second, (a, b) -> a + " " + b)
.thenCombine(third, (ab, c) -> ab + " " + c);2. What if ALL tasks fail?
Same behavior.
first → ❌
second → ❌
third → ❌allOf() completes exceptionally.
If your requirement is:
“Return the first successful result. If one task fails, ignore it and wait for another task to succeed.”
then CompletableFuture.anyOf() is NOT the right solution by itself.
CompletableFuture<String> first = multipledataFetch.first();
CompletableFuture<String> second = multipledataFetch.second();
CompletableFuture<String> third = multipledataFetch.third();
CompletableFuture<String> f1 =
first.exceptionally(ex -> null);
CompletableFuture<String> f2 =
second.exceptionally(ex -> null);
CompletableFuture<String> f3 =
third.exceptionally(ex -> null);
CompletableFuture.anyOf(f1, f2, f3)
.thenAccept(result -> {
if (result != null) {
System.out.println("Successful result: " + result);
}
});
But there is a major problem: if first fails, exceptionally() immediately returns null, so anyOf() may consider that the first completion.
So this doesn't truly mean first successful.Better approach: Java 9+ CompletableFuture composition
CompletableFuture<String> result =
first.handle((value, ex) -> {
if (ex == null) {
return CompletableFuture.completedFuture(value);
}
return second;
}).thenCompose(future -> future);
result.thenAccept(System.out::println);
CompletableFuture<String> result = new CompletableFuture<>();
first.whenComplete((value, ex) -> {
if (ex == null) {
result.complete(value);
}
});
second.whenComplete((value, ex) -> {
if (ex == null) {
result.complete(value);
}
});
third.whenComplete((value, ex) -> {
if (ex == null) {
result.complete(value);
}
});
result.thenAccept(value ->
System.out.println("First successful result: " + value)
).join();
CompletableFuture.complete() only succeeds once.
Suppose:
first → ❌
second → ❌
third → ✅ "Success"
Then:
third -> result.complete("Success") completes result.
If later another task succeeds:
second -> result.complete("Another")
that call returns false because result is already completed.
SUCCESS FAILURE
│ │
▼ ▼
value exception
│ │
┌────┴─────────────┴────┐
│ │
handle() whenComplete()
│ │
Can transform Just observe
result/exception result/exception
│ │
▼ ▼
New CompletableFuture Same outcome1. exceptionally() → Recovery
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
// DB call
return "Priyanka";
});
Use it when:
"If the DB call fails, give me a fallback."
CompletableFuture<String> result =
future.exceptionally(ex -> {
System.out.println("DB failed");
return "Unknown User";
});
DB call
│
├── Success → "Priyanka" ────────┐
│ │
└── Failure → exceptionally() → "Unknown User"
│
▼
Result
If DB succeeds:
"Priyanka"
If DB fails:
"Unknown User"2. handle() → Success + Failure
Use it when:
“I want to look at both the result AND exception and decide what the final result should be.”
CompletableFuture<String> result =
future.handle((value, ex) -> {
if (ex != null) {
return "Unknown User";
}
return value.toUpperCase();
});
future
│
┌──────┴──────┐
│ │
SUCCESS FAILURE
│ │
"Priyanka" ex
│ │
└──────┬──────┘
↓
handle()
↓
┌─────────┴─────────┐
│ │
"PRIYANKA" "Unknown User"
So handle() can process both paths.
3. whenComplete() → Observe
Use it when:
“I just want to know what happened — usually for logging/metrics.”
CompletableFuture<String> result =
future.whenComplete((value, ex) -> {
if (ex != null) {
System.out.println("DB failed: " + ex);
} else {
System.out.println("DB success: " + value);
}
});
future
│
├── "Priyanka" ──→ whenComplete() ──→ "Priyanka"
│
└── Exception ───→ whenComplete() ──→ Exception“exceptionally() is useful when I only care about recovering from failures. handle() is useful when I need to inspect both the successful result and the exception and produce a new result in either case."
Interview ***
If a CompletableFuture completes exceptionally, the normal success-stage methods won't execute.
1. supplyAsync() throws an exception
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Something went wrong");
});
The future becomes:
supplyAsync
↓
❌ Exception
↓
CompletableFuture<String> (EXCEPTIONAL)
future.thenApply(value -> {
System.out.println("thenApply");
return value.toUpperCase();
});
future.thenAccept(value -> {
System.out.println("thenAccept");
});
future.thenRun(() -> {
System.out.println("thenRun");
});
None of these execute.
supplyAsync()
│
❌ Exception
│
▼
thenApply() ← SKIPPED
│
▼
thenApply() ← SKIPPED
3.. But exceptionally() DOES execute
CompletableFuture<String> result =
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Failed");
})
.thenApply(value -> value.toUpperCase())
.exceptionally(ex -> {
System.out.println("Error: " + ex.getMessage());
return "DEFAULT";
});
supplyAsync()
↓
❌
↓
thenApply() SKIPPED
↓
exceptionally() ✅ executes
↓
"DEFAULT"
Now result completes normally with: DEFAULT
4. What about handle()?
handle() is even more powerful because it runs for both success and failure.
CompletableFuture<String> result =
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Failed");
})
.handle((value, ex) -> {
if (ex != null) {
return "DEFAULT";
}
return value.toUpperCase();
});
handle() executes:
Success ──────┐
├──> handle() ✅
Failure ──────┘
Important
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Failed");
})
.thenApply(x -> {
// skipped
return x;
})
.exceptionally(ex -> {
System.out.println("Handling error");
return "Recovered";
})
.thenApply(x -> {
System.out.println("Now this runs");
return x + "!";
});
//output
Handling error
Now this runs
Why does the last thenApply() run?
Because exceptionally() recovered the exception by returning "Recovered".
So the chain changes from:
EXCEPTIONAL
to:
SUCCESS("Recovered")
These three are the async versions of thenRun(), thenAccept(), and thenApply().
1. thenRunAsync()
Use when:
You don’t need the previous result, and you don’t return a result.
CompletableFuture<Void> thenRunAsync(Runnable action)
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Void> result =
future.thenRunAsync(() -> {
System.out.println("Task completed");
});
2. thenAcceptAsync()
Use when:
You need the previous result, but you don’t return a new result.
CompletableFuture<Void> thenAcceptAsync(
Consumer<? super T> action
)
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Void> result =
future.thenAcceptAsync(value -> {
System.out.println(value);
});
//output Hello
3. thenApplyAsync()
Use when:
You need the previous result AND want to return a new result.
<U> CompletableFuture<U> thenApplyAsync(
Function<? super T, ? extends U> fn
)
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Integer> result =
future.thenApplyAsync(value -> value.length());
The Async part answers a different question:
“Which thread should execute the next stage?”
thenApply() and thenApplyAsync() both use the previous result. Async is about execution, not data.
Without Async
future.thenApply(value -> value.length());
The next action may run in the same thread that completes the previous future.
Thread-1
│
├── execute task
│
├── task completes → "Hello"
│
└── thenApply() executes“thenApply and thenApplyAsync both receive the previous result and transform it. The difference is execution behavior. The non-Async version may execute the continuation in the thread that completes the previous stage, whereas the Async version schedules the continuation asynchronously, using the common pool by default or a supplied executor."
We need the Async versions mainly to control where the next stage runs and to avoid doing expensive/blocking work in the thread that completed the previous stage.
CPU-bound vs I/O-bound
This is a useful interview concept.
CPU-bound
Examples:
Image processing
Encryption
Complex calculations
Large sortingI/O-bound
Examples:
Database call
HTTP call
File operation
External service1. ForkJoinPool.commonPool()
This is a shared JVM-wide pool.
Your application
│
├── Task A ──┐
├── Task B ──┤
├── Task C ──┤
└── Task D ──┘
↓
ForkJoinPool.commonPoolDifferent parts of your application can use the same pool.
It’s designed primarily for CPU-oriented parallel work.
2. Your own Executor
You can create a dedicated executor:
Application
│
├── CPU tasks → commonPool
│
└── API calls → your executor
│
10 threadsThis gives you control over:
- Number of threads
- Queueing
- Isolation
- Shutdown
- Workload separation
3. Why isolation matters
Imagine you have:
thenApplyAsync(value -> callSlowAPI(value));and you use the common pool.
Suppose many requests start slow API calls:
CommonPool
│
├── API call ── waiting
├── API call ── waiting
├── API call ── waiting
├── API call ── waiting
└── API call ── waiting
Now another part of your application also needs the common pool:
calculateImportantResult()
It may have to compete for the same shared resources.
With a dedicated executor:
CommonPool
│
└── CPU work
API Executor
│
├── API call
├── API call
├── API call
└── API call
The workloads are isolated.thenCombine() and thenCompose() are non-blocking and deferred, but calling them does not mean they start the original tasks.
Youtube link







