ReentrantLock
In the realm of Java multi-threaded programming, managing access to shared resources is a fundamental challenge. While the synchronized keyword provides a built-in mechanism for mutual exclusion, it often lacks the granularity and flexibility required for complex concurrency patterns. This is where ReentrantLock, part of the java.util.concurrent.locks package, becomes indispensable.
ReentrantLock is a highly capable, explicit mutual exclusion lock that implements the Lock interface. It offers advanced features such as interruptible lock acquisitions, timed attempts, fairness policies, and multiple condition variables. Under the hood, it leverages the AbstractQueuedSynchronizer (AQS) framework, making it a robust tool for building high-performance concurrent applications.
Core Concept: Reentrancy
The "reentrant" in ReentrantLock refers to its ability to allow a thread that already holds the lock to acquire it again without causing a deadlock. This is a critical feature for recursive calls or nested method calls where multiple methods require the same lock.
Internally, ReentrantLock maintains a hold count. When a thread acquires the lock, the count increases; when it releases the lock, the count decreases. The lock is only truly released and made available to other threads when the hold count reaches zero. This mechanism is managed via the state variable in the underlying AQS.
public void outerMethod() {
lock.lock();
try {
// Some logic...
innerMethod(); // Re-entering the lock
} finally {
lock.unlock();
}
}
public void innerMethod() {
lock.lock();
try {
// More logic...
} finally {
lock.unlock();
}
}
Without reentrancy, the innerMethod() call in the example above would cause the thread to wait for a lock it already owns, resulting in a permanent deadlock.
Basic Usage and the Importance of finally
Unlike synchronized, which is managed automatically by the JVM, ReentrantLock requires explicit management. You must manually call lock() to acquire the lock and unlock() to release it.
To ensure system stability, it is a strict best practice to wrap the business logic within a try block and place the unlock() call inside a finally block. If an exception occurs during the execution of the protected code and the lock is not released, the lock will be held indefinitely, potentially causing all other threads to hang.
import java.util.concurrent.locks.ReentrantLock;
public class SafeCounter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // Acquire the lock
try {
count++;
} finally {
lock.unlock(); // Always release in finally
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Fairness Policies: Fair vs. Non-fair Locks
When constructing a ReentrantLock, you can specify a fairness policy:
- Non-fair Lock (Default): This mode does not guarantee the order in which waiting threads acquire the lock. A thread requesting the lock might "barge" ahead of threads that have been waiting longer. While this can lead to thread starvation in extreme cases, it generally offers significantly higher throughput because it reduces the overhead of context switching and thread parking.
- Fair Lock: By passing
trueto the constructor (new ReentrantLock(true)), you enable fairness. In this mode, the lock favors the thread that has been waiting the longest (FIFO order). This prevents starvation but typically results in lower performance due to the strict management of the waiting queue.
In most high-performance applications, the default non-fair lock is preferred unless there is a specific requirement to prevent thread starvation.
Advanced Coordination with Condition
One of the most powerful features of ReentrantLock is its ability to create multiple Condition variables via the newCondition() method. In standard Java synchronization, Object.wait() and Object.notify() operate on a single wait-set per object. In contrast, a single ReentrantLock can support multiple Condition objects, allowing for much more granular signaling.
This is particularly useful in producer-consumer scenarios where you want to signal "not full" and "not empty" separately.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class DataBuffer {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
private final Object[] items = new Object[10];
private int count = 0;
public void produce(Object item) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await(); // Wait until space is available
}
items[count++] = item;
notEmpty.signal(); // Signal that data is available
} finally {
lock.unlock();
}
}
public Object consume() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await(); // Wait until data is available
}
Object item = items[--count];
notFull.signal(); // Signal that space is available
return item;
} finally {
lock.unlock();
}
}
}
Crucial Note: When using await(), always wrap the call in a while loop rather than an if statement. This protects the logic against spurious wakeups, where a thread might wake up without a corresponding signal() being sent.
Comparing ReentrantLock and synchronized
Choosing between these two mechanisms depends on the complexity of your concurrency requirements.
| Feature | synchronized |
ReentrantLock |
|---|---|---|
| Ease of Use | Very high (automatic) | Moderate (manual management) |
| Flexibility | Limited | High (interruptible, timed, etc.) |
| Fairness | Not supported | Supported (optional) |
| Condition Variables | Single (via wait/notify) |
Multiple (via Condition) |
| Lock Acquisition | Blocking only | tryLock(), lockInterruptibly() |
| Performance | Highly optimized by JVM | Excellent, especially under high contention |
When to use synchronized: For simple synchronization tasks where you don't need advanced features. It is cleaner, less error-prone, and the JVM's intrinsic optimizations make it very efficient for most standard use cases.
When to use ReentrantLock: When you need advanced capabilities like tryLock(long time, TimeUnit unit) to avoid indefinite blocking, lockInterruptibly() to allow a thread to respond to an interrupt while waiting, or multiple Condition objects for complex coordination.
Best Practices and Precautions
To write safe and efficient concurrent code with ReentrantLock, keep the following guidelines in mind:
- Always use
try-finally: As emphasized, failing to callunlock()in afinallyblock is a recipe for system-wide deadlocks. - Declare locks as
final: This prevents the lock reference from being changed accidentally, which would lead to different threads synchronizing on different objects. - Be mindful of lock ordering: When acquiring multiple locks, always acquire them in a consistent order across all threads to prevent circular wait deadlocks.
- Use
tryLock()judiciously: If you usetryLock(), always check the boolean return value before proceeding to the protected logic. - Prefer non-fair locks by default: Only opt for fairness if your specific business logic requires strict ordering to prevent starvation.
- Handle spurious wakeups: Always use a
whileloop when callingCondition.await().
Conclusion
ReentrantLock is a sophisticated tool that provides developers with fine-grained control over thread synchronization. While it requires more discipline than the synchronized keyword, its ability to handle complex coordination through Condition variables and its flexible acquisition modes make it a cornerstone of professional Java concurrency development. By understanding its underlying AQS-based mechanics and adhering to best practices, you can build highly scalable and reliable concurrent systems.