Class 34: Explicit Locking with ReentrantLock
Part 6 — Locks and Concurrent Collections
Java provides additional tools to protect shared data and manage access to resources. Let's explore some useful concurrency utilities and understand when we might need them.
In the previous classes, we learned about synchronized methods and synchronized blocks.
They help us ensure that only one thread at a time can execute a protected section of code using the same lock.
But Java provides another way to manage locks. It is called ReentrantLock.
Let's understand it step by step.
1. A Quick Revision: Why Do We Need Locks?
Imagine that two threads are updating the same bank account.
The account has a balance of ₹1,000. Both threads try to withdraw money at the same time.
If both threads read and update the balance without proper coordination, the final result may be incorrect.
We have already seen how synchronized can protect shared data.
public synchronized void withdraw(int amount) {
balance = balance - amount;
}
The synchronized keyword automatically acquires the object's monitor lock before executing the method and releases it when the method exits.
ReentrantLock gives us another way to achieve mutual exclusion. The important difference is that we explicitly control when to acquire and release the lock.
2. What Is ReentrantLock?
ReentrantLock is a class provided by Java in the
java.util.concurrent.locks package.
It allows us to control access to a section of code by explicitly acquiring and releasing a lock.
Think of a room that can be used by only one person at a time.
A person must take the key before entering the room. Once finished, they must return the key so another person can enter.
ReentrantLock works in a similar way:
lock()acquires the lock.- The thread executes the protected code.
unlock()releases the lock.
Let's see how to create a ReentrantLock.
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lock = new ReentrantLock();
3. Using lock() and unlock()
Let's start with a simple example.
We will create a shared counter. Two threads will increment the counter. We will use ReentrantLock to protect the increment operation.
import java.util.concurrent.locks.ReentrantLock;
class SharedCounter {
private int count = 0;
private final ReentrantLock lock =
new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
public class ReentrantLockExample {
public static void main(String[] args)
throws InterruptedException {
SharedCounter counter = new SharedCounter();
Runnable task = () -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.getCount());
}
}
The expected output is:
200000
Let's understand the important part.
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
First, the thread calls lock.lock().
If another thread already holds this lock, the current thread waits until the lock becomes available.
Once the thread acquires the lock, it executes
count++.
Finally, it releases the lock using unlock().
This ensures that only one thread at a time can execute the protected operation using this lock.
4. Why Should We Use finally to Release the Lock?
This is one of the most important things to remember when working with ReentrantLock.
Consider this code:
lock.lock();
performSomeOperation();
lock.unlock();
What happens if performSomeOperation() throws an exception?
The program may skip the unlock() statement. The lock will remain held, and other threads waiting for it may never get a chance to continue.
We should therefore use a try-finally block.
lock.lock();
try {
performSomeOperation();
} finally {
lock.unlock();
}
The finally block executes even if an exception occurs inside the try block.
This helps us ensure that the lock is released properly.
Remember this simple rule:
Whenever you successfully acquire a ReentrantLock, make sure you release it in a finally block.
5. Understanding ReentrantLock with Two Threads
Let's understand what happens when two threads try to acquire the same lock.
import java.util.concurrent.locks.ReentrantLock;
public class TwoThreadLockExample {
private static final ReentrantLock lock =
new ReentrantLock();
public static void performTask(String threadName) {
lock.lock();
try {
System.out.println(threadName + " acquired the lock");
System.out.println(threadName + " is performing work");
} finally {
System.out.println(threadName + " released the lock");
lock.unlock();
}
}
public static void main(String[] args) {
Thread thread1 = new Thread(
() -> performTask("Thread 1")
);
Thread thread2 = new Thread(
() -> performTask("Thread 2")
);
thread1.start();
thread2.start();
}
}
The exact order may vary depending on which thread acquires the lock first.
However, both threads cannot hold this same lock simultaneously.
One thread acquires the lock, performs its work, and releases it.
The other thread can then acquire the lock and continue.
6. Why Is It Called ReentrantLock?
Let's understand the word "reentrant".
Reentrant means that a thread which already owns a lock can acquire the same lock again.
This is useful when one method calls another method that also needs the same lock.
Consider this example:
import java.util.concurrent.locks.ReentrantLock;
class SharedResource {
private final ReentrantLock lock =
new ReentrantLock();
public void methodOne() {
lock.lock();
try {
System.out.println("Inside methodOne");
methodTwo();
} finally {
lock.unlock();
}
}
public void methodTwo() {
lock.lock();
try {
System.out.println("Inside methodTwo");
} finally {
lock.unlock();
}
}
}
public class ReentrantExample {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
resource.methodOne();
}
}
Let's understand what happens.
- methodOne() acquires the lock.
- methodOne() calls methodTwo().
- methodTwo() tries to acquire the same lock.
- The same thread already owns the lock, so it can acquire it again.
- methodTwo() completes and releases one acquisition.
- methodOne() completes and releases its acquisition.
The thread can acquire the same ReentrantLock multiple times. However, it must release it the same number of times before another thread can acquire it.
This is what makes the lock reentrant.
7. What Happens If We Call unlock() Without Acquiring the Lock?
A thread must own a ReentrantLock before it can release it.
If a thread calls unlock() without owning the lock,
Java throws an IllegalMonitorStateException.
This is another reason why we should carefully place lock() and unlock() statements inside a try-finally structure.
8. ReentrantLock vs. synchronized
Both ReentrantLock and synchronized help us protect shared data from concurrent access.
However, there are some differences.
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Lock management | Automatic | Manual |
| Acquiring lock | Using synchronized keyword | Using lock() |
| Releasing lock | Automatically when leaving the synchronized block or method | Explicitly using unlock() |
| Try to acquire without waiting | Not directly supported | Supported through tryLock() |
| Interruptible lock acquisition | Not directly supported | Supported through lockInterruptibly() |
The biggest practical difference is that ReentrantLock provides additional control over acquiring and managing locks.
But remember, synchronized is still a perfectly good choice for many situations.
9. A Quick Introduction to tryLock()
We have seen that lock() waits until the lock
becomes available.
But what if we don't want our thread to wait?
ReentrantLock provides another method called
tryLock().
if (lock.tryLock()) {
try {
System.out.println("Lock acquired");
// Perform protected work
} finally {
lock.unlock();
}
} else {
System.out.println("Lock is not available");
}
The tryLock() method attempts to acquire the lock immediately.
- If the lock is available, it acquires it and returns true.
- If the lock is unavailable, it returns false without waiting.
This gives us more flexibility. Instead of waiting for a lock, our thread can perform some other work or try again later.
We will explore more advanced locking options in future classes.
10. When Should We Use ReentrantLock?
ReentrantLock is useful when we need more control over how locks are acquired and released.
For example:
- When we want to try acquiring a lock without waiting.
- When we want interruptible lock acquisition.
- When we need more control over lock management.
- When a thread needs to acquire the same lock multiple times.
For simple situations, synchronized may be easier to use. We should choose the locking mechanism based on what the application actually needs.
Test Your Understanding
Let's see how well you understood this class!
Question 1: Which package contains ReentrantLock?
Question 2: Which method is used to acquire a ReentrantLock?
Question 3: Why should unlock() be placed inside a finally block?
Question 4: What does reentrant mean in ReentrantLock?
Question 5: What happens when tryLock() cannot acquire the lock?
Class Summary
Let's quickly revise what we learned today.
- ReentrantLock is available in java.util.concurrent.locks.
- It provides explicit control over acquiring and releasing locks.
lock()acquires the lock.unlock()releases the lock.- We should release locks inside a finally block.
- A thread can acquire the same ReentrantLock multiple times.
tryLock()attempts to acquire a lock without waiting.- ReentrantLock provides additional locking features compared with synchronized.
Remember: ReentrantLock gives us more control over locking, but that also means we are responsible for releasing the lock correctly.
You have completed 34 out of 46 classes.