Multithreading in Java

 Sandhi Jain Sandhi Jain
39 min read

To create a new thread in Java, there are two ways:

  1. Extend the thread class

  2. 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 called join() 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 an InterruptedException 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, the run() method of the Thread (or Runnable 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 the run() method in the current thread.

Important Notes:

  • You should never call start() more than once on the same thread object. Doing so will throw an IllegalThreadStateException.

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 an InterruptedException.

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 using Thread.interrupted() or isInterrupted().

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:

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

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

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

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

  5. 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 using tryLock(). If an interruption occurs, the thread's interrupted status is restored by calling Thread.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 the finally 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 like lock(). However, if the thread is interrupted while waiting, it will throw an InterruptedException 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 of lock()

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. With lockInterruptibly(), 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:

  1. Mutual Exclusion: Only one process can access a resource at a time.

  2. Hold and Wait: A process holding a resource is waiting to acquire additional resources held by other processes.

  3. No Preemption: Resources cannot be forcibly taken from a process; they must be released voluntarily.

  4. 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 calls notify() or notifyAll() 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:

  1. Manual Thread Management:

    • Issue: Developers had to manually create and manage threads, leading to complex and error-prone code.
  2. Resource Management:

    • Issue: Managing resources efficiently was challenging, with overhead from frequently creating and destroying threads.
  3. Scalability:

    • Issue: Scaling applications to handle increasing workloads was difficult due to the complexities of managing a large number of threads.
  4. Thread Reuse:

    • Issue: Reusing threads was not straightforward, resulting in inefficiencies and increased overhead from thread creation.
  5. 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 like submit() to submit tasks and return a Future<?> 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 like schedule(), scheduleAtFixedRate(), and scheduleWithFixedDelay().

    • 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 a Future<?>. Used for running tasks that do not return a result.

  • submit(Callable<T> task):

    Submits a Callable task for execution and returns a Future<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 method call() 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:

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

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

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

  1. Reusable Synchronization:

    • If you need a synchronization mechanism that can be reused multiple times, CountDownLatch is not appropriate because it cannot be reset. Use CyclicBarrier instead.

    • Example: Repeatedly performing a task in phases, where each phase needs synchronization.

  2. 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 using CyclicBarrier or Phaser.

    • Example: Coordinating multiple threads in multiple stages with variable participants.

  3. Single-Thread Dependencies:

    • If you just need to wait for a single thread or task to complete, using CountDownLatch is overkill. A simple join() or Future.get() can be used instead.

    • Example: Waiting for one background task to complete before proceeding.

  4. Task Termination:

    • CountDownLatch is not meant for signaling the completion of a task to other threads after it’s done. For that, consider using Exchanger, 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 like Semaphore or CountDownLatch 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 like thenApply, thenAccept, thenCompose, and handle exceptions with handle or exceptionally. 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

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