Java Multithreading | Part 3
Published on July 18, 2024 • 9 min read

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
andth2
) call theperformTask
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
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 ofSharedResource
. - 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.
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
andth2
) 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
andth2
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.
- Acquires an optimistic read lock using
-
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.
- Acquires a write lock using
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
, andThread-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
, andThread-2
release their locks,Thread-3
andThread-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.