Multithreading in Java
To create a new thread in Java, there are two ways:
Extend the thread class
Implement the Runnable Interface
Extending the thread class
This is the main class. Here, start() method is called to initiate new thread
package com.basics;
public class Main {
public static void main(String[] args) {
World impl = new World();
impl.start(); //Initiates new thread
for(;;) {
System.out.println(Thread.currentThread().getName()); //Thread-0
// System.out.println("Hello");
}
}
}
A new class World is created that extends Thread
The run method is overridden to define the code that constitutes the new thread.
package com.basics;
public class World extends Thread {
@Override
public void run() {
for(int i=0;i<100000;i++) {
System.out.println(Thread.currentThread().getName()); //main
}
}
}
Output:
Consists of Random order of :
Thread-0
main
Implementing the runnable interface
A new class World1 is created that implements Runnable interface.
Run method is overridden to define code that constitutes new thread
A thread object is created by passing an instance of World1 class.
start method is called on the Thread object to initiate the new thread.
package com.basics;
public class Main {
public static void main(String[] args) {
World1 impl = new World1();
// impl.start();
/*
* impl.start() will not work as runnable interface does not
* have start() method. Hence we create an instance of thread class.
* start() method is present in Thread class.
*/
Thread t1 = new Thread(impl);
t1.start();
for(;;) {
System.out.println("Hello");
}
}
}
package com.basics;
public class World1 implements Runnable {
@Override
public void run() {
for(;;) {
System.out.println("World");
}
}
}
In both the cases, run method contains the code that will be executed in the new thread.
Thread Lifecycle
New: The thread is created, but
start()
has not been called.Runnable: The thread is ready to run, awaiting CPU time.
Running: The thread is currently executing.
Blocked: The thread is waiting to acquire a lock.
Waiting: The thread is waiting indefinitely for another thread to perform an action.
Timed Waiting: The thread is waiting for a specified amount of time.
Terminated: The thread has finished executing.
+-----------+ start() +-----------+
| New | -----------------> | Runnable |
+-----------+ +-----------+
|
| Thread scheduler selects thread
v
+-----------+
| Running |
+-----------+
/ | \\
/ | \\
v v v
+-----------+ +-----------+ +-------------+
| Blocked | | Waiting | | TimedWaiting |
+-----------+ +-----------+ +-------------+
| | |
| | |
v v v
(Back to Runnable) |
\\ /
\\ /
v v
+-----------+
| Terminated|
+-----------+
Example 1:
package com.basics;
public class Main {
public static void main(String[] args) {
World impl = new World(); //NEW (Object create karte hai)
impl.start(); //Initiates new thread - RUNNABLE (start method call hota hai)
for(;;) {
System.out.println(Thread.currentThread().getName()); //Thread-0
// System.out.println("Hello");
}
}
}
package com.basics;
public class World extends Thread {
@Override
public void run() {
for(int i=0;i<100000;i++) {
System.out.println(Thread.currentThread().getName()); //main - Thread is in RUNNING state (CPU ko time mil gaya)
}
}
}
Example 2:
package com.basics;
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("RUNNING"); //RUNNING
try {
Thread.sleep(2000); //t1 ke execution ko pause karwana
} catch (InterruptedException e) {
System.out.println(e);
}}
/*
* Why did we use try/catch block instead of throws declaration??
* We are overriding already declared run method
* Original run method is:
* @Override
* public void run() {
* if (target != null) {
* target.run();
* }}
* Since it originally does not throw any exception we use try/catch
*/
public static void main(String[] args) throws InterruptedException {
MyThread t1 = new MyThread();
System.out.println(t1.getState()); //NEW
t1.start();
System.out.println(t1.getState()); //RUNNABLE
Thread.sleep(100); //Run method ko chalne ka mauka and time dena. Main method is paused.
System.out.println(t1.getState()); //TIMED_WAITING
t1.join();
//Jis thread par join method call kiya jata hai
//uske finish hone ka wait kar raha hai main method
//join method ko chalane wala is main method
System.out.println(t1.getState()); //TERMINATED
}}
Thread Methods
Thread.join()
Purpose: If you want to ensure that the main
thread waits for all the other threads to finish execution before exiting, you might consider using join()
after starting the threads.
How It Works:
When you call
join()
on a thread, the calling thread (usually the main thread) will be blocked until the thread you calledjoin()
on has completed its execution.This method is particularly useful when you want to ensure that all threads have completed their work before the main thread or another thread proceeds with its task.
Thread.sleep()
Purpose: Thread.sleep(long millis)
is a static method that pauses the execution of the current thread for the specified number of milliseconds.
How It Works:
When a thread calls
Thread.sleep(1000)
, it goes into a "sleep" state for 1 second (1000 milliseconds). During this time, the thread does not consume CPU resources but remains in memory.After the sleep duration is over, the thread transitions back to the "runnable" state, ready to resume execution.
Important Notes:
Thread.sleep()
can throw anInterruptedException
if another thread interrupts the sleeping thread. This exception must be handled either with a try-catch block or by throwing it in the method signature.
Thread.start()
Purpose: Thread.start()
is used to start the execution of a new thread. When you call start()
on a Thread
object, it moves the thread from the "new" state to the "runnable" state, allowing the JVM to schedule it for execution.
How It Works:
When
start()
is called, therun()
method of theThread
(orRunnable
passed to it) is executed in a new thread, running concurrently with the main thread and any other threads.It's important to note that calling
run()
directly will not start a new thread; it will just execute therun()
method in the current thread.
Important Notes:
- You should never call
start()
more than once on the same thread object. Doing so will throw anIllegalThreadStateException
.
Thread.setPriority(int newPriority)
Purpose: Thread.setPriority()
used to set priority for thread execution
MAX_PRIORITY = 10
NORM_PRIORITY = 5
MIN_PRIORITY = 1
How It Works:
Threads with higher priorities are generally given preference by the thread scheduler for execution over lower-priority thread. However, execution of threads totally depends on the Operating System, no of cores in the OS and the CPU. We can say that the execution of threads using priority can be slightly regulated (Thread with highest priority may finish its execution first {not true for every execution} ).
package threadmethods;
public class MyThread extends Thread {
/*
Below method is used for custom naming of threads
*/
public MyThread(String name) {
super(name);
}
@Override
public void run() {
System.out.println("Thread is Running...");
for(int i=1;i<=5;i++) {
// String a = "";
for(int j=0; j<5; j++) {
// a += a;
System.out.println(Thread.currentThread().getName()+ " - Priority: "+ Thread.currentThread().getPriority() + " - count: " + i);
try {
Thread.sleep(1000);
}catch (InterruptedException e) {
e.printStackTrace();
}}
}}
public static void main(String[] args) throws InterruptedException {
MyThread l = new MyThread("Low Priority Thread");
MyThread m = new MyThread("Medium Priority Thread");
MyThread n = new MyThread("High Priority Thread");
l.setPriority(Thread.MIN_PRIORITY);
m.setPriority(Thread.NORM_PRIORITY);
n.setPriority(Thread.MAX_PRIORITY);
l.start();
m.start();
n.start();
}}
Output:
Thread is Running...
Thread is Running...
Thread is Running...
High Priority Thread - Priority: 10 - count: 1
Low Priority Thread - Priority: 1 - count: 1
Medium Priority Thread - Priority: 5 - count: 1
''
''
''
High Priority Thread - Priority: 10 - count: 5
Medium Priority Thread - Priority: 5 - count: 5
Low Priority Thread - Priority: 1 - count: 5
(Order is totally random, depends on the Operating System)
Thread.interrupt()
- Purpose:
Thread.interrupt()
is used to interrupt a thread that is either sleeping, waiting, or otherwise blocked. It sets the thread's interrupt status, and if the thread is currently in a blocking state, it will receive anInterruptedException
.
How It Works:
- If a thread is interrupted while it is sleeping or waiting, it will throw an
InterruptedException
. If it is not in such a state, its interrupt status is set, and the thread can check this status usingThread.interrupted()
orisInterrupted()
.
Important Notes:
It's good practice to handle
InterruptedException
properly, either by stopping the thread's execution or by re-interrupting the thread if it needs to propagate the interrupt status.If an interrupted thread is not handled properly, the information that the thread was interrupted could be lost. This could lead to issues where the thread continues executing even though it was meant to be interrupted, potentially causing unexpected behavior in the program.
package threadmethods;
public class MyThread1 extends Thread{
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println("Thread is running ...");
} catch (InterruptedException e) {
System.out.println("Thread interrupted: "+e);
}}
public static void main(String[] args) {
MyThread1 t1 = new MyThread1();
t1.start();
t1.interrupt(); //Thread interrupted: java.lang.InterruptedException: sleep interrupted
}}
Interrupt method stops the execution of the current thread.
Thread.yield()
Purpose*:* Thread.yield()
is a static method that suggests the current thread temporarily pause its execution to allow other threads of the same or higher priority to execute.
How It Works:
When a thread calls
yield()
, it moves from the running state to the runnable state, and the thread scheduler can then decide to schedule another thread to run.It's important to note that
yield()
is just a hint to the thread scheduler, and the actual behavior may vary depending on the JVM and OS.
package threadmethods;
public class MyThread1 extends Thread{
@Override
public void run() {
for(int i=0; i<5;i++) {
System.out.println(Thread.currentThread().getName() + " is running...");
Thread.yield();
}
public static void main(String[] args) {
MyThread1 t1 = new MyThread1(); //Thread-0
MyThread1 t2 = new MyThread1(); //Thread-1
t1.start();
t2.start();
}}
Using yield method we can obtain somewhat regulated method, but not always. A hint to the scheduler that the current thread is willing to yield its current use of a processor. The scheduler is free to ignore this hint.
Output:
Thread-0 is running...
Thread-1 is running...
Thread-0 is running...
Thread-1 is running...
Thread-1 is running...
Thread-1 is running...
Thread-1 is running...
Thread-0 is running...
Thread-0 is running...
Thread-0 is running...
Thread.setDaemon(boolean)
Purpose: Sets a thread as a daemon thread, which runs in the background and doesn't prevent the JVM from exiting when all user threads finish.
Key Point: Daemon threads are automatically terminated when all user threads finish. This method must be called before starting the thread; otherwise, it throws an
IllegalThreadStateException
.
package threadmethods;
public class MyThread extends Thread {
@Override
public void run() {
while(true) {
System.out.println("Hello world! " + Thread.currentThread().getName());
}}
public static void main(String[] args) {
MyThread d1 = new MyThread();
d1.setDaemon(true);
//d1 is User thread - jo useful work karta hai,business logic implement karta hai
d1.start();
System.out.println("Main Done");
//DAEMON THREADS - those threads which run in background (for ex. in Java - Garbage collector). JVM does not wait for these threads.
}}
Output:
Main Done
When I ran the code ( in Eclipse[Java -17]), it showed only Main Done. In this case, no printing by d1 (daemon thread) is performed. However, when the instructor ran the same code, first Main Done, was printed then few lines had Hello world!
Instructor’s Output ( Instructor’s code :Does not consist code for printing the thread’s name):
Main Done
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Process finished with exit code 0
According to this output, d1.start() sends the thread into runnable state, CPU executes some code and then Main thread prints Main Done. Later on, it is realized that d1 is actually a daemon thread, which is a background task. If other tasks are completed, the code stops executing after that, even though, there is infinite loop.
Synchronization
1. Race Condition
A race condition occurs when two or more threads access shared resources (like variables or objects) concurrently, and the final outcome depends on the timing of their execution. If the threads' operations are not properly synchronized, this can lead to unpredictable and incorrect results.
package com.synchronization;
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
package com.synchronization;
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
MyThread t1 = new MyThread(counter);
MyThread t2 = new MyThread(counter);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (Exception e) {
}
System.out.println(counter.getCount());
//Random output is generated because the same variable is tweaked by the
//two threads (they share common object counter. Considering if t1 incremented
// once, t2 will consider that the increment has taken place due to it.
//This would result in value of count <= 2000 (which changes randomly)
}
}
package com.synchronization;
public class MyThread extends Thread {
private Counter counter;
public MyThread(Counter counter) {
this.counter = counter;
}
@Override
public void run() {
for(int i=0;i<1000;i++) {
counter.increment();
}
}
}
In this example, the expected final count should be 2000, but due to the race condition, it may not always be the case.
2. Critical Section
The critical section is the part of the code that accesses shared resources and must be executed by only one thread at a time to avoid race conditions.
public void increment() {
count++;
}
Above code is critical section which needs to be modified such that the two threads access it one at a time to ensure correct count value.
3. Mutual Exclusion
Mutual exclusion is a concept used to prevent race conditions by ensuring that only one thread can access the critical section (shared resource) at a time. In Java, this can be achieved using synchronization.
4**. Synchronization (Keyword and Block)**
Synchronization in Java is a mechanism that ensures that two or more concurrent threads do not simultaneously execute particular segments of the program that access shared resources.
Synchronization using the
synchronized
keyword:- This keyword can be used to make a method synchronized or to create a synchronized block within a method.
// Synchronized method
public synchronized void increment() {
count++;
}
Synchronized Method: The entire method is locked for a single thread at a time.
Synchronized Block: Only the block of code within the method is locked, allowing more fine-grained control.
public void increment() {
synchronized (this) { // Synchronized block (this refers to current object)
count++;
}
}
In this example, the synchronized
block ensures that only one thread can execute the critical section of the code at a time, preventing race conditions while allowing other code in the method to be executed by different threads concurrently.
Locks
Intrinsic Locks: These are built into every object in Java. We don't see them, but they are there. When we use a synchronized keyword, we're using these automatic locks.
Explicit Locks: These are more advanced locks which we can control ourselves, using the Lock class : java.util.concurrent.locks
. We explicitly say when to lock and unlock, giving more control.
Why do we need locks if synchronized keyword already exists???
Consider you have a bank account in SBI. Two threads t1 and t2 are trying to share the same function, for withdrawing money from the balance available. If we use synchronized keyword, then whichever thread( assume t1) is executing will have access to all the resources. Until and unless t1 completes t2 will not be allowed to access the withdraw function. Due to some glitch, if t1 is not able to complete its task, then t2 will have to wait indefinitely. Therefore, concept of locks is introduced, which will allow us to explicitly (manually) place locks.
Code Example:
package com.locks;
public class BankAccount {
private int balance = 100;
public synchronized void withdraw(int amount) throws InterruptedException {
System.out.println(Thread.currentThread().getName()+ " attempting to withdraw "+ amount);
if(balance >= amount) {
System.out.println(Thread.currentThread().getName()+ " proceeding with withdrawal ");
Thread.sleep(3000);
balance -= amount;
System.out.println(Thread.currentThread().getName()+ " completed withdrawal. Remaining balance: "+ balance);
}
else {
System.out.println(Thread.currentThread().getName() + " insufficient balance");
}
}
}
package com.locks;
public class Main {
public static void main(String[] args) {
BankAccount sbi= new BankAccount();
Runnable task = new Runnable() {
@Override
public void run() {
try {
sbi.withdraw(50);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
};
Thread t1 = new Thread(task, "Thread 1");
Thread t2 = new Thread(task, "Thread 2");
t1.start();
t2.start();
}}
Output:
Thread 1 attempting to withdraw 50
Thread 1 proceeding with withdrawal
Thread 1 completed withdrawal. Remaining balance: 50
Thread 2 attempting to withdraw 50
Thread 2 proceeding with withdrawal
ReentrantLock in Java
ReentrantLock
is a part of the java.util.concurrent.locks
package and provides more extensive locking operations compared to synchronized
blocks/methods. It allows the same thread to acquire the lock multiple times without causing a deadlock (hence the name "reentrant").
ReentrantLock ensures that the critical section (withdraw
method) is accessed by only one thread at a time.
Methods of ReentrantLock:
lock()
Acquires the lock, blocking the current thread until the lock is available.
If the lock is already held by another thread, the current thread will wait until it can acquire the lock.
tryLock()
Tries to acquire the lock without waiting. Returns
true
if the lock was acquired,false
otherwise.This is non-blocking, meaning the thread will not wait if the lock is not available.
tryLock(long timeout, TimeUnit unit)
Attempts to acquire the lock, but with a timeout. If the lock is not available, the thread waits for the specified time before giving up.
Returns
true
if the lock was acquired within the timeout,false
otherwise.
unlock()
Releases the lock held by the current thread.
Must be called in a
finally
block to ensure that the lock is always released even if an exception occurs.
lockInterruptibly()
- Acquires the lock unless the current thread is interrupted. This is useful when you want to handle interruptions while acquiring a lock.
Code example using trylock() and ReentrantLock():
package com.locks;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private int balance = 100;
private final Lock lock = new ReentrantLock();
public void withdraw(int amount) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " attempting to withdraw " + amount);
try {
if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) { // Attempt to acquire the lock within 1 second
if (balance >= amount) {
try {
System.out.println(Thread.currentThread().getName() + " proceeding with withdrawal ");
Thread.sleep(3000); // Simulate time taken to process the withdrawal
balance -= amount;
System.out.println(Thread.currentThread().getName()
+ " completed withdrawal. Remaining balance: " + balance);
} catch (Exception e) {
Thread.currentThread().interrupt();// Restore the interrupted status
} finally {
lock.unlock();// Release the lock
}
} else {
System.out.println(Thread.currentThread().getName() + " insufficient balance");
}
}
else {
System.out.println(Thread.currentThread().getName()+" could not acquire the lock, will try later");
}
} catch (Exception e) {
Thread.currentThread().interrupt();// Restore the interrupted status
}
}
}
Thread 1 attempting to withdraw 50
Thread 2 attempting to withdraw 50
Thread 2 proceeding with withdrawal
Thread 1 could not acquire the lock, will try later
Thread 2 completed withdrawal. Remaining balance: 50
Why is tryLock
used instead of lock
?
tryLock()
is used when you want to attempt to acquire the lock without waiting indefinitely. It allows the thread to proceed with other work if the lock isn't available within the specified time (in this case, 1000 milliseconds). This approach is useful to avoid deadlock scenarios and when you don't want a thread to block forever waiting for a lock.lock()
would block the thread until the lock becomes available, potentially leading to situations where a thread waits indefinitely.
Why is there an inner and outer try-catch
block?
Outer
try-catch
: Handles exceptions that may occur when attempting to acquire the lock. It catches and handles any interruptions while the thread is waiting to acquire the lock usingtryLock()
. If an interruption occurs, the thread's interrupted status is restored by callingThread.currentThread().interrupt()
.Inner
try-catch
: Handles exceptions that might occur during the critical section (e.g., during withdrawal processing). It's important to catch exceptions here so that the lock is released properly in thefinally
block. This ensures that the program doesn't end up in a state where the lock is not released due to an unexpected exception.
Why re-interrupt the method or rethrow the interrupted exception?
- Re-interrupting or rethrowing the interrupted exception is crucial because it preserves the thread's interrupted status. When a thread is interrupted, it sets an internal flag indicating that it has been interrupted. By calling
Thread.currentThread().interrupt()
, you're ensuring that the flag is set again.
If an interrupted thread is not handled properly, is the information about the interruption lost?
- Yes, if the interrupted status is not restored or the exception is not propagated, the information about the interruption is lost. This can lead to threads continuing execution without recognizing that they were supposed to stop or handle the interruption, potentially leading to incorrect program behavior or failure to clean up resources properly.
What happens if the information about the interruption is lost?
If the interruption information is lost, the thread won't know that it was interrupted and will continue its normal execution. This could lead to various problems:
Resource leaks: If the thread was supposed to clean up resources upon interruption (like closing files or releasing locks), failing to recognize the interruption could leave those resources allocated.
Deadlocks: If the thread was holding a lock or resource and was supposed to release it upon interruption, failing to do so might lead to deadlocks.
Incorrect behavior: The application logic might depend on the thread responding to interruptions (e.g., shutting down gracefully). Ignoring the interruption could cause the application to run in an unexpected state
Understanding the ReentrantLock():
package com.locks;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantExample {
private final Lock lock = new ReentrantLock();
public void outerMethod() {
lock.lock(); //Acquiring the lock - count 1
try {
System.out.println("Outer Method");
innerMethod();
} finally {
lock.unlock(); //Releasing the lock - count = (1-1) = 0
}
}
public void innerMethod() {
lock.lock(); //Acquiring the lock - count 2
try {
System.out.println("Inner Method");
}
finally {
lock.unlock(); //Releasing the lock - count = (1+1-1) = 1
}
}
public static void main(String[] args) {
ReentrantExample example = new ReentrantExample();
example.outerMethod();
}
}
Let’s try to understand the above program. We have a lock variable and an instance of RentrantExample class is created known as example. We are trying to call the outerMethod() on this object. When call will transfer to outerMethod(), lock will be acquired, then code proceeds to try block. Outer Method is printed and innerMethod() is called. Now, in inner method lock is acquired again, but how??
Lock was already acquired in outerMethod, in this case, main thread is waiting to acquire itself. Now a deadlock situation will be created, because innerMethod() s waiting to acquire the lock after the lock in outerMethod() is released. While the outerMethod() is waiting for innerMethod() to execute so that it can release the lock.
But this does not happen in case of reentrantLock!!
Consider reentrantLock() like a master key of a house. When you have it, you can unlock any room, whether it be study room or living room.
In reentrantLock() a count is maintained which calculates, how many times lock is acquired. Each lock call is paired with an unlock call. So, when lock is acquired for the first time, count = 1. On calling the innerMethod() count becomes 2. After execution of finally block (count =1). And with the completion of outerMethod (count=0). This means all locks are unlocked.
Output:
Outer Method
Inner Method
How lockInterruptibly()
Works:
Blocking Behavior: When a thread calls
lockInterruptibly()
, it will block and wait to acquire the lock just likelock()
. However, if the thread is interrupted while waiting, it will throw anInterruptedException
and exit the waiting state.Use Case: This is particularly useful in scenarios where you want a thread to remain responsive to interruptions even while waiting to acquire a lock. For example, if your thread should abort its operation when interrupted, you would use
lockInterruptibly()
instead oflock()
Why Use lockInterruptibly()
?
Responsiveness to Interruptions: It's especially useful in applications where threads need to remain responsive to interruption signals, such as in user-facing applications where you might want to cancel a long-running operation.
Avoiding Deadlock: If you use
lock()
and a thread is indefinitely blocked due to waiting for a lock, it may lead to deadlock scenarios. WithlockInterruptibly()
, you can avoid these situations by interrupting threads if necessary.
Fairness Of Locks
The fairness parameter in ReentrantLock
controls the order in which threads acquire the lock.
Fair vs. Non-Fair Lock
Fair Lock (Fairness = true):
Threads are granted access to the lock in the order they requested it (first-come, first-served).
The longest-waiting thread gets the lock next.
This avoids starvation (where a thread never gets the lock because others keep getting it first).
Non-Fair Lock (Fairness = false):
The default behavior of
ReentrantLock
.There is no particular order for which thread gets the lock; it depends on the JVM’s thread scheduling.
This allows for potentially higher throughput but can lead to thread starvation.
ReentrantLock lock = new ReentrantLock(true); // Fair lock
With a fair lock, if multiple threads are waiting for the lock, the longest-waiting thread will be given priority.
In contrast, with a non-fair lock (default), a thread that has just requested the lock might acquire it immediately, even if other threads have been waiting longer.
Why Does Non-Fair Locking Allow This?
Non-fair locking can improve performance by reducing context switches, which is why it is the default.
When to Use Fair Locks?
Use a fair lock if you want to ensure that threads are served in the order they request the lock, especially in scenarios where fairness and predictability are more critical than performance.
However, fair locks can be slightly slower due to the overhead of maintaining the queue of waiting threads.
Code Example:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class FairnessLockExample {
private final Lock lock = new ReentrantLock(true);
public void accessResource() {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + " acquired the lock");
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + " released the lock");
}
}
public static void main(String[] args) {
FairnessLockExample example = new FairnessLockExample();
Runnable taskRunnable = new Runnable() {
@Override
public void run() {
example.accessResource();
}
};
Thread thread1 = new Thread(taskRunnable, "Thread1");
Thread thread2 = new Thread(taskRunnable, "Thread2");
Thread thread3 = new Thread(taskRunnable, "Thread3");
thread1.start();
thread2.start();
thread3.start();
}
}
Disadvantages of Synchronized:
Fairness:
synchronized
doesn't ensure that waiting threads acquire the lock in order, so some threads may wait longer than others.Blocking: Threads are blocked indefinitely when waiting for a
synchronized
lock, impacting performance.Interruptibility:
synchronized
locks cannot be interrupted, reducing thread responsiveness.Read/Write Locking:
synchronized
lacks read/write distinction, limiting concurrency and efficiency.
Read/Write Lock
Using a ReadWriteLock
instead of a synchronized
block can offer better performance:
With
synchronized
, once a thread acquires a lock, other threads are blocked from accessing the synchronized block/method until the lock is released, whether they want to read or write.A
ReadWriteLock
allows multiple threads to acquire the read lock simultaneously, provided no thread holds the write lock.
Code Example:
package com.locks;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteCounter {
private int count = 0;
private final ReadWriteLock lock = new ReentrantReadWriteLock(); //Class used to implement read/write locks
private final Lock readLock = lock.readLock(); //lock to read
private final Lock writeLock = lock.writeLock(); //lock to write
public void increment() {
writeLock.lock();
try {
count++;
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
finally {
writeLock.unlock();
}
}
public int getCount() {
readLock.lock(); //multiple threads can acquire this readlock
try {
return count;
}
finally {
readLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReadWriteCounter counter = new ReadWriteCounter();
Runnable readTask = new Runnable() { //Does the task of readingOu
@Override
public void run() {
for(int i=0;i<10;i++) {
System.out.println(Thread.currentThread().getName() + " read: " + counter.getCount());
}
}
};
Runnable writeTask = new Runnable() { //Does the task of writing
@Override
public void run() {
for(int i=0;i<10;i++) {
counter.increment();
System.out.println(Thread.currentThread().getName() + " incremented");
}
}
};
Thread writerThread = new Thread(writeTask);
Thread readerThread = new Thread(readTask);
Thread readerThread2 = new Thread(readTask);
writerThread.start();
readerThread.start();
readerThread2.start();
writerThread.join();
readerThread.join();
readerThread2.join();
System.out.println("Final count: "+ counter.getCount());
}
}
Output:
It is visible in the output that when writer thread is working, reader threads are not able to read the threads. The Thread.sleep(50) code in the write block, gives the chance to reader thread to read the counter value.
Thread-0 incremented
Thread-2 read: 1
Thread-1 read: 1
Thread-0 incremented
Thread-2 read: 2
Thread-1 read: 2
Thread-0 incremented
Thread-1 read: 3
Thread-2 read: 3
Thread-0 incremented
Thread-1 read: 4
Thread-2 read: 4
Thread-0 incremented
Thread-1 read: 5
Thread-2 read: 5
Thread-0 incremented
Thread-1 read: 6
Thread-2 read: 6
Thread-0 incremented
Thread-2 read: 7
Thread-1 read: 7
Thread-0 incremented
Thread-1 read: 8
Thread-2 read: 8
Thread-0 incremented
Thread-2 read: 9
Thread-1 read: 9
Thread-0 incremented
Thread-2 read: 10
Thread-1 read: 10
Final count: 10
Deadlock
Deadlock is a situation in multithreading where two or more thread are blocked forever, waiting for each other to release a resource. This typically occurs when two or more threads have circular dependencies on a set of locks.
The four necessary conditions for a deadlock to occur are:
Mutual Exclusion: Only one process can access a resource at a time.
Hold and Wait: A process holding a resource is waiting to acquire additional resources held by other processes.
No Preemption: Resources cannot be forcibly taken from a process; they must be released voluntarily.
Circular Wait: A set of processes are waiting on each other in a circular chain, where each process holds a resource needed by the next process in the chain.
Let's understand this with a code example:
Here, we have two classes Pen and Paper, Thread-1 locks the Pen
object and tries to access the Paper
object, while Thread-2 locks the Paper
object and tries to access the Pen
object. Since each thread holds one lock and waits for the other, neither can proceed, resulting in a deadlock where both threads are stuck indefinitely.
package com.locks;
class Pen {
public synchronized void writeWithPenAndpaper(Paper paper) {
System.out.println(Thread.currentThread().getName() + " is using pen " + this + " and trying to access paper");
paper.finishWriting();
}
public synchronized void finishWriting() {
System.out.println(Thread.currentThread().getName() + " finished using pen " + this);
}
}
class Paper {
public synchronized void writeWithPaperAndpen(Pen pen) {
System.out.println(Thread.currentThread().getName() + " is using paper " + this + " and trying to access pen");
pen.finishWriting();
}
public synchronized void finishWriting() {
System.out.println(Thread.currentThread().getName() + " finished using paper " + this);
}
}
class Task1 implements Runnable {
private Pen pen;
private Paper paper;
public Task1(Pen pen, Paper paper) {
this.paper = paper;
this.pen = pen;
}
@Override
public void run() {
pen.writeWithPenAndpaper(paper);
}
}
class Task2 implements Runnable {
private Pen pen;
private Paper paper;
public Task2(Pen pen, Paper paper) {
this.paper = paper;
this.pen = pen;
}
@Override
public void run() {
paper.writeWithPaperAndpen(pen);
}
}
public class Deadlock {
public static void main(String[] args) {
Pen pen = new Pen();
Paper paper = new Paper();
Thread thread1 = new Thread(new Task1(pen, paper), "Thread-1");
Thread thread2 = new Thread(new Task2(pen, paper), "Thread-2");
thread1.start();
thread2.start();
}
}
Output:
Code needs to be stopped manually.
How to resolve the deadlock issue?
We should try to make the threads acquire locks in a consistent manner. This means that locks should be acquired in sequence. We use synchronized
block (in case of Task1) to first access the lock of Paper and then request for Pen.
class Task1 implements Runnable {
private Pen pen;
private Paper paper;
public Task1(Pen pen, Paper paper) {
this.paper = paper;
this.pen = pen;
}
@Override
public void run() {
synchronized (paper) {
pen.writeWithPenAndpaper(paper);
}
}
}
class Task2 implements Runnable {
private Pen pen;
private Paper paper;
public Task2(Pen pen, Paper paper) {
this.paper = paper;
this.pen = pen;
}
@Override
public void run() {
synchronized(pen) {
paper.writeWithPaperAndpen(pen);
}
}
}
Output: Deadlock resolved!!
Thread Communication
In a multithreaded environment, threads often need to communicate and coordinate with each other to accomplish a task.
Without proper communication mechanisms, threads might end up in inefficient busy-waiting states, leading to wastage of CPU resources and potential deadlocks.
Producer-Consumer Problem
Producer: Produces items and puts them into a shared resource (e.g., a queue or buffer).
Consumer: Consumes items from the shared resource.
The key challenge is to ensure that the producer does not add items to a full buffer, and the consumer does not try to consume from an empty buffer. This requires proper synchronization and communication between threads.
Thread Communication Methods
wait()
: Used by a thread to release the lock and wait for a condition (e.g., buffer availability). The thread remains in the waiting state until another thread callsnotify()
ornotifyAll()
on the same object.notify()
: Wakes up a single waiting thread when the condition (e.g., buffer space) is met.notifyAll()
: Wakes up all waiting threads, allowing them to recheck the condition and proceed if possible. Useful when multiple threads are waiting, and you want to ensure that all have the opportunity to check if they can proceed.
The Producer
thread produces data and stores it in the SharedResource
, waiting if the resource already has data. The Consumer
thread consumes the data from the SharedResource
, waiting if there's no data available. wait()
and notify()
are used to manage synchronization, ensuring that the producer and consumer coordinate access to the shared resource without conflicts.
Code Example:
package com.threadcomm;
class SharedResource{
private int data;
private boolean hasData;
public synchronized void produce(int value) {
while(hasData) {
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
data = value;
hasData = true;
System.out.println("Produced: "+ value);
notify();
}
public synchronized int consume() {
while(!hasData) {
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
hasData = false;
System.out.println("Consumed: "+ data);
notify();
return data;
}
}
class Producer implements Runnable {
private SharedResource resource;
public Producer(SharedResource resource) {
this.resource = resource;
}
@Override
public void run() {
for(int i=0;i<10;i++) {
resource.produce(i);
}
}
}
class Consumer implements Runnable{
private SharedResource resource;
public Consumer(SharedResource resource) {
this.resource = resource;
}
@Override
public void run() {
for(int i=0;i<10;i++) {
int value = resource.consume();
}
}
}
public class ThreadCommunication {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread producerThread = new Thread(new Producer(resource));
Thread consumerThread = new Thread(new Consumer(resource));
producerThread.start();
consumerThread.start();
}
}
Thread Safety
Thread safety means that a piece of code or a data structure can be safely accessed and modified by multiple threads concurrently without leading to data corruption, unexpected behavior, or inconsistent results.
It's important because, in a multi-threaded environment, multiple threads might try to read and write shared resources simultaneously, leading to race conditions, deadlocks, or other concurrency issues. Ensuring thread safety prevents these problems and allows for reliable and predictable program behavior. That's why we are studying synchronization and locking concepts.
Thread using Lambda Expression
Runnable as a Functional Interface
In Java, the Runnable
interface is a functional interface, which means it has exactly one abstract method that can be implemented using a lambda expression. The primary method(abstract) of the Runnable
interface is:
@FunctionalInterface
public interface Runnable {
void run();
}
Lambda Expression as an Anonymous Function
A lambda expression in Java provides a concise way to represent an anonymous function. Lambda expressions are particularly useful when implementing single-method interfaces, such as Runnable
.
Details on Lambda Expressions
Syntax: The basic syntax of a lambda expression is:
(parameters) -> expression
or for multiple statements:
(parameters) -> {
statements
}
Thread Pooling
Example: Ice Cream Stall Scenario
A person started an ice cream stall and initially relied on family for help. As demand grew, he included extended family and friends, but their availability was inconsistent. To ensure reliable service, he hired a fixed team of 7 helpers to work daily. This approach is similar to thread pooling, where a fixed number of threads are managed for consistent performance.
Why use Thread Pooling?
Thread pooling is used to manage a set of worker threads that handle tasks in a concurrent application.
Resource Management: Thread pooling reuses a fixed number of threads, reducing the cost of repeatedly creating and destroying threads.
Response Time: It speeds up task execution by using pre-existing threads, avoiding delays from creating new ones.
Control Over Thread Count: It limits the number of concurrent threads, preventing performance issues and resource exhaustion from excessive threads.
Executors Framework (Introduced in Java 5)
To simplify the development of concurrent applications by abstracting away many of the complexities involved in creating and managing threads.
Problems Prior to the Executors Framework:
Manual Thread Management:
- Issue: Developers had to manually create and manage threads, leading to complex and error-prone code.
Resource Management:
- Issue: Managing resources efficiently was challenging, with overhead from frequently creating and destroying threads.
Scalability:
- Issue: Scaling applications to handle increasing workloads was difficult due to the complexities of managing a large number of threads.
Thread Reuse:
- Issue: Reusing threads was not straightforward, resulting in inefficiencies and increased overhead from thread creation.
Error Handling:
- Issue: Handling errors and exceptions in a multi-threaded environment was complicated and required additional code to manage thread failures and recovery.
Interfaces in the Executors Framework
Executor:
Description: The simplest interface for task execution. It provides the basic
execute(Runnable command)
method to submit tasks for execution.Use Case: Suitable for scenarios where you just need to execute tasks without needing a result or tracking the task's completion.
ExecutorService:
Description: Extends
Executor
and provides additional methods for managing the lifecycle of tasks and retrieving results. It includes methods likesubmit()
to submit tasks and return aFuture<?>
for tracking progress and obtaining results.Use Case: Ideal for more complex task management, where you need to track task completion, get results, or shut down the executor.
ScheduledExecutorService:
Description: Extends
ExecutorService
and provides methods for scheduling tasks to execute at fixed intervals or after a delay. It includes methods likeschedule()
,scheduleAtFixedRate()
, andscheduleWithFixedDelay()
.Use Case: Useful for scheduling periodic tasks or delayed executions.
Code Example: Considering we use threads to execute a task such as calculation of factorial. The example does not use a thread pool or reuse threads, which can be inefficient for a larger number of tasks:
package com.executorsFramework;
//Threads are not reused
public class Main {
public static void main(String[] args) {
long startTime = System.currentTimeMillis(); //Milliseconds from 1 JAN 1970
Thread[] threads = new Thread[9];
for(int i=1;i<10;i++) {
int finalI = i;
threads[i-1] = new Thread(
() -> {
long result = factorial(finalI); //Value should be effectively final, this means that i should not change.
System.out.println(result);
}
);
threads[i-1].start();
}
for(Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Total time: "+ (System.currentTimeMillis() - startTime));
}
private static long factorial(int n) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
long result = 1;
for(int i=1;i<=n;i++) {
result *=i;
}
return result;
}
}
Each factorial value is printed by a different thread, and the total time will be around 1 second.
2
24
120
362880
1
720
40320
5040
6
Total time: 1019
But here, we are ourselves calculating the factorial and also creating threads. What Executors Framework does is reduce our work of creating threads and increases the efficiency by reusing threads.
//For thread pool
ExecutorService executorService = Executors.newFixedThreadPool(9);
//For single thread
ExecutorService executorService = Executors.newSingleThreadExecutor();
submit(Runnable task)
:Submits a
Runnable
task for execution and returns aFuture<?>
. Used for running tasks that do not return a result.submit(Callable<T> task)
:Submits a
Callable
task for execution and returns aFuture<T>
.Used for running tasks that return a result.shutdown()
:Initiates an orderly shutdown, preventing new tasks from being submitted.
shutdownNow()
:Attempts to stop all actively executing tasks and halts the processing of waiting tasks.
isShutdown()
:Checks if the executor service has been shut down.
isTerminated()
:Checks if all tasks have completed after a shutdown.
awaitTermination(long timeout, TimeUnit unit)
:Blocks until all tasks have completed after a shutdown, or the timeout occurs. Returns
true
if the executor service terminated successfully within the specified timeout period; otherwise,false
Code Example:
package com.executorsFramework;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Test {
public static void main(String[] args) {
long startTime = System.currentTimeMillis(); //Milliseconds from 1 JAN 1970
ExecutorService executorService = Executors.newFixedThreadPool(9);
for(int i=1;i<10;i++) {
int finalI = i;
executorService.submit(
() -> {
long result = factorial(finalI); //Value should be effectively final, this means that i should not change.
System.out.println(result);
}
);
}
executorService.shutdown(); //Since threads are being reused, so we need to shutdown.
try {
while(!executorService.awaitTermination(100, TimeUnit.MILLISECONDS)) { //Waits until task is not completed
System.out.println("Waiting...");
}} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Total time: "+ (System.currentTimeMillis() - startTime));
}
private static long factorial(int n) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
long result = 1;
for(int i=1;i<=n;i++) {
result *=i;
}
return result;
}
}
Output:
24
2
6
120
40320
362880
1
5040
720
Total time: 1023
Future<?>
Description: The
Future<?>
interface represents the result of an asynchronous computation. It provides methods to check if the task is complete, wait for its completion, and retrieve the result or handle exceptions.Key Methods:
boolean cancel(boolean mayInterruptIfRunning)
: Attempts to cancel the task.boolean isCancelled()
: Checks if the task was canceled.boolean isDone()
: Checks if the task is complete.V get()
: Retrieves the result of the computation. This method blocks until the result is available.V get(long timeout, TimeUnit unit)
: Retrieves the result, waiting up to the specified time if necessary.
Code Example (without returning any result):
package com.executorsFramework;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FutureExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<?> future = executorService.submit(()->System.out.println("Hello"));
System.out.println(future.get()); //waits for the computation to complete
if(future.isDone()) {
System.out.println("Task is done !");
}
executorService.shutdown();
}
}
Code Example (returning a result using Future interface):
package com.executorsFramework;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FutureClassCallable {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Callable<String> runnable = () -> "Hello";
Future<String> future = executorService.submit(runnable);
System.out.println(future.get());
if(future.isDone()) {
System.out.println("Task is done !");
}
executorService.shutdown();
}
}
Runnable vs Callable
Runnable:
Description: Represents a task that does not return a result and cannot throw checked exceptions. It has a single method
run()
that contains the task's code.Use Case: Suitable for tasks that perform actions but do not need to return a result or handle checked exceptions.
Example:
Runnable task = () -> System.out.println("Task executed");
Callable:
Description: Similar to
Runnable
, but it can return a result and throw checked exceptions. It has a single methodcall()
that returns a value.Use Case: Ideal for tasks that need to return a result or handle checked exceptions.
Example:
Callable<Integer> task = () -> { return 123; };
Code Example:
package com.executorsFramework;
public class RunnableTask implements Runnable {
@Override
public void run() { //We need try-catch block since run() does not throw any exception.
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
package com.executorsFramework;
import java.util.concurrent.Callable;
public class CallableTask implements Callable {
@Override
public Object call() throws Exception { //Callable automatically throws exception as it returns something.
Thread.sleep(100);
return null;
}
}
CountDownLatch
The CountDownLatch
is useful when you need one or more threads to wait until a set of operations being performed by other threads is complete. In the example, we are waiting for three dependent services to finish before starting the main service.
Code Example:
package com.countdownlatch;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
class DependentService implements Callable<String>{
@Override
public String call() throws Exception {
System.out.println(Thread.currentThread().getName()+ " service started.");
Thread.sleep(1000);
return "ok";
}
}
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newFixedThreadPool(3);
Future<String> future1 = executorService.submit(new DependentService());
Future<String> future2 = executorService.submit(new DependentService());
Future<String> future3 = executorService.submit(new DependentService());
future1.get();
future2.get();
future3.get();
System.out.println("All dependent services finished. Starting main service...");
executorService.shutdown();
}
}
Why CountDownLatch
?
Synchronization: It provides a simple way to wait for multiple tasks to complete before proceeding.
Flexibility: Unlike using
Future.get()
to block and wait for each task,CountDownLatch
allows you to wait for all tasks in a non-blocking way.Thread Coordination: It helps in coordinating threads in scenarios where multiple threads need to finish their work before another thread can start.
When to Use CountDownLatch
:
Waiting for Multiple Threads to Complete:
Use
CountDownLatch
when you need one or more threads to wait for a group of threads to complete their tasks before proceeding.Example: A main thread waiting for multiple worker threads to finish processing before continuing with a task.
One-Time Synchronization:
CountDownLatch
is ideal for scenarios where the latch will be used only once and won't need to be reset.Example: Starting a main service only after all dependent services have initialized.
Phased Execution:
Use
CountDownLatch
when you need to start a task only after completing a specific set of other tasks.Example: Coordinating the launch of multiple systems where each system must wait until certain subsystems are up and running.
When Not to Use CountDownLatch
:
Reusable Synchronization:
If you need a synchronization mechanism that can be reused multiple times,
CountDownLatch
is not appropriate because it cannot be reset. UseCyclicBarrier
instead.Example: Repeatedly performing a task in phases, where each phase needs synchronization.
Complex Thread Coordination:
When your application requires more complex thread coordination that involves resetting the latch or waiting for a variable number of threads over time,
CountDownLatch
might not be the best choice. Consider usingCyclicBarrier
orPhaser
.Example: Coordinating multiple threads in multiple stages with variable participants.
Single-Thread Dependencies:
If you just need to wait for a single thread or task to complete, using
CountDownLatch
is overkill. A simplejoin()
orFuture.get()
can be used instead.Example: Waiting for one background task to complete before proceeding.
Task Termination:
CountDownLatch
is not meant for signaling the completion of a task to other threads after it’s done. For that, consider usingExchanger
,Semaphore
, or other more appropriate mechanisms.Example: When a producer-consumer setup requires signaling that no more items will be produced.
Code Example:
package com.countdownlatch;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
class DependentService implements Callable<String>{
private final CountDownLatch latch;
public DependentService(CountDownLatch latch) {
this.latch = latch;
}
@Override
public String call() throws Exception {
System.out.println(Thread.currentThread().getName()+ " service started.");
Thread.sleep(2000);
return "ok";
}
}
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
int numberOfServices = 3;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfServices);
CountDownLatch latch = new CountDownLatch(numberOfServices);
executorService.submit(new DependentService(latch));
executorService.submit(new DependentService(latch));
executorService.submit(new DependentService(latch));
latch.await(5, TimeUnit.SECONDS);
System.out.println("Main");
executorService.shutdown();
}
}
Cyclic Barrier
What is CyclicBarrier?
CyclicBarrier
is a synchronization aid in Java that allows a set of threads to all wait for each other to reach a common barrier point. This means that all threads must reach a certain point in their execution before any of them can proceed. It’s called "cyclic" because the barrier can be reused after the waiting threads are released.
Why and When to Use CyclicBarrier:
Coordination Among Threads: Use
CyclicBarrier
when you need multiple threads to wait for each other to reach a common execution point before any of them can proceed. For example, in simulations, games, or tasks that require parallel stages of execution.Reusability: Unlike
CountDownLatch
, which is a one-time-use barrier,CyclicBarrier
can be reset and reused multiple times, making it ideal for scenarios where the threads repeatedly need to synchronize at multiple points during their execution.Barrier Action:
CyclicBarrier
allows you to specify a barrier action that will be executed by one of the threads when they all reach the barrier, adding flexibility for tasks like merging results or performing collective operations.
When Not to Use CyclicBarrier:
Single-Use Synchronization: If you only need a one-time barrier,
CountDownLatch
is simpler and more appropriate.Heavyweight Synchronization: In cases where threads are infrequently reaching the synchronization point, the overhead of
CyclicBarrier
might not justify its use. Simpler synchronization mechanisms likeSemaphore
orCountDownLatch
might be better suited.Complex Scenarios: If your application logic involves complex dependencies or varying numbers of threads, managing those with
CyclicBarrier
can become complicated.
CompletableFuture
What is CompletableFuture?
CompletableFuture
is part of Java's java.util.concurrent
package and provides a framework for writing asynchronous, non-blocking code. It allows you to create a future that you can complete manually and provides a rich API for composing tasks and handling results in a non-blocking manner.
Why and When to Use CompletableFuture:
Asynchronous Programming:
CompletableFuture
is ideal for performing asynchronous tasks where you don't want to block the main thread waiting for the result. It’s useful for IO-bound tasks, such as making HTTP requests, database queries, or file operations.Chaining and Composition: With
CompletableFuture
, you can easily chain multiple tasks together using methods likethenApply
,thenAccept
,thenCompose
, and handle exceptions withhandle
orexceptionally
. This makes it easier to build complex asynchronous workflows.Non-Blocking: Since
CompletableFuture
operates asynchronously, it allows your application to perform other tasks while waiting for the completion of a future, improving performance and responsiveness.
When Not to Use CompletableFuture:
Simple Synchronous Tasks: If your task does not benefit from being run asynchronously or if it’s a quick, simple operation, using
CompletableFuture
adds unnecessary complexity.Thread-Intensive Operations: If your application has many CPU-bound tasks that don’t involve waiting (e.g., heavy computations), the overhead of managing threads and futures might outweigh the benefits of using
CompletableFuture
. In such cases, parallel streams or traditional thread pools may be more appropriate.Complex Error Handling: While
CompletableFuture
provides methods for handling exceptions, managing complex error handling across multiple asynchronous tasks can become tricky. If error handling is a priority and your tasks are interdependent, you might need more robust solutions or frameworks.
All the learnings in this post are derived from the following tutorial and ChatGPT. I have learnt many of the difficult concepts from this channel. I was struggling with streams topic, but the instructor's blunt, to the point approach and hands on exercises have simplified the topic for me. This is my submission as part of the challenge posted by @EngineeringDigest. I am grateful that I got a chance to learn some advanced concepts and would love to continue deepening my understanding through similar resources in the future.
For the code examples refer this link: Code Repo
If you need notes of Basics of Multithreading, download here: Notes
Subscribe to my newsletter
Read articles from Sandhi Jain directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Sandhi Jain
Sandhi Jain
Aspiring Software Engineer, currently gaining hands-on experience as a trainee at Persistent Systems. I'm committed to building a solid foundation in Java development. I believe in learning by doing, and I'm constantly seeking new challenges to enhance my skills and grow as a developer.