Java Multithreading | Part 3

Published on July 18, 2024 9 min read

Tags:
Java

Concurrency in Java can be tricky, especially when dealing with monitor locks and synchronized blocks. One common issue arises when different instances of a class need to access a shared resource exclusively, but each instance has its own intrinsic lock. This post discusses the problem and provides a solution using different locks - Reentrant, ReadWrite, Semaphore, Stamp.

The Problem with Instance-Level Locks

Consider the following example where two threads are working with two different instances of the same class:

package multithreading;
 
public class MonitorLockIssue {
    public static void main(String[] args) {
        SharedResource3 resource3 = new SharedResource3();
        SharedResource3 resource4 = new SharedResource3();
 
        Thread th1 = new Thread(() -> {
            resource3.performTask();
        });
        Thread th2 = new Thread(() -> {
            resource4.performTask();
        });
 
        th1.start();
        th2.start();
    }
}
 
class SharedResource3 {
    public synchronized void performTask() {
        System.out.println("Lock acquired by: " + Thread.currentThread().getName());
        try {
            Thread.sleep(2000); // Simulate critical section
            // Critical section tasks go here
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            System.out.println("Lock released by: " + Thread.currentThread().getName());
        }
    }
}
Lock acquired by : Thread-0
Lock acquired by : Thread-1
...After 2 sec...
Lock released by : Thread-0
Lock released by : Thread-1

In this code:

  • Two instances of SharedResource3 are created.
  • Two threads (th1 and th2) call the performTask method on these different instances so they both acquired the lock at the same time.

Since each instance has its own lock, both threads can enter the performTask method simultaneously, leading to potential data inconsistencies.


Let's Explore a Few Other Alternatives to Handle This Issue

Concurrency Mechanism

1. Reentrant Lock

The ReentrantLock class in Java provides a more flexible and powerful mechanism for managing concurrent access to shared resources compared to the built-in synchronized methods and blocks.

Here's a basic example of how to use ReentrantLock:

package multithreading;
 
import java.util.concurrent.locks.ReentrantLock;
 
public class ReentrantLockExample {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        SharedResource resource1 = new SharedResource(lock);
        SharedResource resource2 = new SharedResource(lock);
 
        Thread th1 = new Thread(() -> {
            resource1.performTask();
        });
        Thread th2 = new Thread(() -> {
            resource2.performTask();
        });
 
        th1.start();
        th2.start();
    }
}
 
class SharedResource {
    private final ReentrantLock lock;
 
    public SharedResource(ReentrantLock lock) {
        this.lock = lock;
    }
 
    public void performTask() {
        lock.lock();
        try {
            System.out.println("Lock acquired by: " + Thread.currentThread().getName());
            Thread.sleep(2000); // Simulate critical section
            // Critical section tasks go here
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            System.out.println("Lock released by: " + Thread.currentThread().getName());
            lock.unlock();
        }
    }
}
Lock acquired by: Thread-0
Lock released by: Thread-0
Lock acquired by: Thread-1
Lock released by: Thread-1

*Note here Thread-1 can only execute the method after lock released by Thread-0.

In this example:

  • A single ReentrantLock instance is shared among multiple instances of SharedResource.
  • The performTask method explicitly acquires and releases the lock, ensuring only one thread can access the critical section at a time.

Using ReentrantLock provides more control and flexibility, making it a powerful tool for managing concurrent access to shared resources in complex multithreading scenarios.

2. ReadWrite Lock

  • Read Lock: Allows multiple threads to acquire the read lock simultaneously. This is useful when multiple threads need to read data without modifying it.
  • Write Lock: Ensures that only one thread can acquire the write lock at a time. This is essential when a thread needs to modify the data, ensuring no other thread reads or writes concurrently.

Shared and Exclusive Locks

  • Shared Lock: A read lock can be considered a shared lock because multiple threads can hold it simultaneously.
  • Exclusive Lock: A write lock is an exclusive lock as only one thread can hold it at a time, preventing any other read or write operations.

