Java ReentrantLock: Locking with More Control
java reentrantlock: Learn how Java ReentrantLock provides reentrant locking, fairness control, timed and interruptible acquisition, and condition variables for advance...
Java ReentrantLock is a synchronization primitive that gives you more control than the synchronized keyword. It supports reentrant locking, timed and interruptible lock acquisition, fairness policies, and multiple condition queues. This article explains how to use ReentrantLock effectively in concurrent Java applications.
Why ReentrantLock Exists
The synchronized keyword works for many cases, but it has limitations. You cannot interrupt a thread waiting to acquire a monitor, you cannot try to acquire a lock without blocking indefinitely, and you cannot choose a fairness policy. ReentrantLock from java.util.concurrent.locks addresses these gaps. It implements the Lock interface and provides the same mutual exclusion guarantee as synchronized, but with additional capabilities that are often necessary in production systems.
A key difference is that ReentrantLock is explicit: you must acquire and release it manually. This gives you the freedom to release the lock in a different order or scope than the block structure, but it also places the responsibility on you to ensure the lock is always released.
Reentrancy and the Lock's Internal Counter
ReentrantLock is reentrant, meaning the same thread can acquire the lock multiple times without deadlocking with itself. Each acquisition increments an internal hold count, and each unlock decrements it. The lock is released only when the hold count reaches zero. This is useful when a method that acquires the lock calls another method that also acquires the same lock.
ReentrantLock lock = new ReentrantLock(); public void outer() { lock.lock(); try { inner(); // reentrant acquisition } finally { lock.unlock(); } } public void inner() { lock.lock(); try { // critical section } finally { lock.unlock(); } }
Without reentrancy, the call to inner() would deadlock. The hold count allows the same thread to enter again, but other threads still wait until the outer unlock completes.
Locking and Unlocking with try/finally
The most common usage pattern is to acquire the lock, wrap the critical section in a try block, and call unlock() in a finally block. This guarantees the lock is released even if an exception is thrown.
lock.lock(); try { // access shared resource counter++; } finally { lock.unlock(); }
This pattern is not optional. If you forget to unlock, the lock remains held, and other threads will block indefinitely. The finally block is the only reliable way to ensure release under all circumstances.
Fairness: Choosing Between Fair and Unfair Locking
By default, ReentrantLock uses an unfair locking policy. An unfair lock allows a thread to "barge" in ahead of waiting threads, which can improve throughput but can cause starvation in extreme cases. A fair lock, created with new ReentrantLock(true), hands the lock to the longest-waiting thread. Fairness reduces throughput because it forces context switches and queue management, but it provides a more predictable ordering.
ReentrantLock fairLock = new ReentrantLock(true);
In practice, fairness is rarely needed unless your application has strict latency requirements or a history of starvation. For most concurrent data structures, the default unfair mode is preferred because it avoids the overhead of maintaining a strict queue.
Non-blocking Acquisition with tryLock()
tryLock() attempts to acquire the lock without blocking. It returns immediately with a boolean result. You can also pass a timeout to wait for the lock for a limited period.
if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // critical section } finally { lock.unlock(); } } else { // did not get the lock, perform fallback System.out.println("Lock not acquired, handling alternative path"); }
This is useful when you want to avoid indefinite blocking or when you need to implement a fail-fast policy. The timed variant throws InterruptedException if the thread is interrupted while waiting, so you must handle that exception.
Interruptible Lock Acquisition with lockInterruptibly()
lockInterruptibly() acquires the lock unless the thread is interrupted. If the thread is interrupted while waiting, the method throws InterruptedException and the lock is not acquired. This is important for responsive systems where a stuck thread should be cancellable.
try { lock.lockInterruptibly(); try { // critical section } finally { lock.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // handle cancellation }
The lock() method does not respond to interrupts, so if you need cancellation support, lockInterruptibly() is the correct choice.
Using Conditions with ReentrantLock
ReentrantLock provides newCondition() to create one or more Condition objects. These work like wait() and notify() but allow multiple wait sets. This is a major advantage over synchronized, which has only one implicit condition queue per monitor.
ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition();
A producer-consumer example can use two conditions to avoid waking all threads when only one type of event occurs. The await() method releases the lock and waits, while signal() wakes one waiting thread. You must hold the lock when calling these methods.
// Producer lock.lock(); try { while (queue.isFull()) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); }
The loop around await() is necessary because spurious wakeups can occur. Conditions give you finer control over which threads are awakened, reducing unnecessary contention.
Performance and Operational Considerations
ReentrantLock has slightly more overhead than synchronized because it is implemented as a separate class and requires explicit lock and unlock calls. However, under high contention, ReentrantLock can outperform synchronized because it uses a more scalable internal queue and supports features like timed and interruptible acquisition.
The fairness setting has a direct performance impact. A fair lock forces all acquisitions to go through the queue, which increases latency and reduces throughput. Unfair locks allow barging, which can improve overall throughput but may starve some threads.
In production, the choice between synchronized and ReentrantLock should be based on the required features. If you only need basic mutual exclusion, synchronized is simpler and less error-prone. If you need timed waits, interruptible acquisition, fairness control, or multiple conditions, ReentrantLock is the appropriate tool. Always measure your specific workload rather than assuming one is faster.