Sitemap

Asynchronous Programming in Java

31 min readAug 7, 2026

--

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()
↓
finishes

If taskA() takes 5 seconds:

taskA → 5 sec
taskB → 2 sec
taskC → 1 sec
Total = 8 sec

The 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
↓
Continue

The 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 API

Each takes 2 seconds.

Synchronous

Payment API
↓ 2 sec
Email API
↓ 2 sec
Notification API
↓ 2 sec

Total ≈ 6 sec

If 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 seconds

rather 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 C

Asynchronous 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 Thread

The 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 work

The 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 1000

This is inefficient.

Threads consume memory and have creation/context-switching overhead.

Instead, we use a thread pool.

7. ExecutorService

Java provides:

ExecutorService

It 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 5

We 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 tasks

and:

5 threads

The executor manages the work:

Executor
|
┌──────┼──────┐
↓ ↓ ↓
Thread1 Thread2 Thread3
↓ ↓ ↓
Task1 Task2 Task3
Remaining tasks stay in a queue.

When Thread 1 finishes Task 1:

Thread 1
↓
Task 1 complete
↓
Task 6

This 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 3

10. 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:

30

So 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 CompletionException

11. CompletableFuture

This is where:

CompletableFuture

becomes 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 result

14. 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()
↓
8

thenApply() 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 result

17. 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 → action

18. Chaining asynchronous operations

Suppose:

Step 1 → Get User
Step 2 → Get Orders
Step 3 → Process Orders

We 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.

  1. It cannot be manually completed.
  2. Multiple Futures cannot be chained together.
  3. We can not combine multiple future together.
  4. 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 zero

Output:

Task is running
Task Completed

future.get() is the important part

public T get() throws InterruptedException, ExecutionException

This 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 a Runnable and does not return a result. If the task throws an exception, it is propagated to the thread's uncaught-exception handler.
  • submit() accepts a Runnable or Callable and returns a Future. The Future can 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 the Future and are observed when calling future.get(), wrapped in an ExecutionException.

“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 a Future whose result is null, while submit(Runnable, T result) returns a Future containing 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
|
↓
ExecutionException

Without get(), an exception thrown by a task submitted with submit() remains inside the Future, so it doesn't directly affect the main thread. Calling get() retrieves the result and, if the task failed, propagates the failure to the calling thread as an ExecutionException.

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 zero
  • Executor → A functional interface that provides a simple way to execute tasks asynchronously without managing threads directly.
  • ExecutorService → Extends Executor and provides task submission, Future handling, lifecycle management, and shutdown.
  • Executors → A utility class that provides factory methods for creating ExecutorService and 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 → exceptional
Press enter or click to view image in full size

invokeAll() 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 tasks

3. 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 ThreadPerTaskExecutor

More 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 ThreadPoolExecutor with a bounded BlockingQueue such as ArrayBlockingQueue, configure an appropriate maximum pool size, and define a suitable RejectedExecutionHandle.

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 thread

3. 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.
Press enter or click to view image in full size
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 thread

How to create CompletableFuture object?

Press enter or click to view image in full size

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) ← input

For CompletableFuture, there are several ways to get the result:

  1. get() — calling thread waits
  2. join() — also waits. String result = future.join();

Main difference:

get()
→ throws checked exceptions
→ ExecutionException / InterruptedException

join()
→ throws unchecked CompletionException
future.get(); // blocking
future.join(); // blocking

3. 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); // 5

Option 2: join() — also blocks

Integer value = result.join();

System.out.println(value); // 5

Same 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()); // A

When 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 completed

3. 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 something

Diff 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 Orders

The 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>>
Press enter or click to view image in full size

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 wins

anyOf() = I don't care which future completes first; give me its result.

allOf() = I need ALL futures to complete.

Press enter or click to view image in full size

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 outcome

1. 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")

Press enter or click to view image in full size

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 sorting

I/O-bound

Examples:

Database call
HTTP call
File operation
External service

1. ForkJoinPool.commonPool()

This is a shared JVM-wide pool.

Your application
│
├── Task A ──┐
├── Task B ──┤
├── Task C ──┤
└── Task D ──┘
↓
ForkJoinPool.commonPool

Different 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 threads

This 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

https://youtu.be/oFRtBuRviHM?si=6XHUbvdTib77-3hd

https://www.youtube.com/watch?v=izKA5jjaRes&t=3s

--

--