Lock Request Type

Here's an example to demonstrate how ReadWriteLock can be used in Java:

package multithreading;
 
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
 
public class ReadWriteLockExample {
    public static void main(String[] args) {
 
        CommonResource cResource = new CommonResource();
        ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
 
        Thread th1 = new Thread(() -> {
            cResource.performRead(readWriteLock);
        });
 
        Thread th2 = new Thread(() -> {
            cResource.performRead(readWriteLock);
        });
 
        Thread th3 = new Thread(() -> {
            try {
                Thread.sleep(1000); // added this so that read lock is acquired first
                cResource.performWrite(readWriteLock);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
 
        th1.start();
        th2.start();
        th3.start();
    }
}
 
class CommonResource {
    int itemValue = 0;
 
    public int performRead(ReadWriteLock lock) {
        lock.readLock().lock();
        try {
            System.out.println("Read lock acquired by: " + Thread.currentThread().getName());
            Thread.sleep(4000); // Simulate reading operation
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            System.out.println("Read lock released by: " + Thread.currentThread().getName());
            lock.readLock().unlock();
        }
        return itemValue;
    }
 
    public void performWrite(ReadWriteLock lock) {
        lock.writeLock().lock();
        try {
            System.out.println("Write lock acquired by: " + Thread.currentThread().getName());
            itemValue += 1;
            Thread.sleep(4000); // Simulate writing operation
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            System.out.println("Write lock released by: " + Thread.currentThread().getName());
            lock.writeLock().unlock();
        }
    }
}

Explanation

In the above example:

  • Two threads (th1 and th2) are created to perform read operations on a shared resource.
  • A third thread (th3) is created to perform a write operation on the same resource.
  • The write operation is delayed by 1 second to ensure the read locks are acquired first.

When you run this program, the output might look something like this:

Read lock acquired by: Thread-0
Read lock acquired by: Thread-1
Read lock released by: Thread-0
Read lock released by: Thread-1
Write lock acquired by: Thread-2
Write lock released by: Thread-2
  • Both th1 and th2 acquire the read lock simultaneously, demonstrating the shared nature of read locks.
  • After both read operations are completed and the locks are released, th3 acquires the write lock, ensuring exclusive access to modify the shared resource.

3. Stamped Lock

Understanding Optimistic Locking with StampedLock

Optimistic locking is a concurrency control mechanism that allows threads to operate on shared resources with the assumption that conflicts are infrequent. Java's StampedLock provides support for optimistic reads, offering improved performance in low-contention scenarios. Let's explore how optimistic locking works using StampedLock with an example.

Here's an example demonstrating optimistic locking with StampedLock in Java:

package multithreading;
 
import java.util.concurrent.locks.StampedLock;
 
public class StampedLockExample {
    public static void main(String[] args) {
 
        StampedLock lock = new StampedLock();
        SharedResource100 rs100 = new SharedResource100();
 
        Thread th1 = new Thread(() -> {
            rs100.performTask1(lock);
        });
        Thread th2 = new Thread(() -> {
            rs100.performTask2(lock);
        });
        th1.start();
        th2.start();
    }
}
 
class SharedResource100 {
    int item = 100;
 
    public void performTask1(StampedLock lock) {
        long stamp = lock.tryOptimisticRead();
 
        System.out.println("Optimistic Read lock acquired by : " + Thread.currentThread().getName());
        try {
            Thread.sleep(5000); // Simulate some processing time
            System.out.println("Now trying to read the value again in task1 after sleep");
            if (lock.validate(stamp)) {
                System.out.println("Optimistic Read: Value updated successfully in task1");
            } else {
                System.out.println("Optimistic Read: Rollback operation needed, value might have changed");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
 
    public void performTask2(StampedLock lock) {
        long stamp = lock.writeLock();
        System.out.println("Write lock acquired by : " + Thread.currentThread().getName());
        try {
            System.out.println("Updating the value in task2");
            item = 9; // Perform the update operation
 
        } finally {
            lock.unlockWrite(stamp);
        }
    }
}

Explanation

The code demonstrates how to use StampedLock in Java for implementing optimistic locking and write locking:

  • Optimistic Read (performTask1):

    • Acquires an optimistic read lock using tryOptimisticRead().
    • Simulates some processing time with Thread.sleep(5000).
    • Checks if the lock is still valid using validate(stamp).
    • Prints messages indicating whether the read operation was successful or if a rollback is required.
  • Write Lock (performTask2):

    • Acquires a write lock using writeLock().
    • Performs an update operation on the shared resource (item = 9).
    • Ensures the write lock is released using unlockWrite(stamp) in a finally block.

The output sequence illustrates concurrent operations using StampedLock:

  • Thread-0 acquires an optimistic read lock.
  • Meanwhile, Thread-1 acquires a write lock and updates the shared value.
  • After a sleep delay, Thread-0 attempts to re-read but finds that the value has changed.
  • This demonstrates the need for validation in optimistic locking when concurrent write operations can modify shared data.

4.Semaphore Lock

Semaphore in Java is a synchronization tool that controls access to shared resources or critical sections by limiting the number of threads that can access them concurrently.

Here's an example to demonstrate how Semaphore can be used in Java:

package multithreading;
 
import java.util.concurrent.Semaphore;
 
public class SemaphoreLockExample {
    public static void main(String[] args) {
        SharedResource101 resource101 = new SharedResource101();
        Semaphore lock = new Semaphore(3); // Allows 3 threads to acquire the semaphore
 
        Thread th1 = new Thread(() -> {
            resource101.performTask(lock);
        });
        Thread th2 = new Thread(() -> {
            resource101.performTask(lock);
        });
        Thread th3 = new Thread(() -> {
            resource101.performTask(lock);
        });
        Thread th4 = new Thread(() -> {
            resource101.performTask(lock);
        });
        Thread th5 = new Thread(() -> {
            resource101.performTask(lock);
        });
 
        th1.start();
        th2.start();
        th3.start();
        th4.start();
        th5.start();
    }
}
 
class SharedResource101 {
    public void performTask(Semaphore lock) {
        try {
            lock.acquire();
            System.out.println("Lock acquired by: " + Thread.currentThread().getName());
            Thread.sleep(2000); // Simulate work being done
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.release();
            System.out.println("Lock released by: " + Thread.currentThread().getName());
        }
    }
}

Output Explanation

  • Thread Execution: Initially, threads Thread-0, Thread-1, and Thread-2 acquire the semaphore simultaneously because the semaphore was initialized with 3 permits (Semaphore lock = new Semaphore(3);).

  • Lock Acquired: Threads print Lock acquired by: Thread-X when they acquire the semaphore.

  • Permit Exhaustion: After Thread-0, Thread-1, and Thread-2 release their locks, Thread-3 and Thread-4 acquire the available permits subsequently.

  • Lock Released: Threads print Lock released by: Thread-X after releasing the semaphore.


Understanding Inter-thread Communication in Java

In Java, traditional inter-thread communication using synchronized blocks and monitor locks involves methods like wait(), notify(), and notifyAll(). These methods allow threads to coordinate and communicate with each other when accessing shared resources.

However, with locks such as ReentrantLock or StampedLock, the approach differs slightly. Instead of wait(), notify(), and notifyAll(), these locks provide methods:

  • Await(): Used to pause a thread and release the associated lock until signaled.
  • Signal(): Signals a waiting thread that it can reacquire the lock.
  • SignalAll(): Signals all waiting threads that they can attempt to reacquire the lock.

These methods enable finer-grained control over thread synchronization and are typically used in scenarios where explicit locking mechanisms are preferred over synchronized blocks for more complex synchronization needs.

For more in-depth information, stay tuned for future posts on advanced thread management techniques.