Java Locks | ReentrantLock, ReadWriteLock, StampedLock, Semaphore & Condition
This chapter explores the advanced concurrency utilities provided by the java.util.concurrent.locks package. While Java's built-in synchronized keyword offers simple monitor-based locking, production-grade multi-threaded applications often require finer control over lock acquisition, timeouts, fairness, and read-write separation. This guide covers the mechanics, use cases, and code implementations of ReentrantLock, ReadWriteLock, StampedLock, Semaphore, and the Condition interface.
1. The Limitations of synchronized
Before diving into the java.util.concurrent.locks package, it is essential to understand why built-in monitor locks (via the synchronized keyword) are insufficient for complex concurrent applications.
A. No Control Over Lock Acquisition (Non-Blocking Attempts)
When a thread attempts to enter a synchronized block and finds the lock held by another thread, it is immediately forced into a blocked state. There is no programmatic way to say: "Try to acquire the lock; if it is unavailable, perform an alternative task instead of blocking."
B. No Timeouts
A thread waiting for a synchronized block will wait indefinitely. In production environments, this can lead to silent deadlocks or severe latency spikes. There is no mechanism to specify a maximum waiting time (e.g., "Wait for 2 seconds; if the lock is still unavailable, abort or log an error").
C. No Fair Play (Fairness)
synchronized blocks do not guarantee any acquisition order. If multiple threads are waiting for a lock, the JVM and the underlying Operating System scheduler arbitrarily select the next thread to acquire it. This lack of fairness can cause thread starvation, where a thread that arrived first remains blocked indefinitely while newly arrived threads repeatedly acquire the lock.
2. The Lock Interface Contract
To address these limitations, Java 5 introduced the java.util.concurrent.locks.Lock interface. This interface defines a contract for explicit locking operations, giving developers manual control over lock lifecycle events.
The core methods defined in the Lock interface include:
void lock(): Acquires the lock. If the lock is unavailable, the current thread becomes disabled for thread scheduling purposes and lies dormant until the lock is acquired.void unlock(): Releases the lock.boolean tryLock(): Attempts to acquire the lock without blocking. Returnstrueimmediately if the lock was acquired, andfalseotherwise.boolean tryLock(long time, TimeUnit unit): Attempts to acquire the lock within the given timeout. Returnstrueif acquired, orfalseif the timeout elapsed before acquisition.
3. ReentrantLock: Syntax, Semantics, and Try-Lock Mechanics
ReentrantLock is the most widely used implementation of the Lock interface. It behaves similarly to the implicit monitor lock accessed via synchronized but offers enhanced capabilities.
Naive vs. Production-Grade Implementation
When using explicit locks, developers must manually release the lock. If an exception occurs within the critical section before unlock() is called, the lock remains held forever, causing a permanent deadlock for all other threads.
The Naive (Dangerous) Approach
// Avoid this pattern!
lock.lock();
doSomeRiskyOperation(); // If this throws an exception, the lock is never released!
lock.unlock();
The Production-Grade (Safe) Approach
Always wrap the critical section in a try block and invoke unlock() inside a finally block. This guarantees lock release regardless of whether the critical section completes successfully or throws an exception.
lock.lock();
try {
// Critical Section
doSomeRiskyOperation();
} finally {
lock.unlock();
}
Realistic Code Evolution: synchronized vs. ReentrantLock
Below is a complete, runnable example demonstrating how multiple threads access a shared resource using a ReentrantLock.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class SharedResource {
private final Lock lock = new ReentrantLock();
public void performTask() {
lock.lock(); // Explicitly acquire lock
try {
System.out.println(Thread.currentThread().getName() + " entered the critical section.");
// Simulate work
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " is exiting the critical section.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock(); // Guaranteed release
}
}
}
public class ReentrantLockDemo {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Runnable task = resource::performTask;
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
Thread t3 = new Thread(task, "Thread-3");
t1.start();
t2.start();
t3.start();
}
}
Non-Blocking Lock Acquisition with tryLock()
The tryLock() method allows threads to attempt lock acquisition without blocking. If the lock is busy, the thread can execute an alternative code path (the else block).
if (lock.tryLock()) {
try {
// Manipulate shared resource safely
} finally {
lock.unlock();
}
} else {
// Perform fallback/alternative task
System.out.println("Lock busy. Doing alternative work...");
}
4. Understanding Reentrancy & Fairness
What is Reentrancy?
A lock is reentrant if a thread that already holds the lock can re-acquire it without deadlocking itself.
If a thread calls lock() three times, it must call unlock() exactly three times to fully release the lock. The lock maintains an internal hold count to track this.
Note: Both
synchronizedandReentrantLockare reentrant by default.
Fairness Policies
When creating a ReentrantLock, you can pass an optional boolean parameter to the constructor to specify its fairness policy:
Lock unfairLock = new ReentrantLock(); // Default: Unfair
Lock fairLock = new ReentrantLock(true); // Fair Lock
- Unfair Lock (Default): Does not guarantee any specific order. A newly requesting thread can "bypas" or "barge" ahead of threads that are already waiting in the queue if the lock happens to become available at that exact moment. This offers higher throughput but risks starvation.
- Fair Lock: Grants access to the longest-waiting thread (FIFO order). This prevents starvation but incurs a performance penalty due to the overhead of tracking and scheduling queued threads.
5. ReadWriteLock and the Reader-Writer Problem
In many applications, read operations on a shared resource are far more frequent than write operations.
- Reading is non-destructive; multiple threads can safely read a resource simultaneously without data corruption.
- Writing is destructive; only one thread should modify the resource at a time, and no threads should read it while a write is in progress.
Using a standard mutual exclusion lock (like ReentrantLock or synchronized) for both operations is highly inefficient because it blocks concurrent reads.
The Solution: ReadWriteLock
The ReadWriteLock interface maintains a pair of associated locks: one for read-only operations and one for write operations.
- Read Lock (Shared): Multiple threads can hold the read lock simultaneously, as long as no thread holds the write lock.
- Write Lock (Exclusive): Only a single thread can hold the write lock. No other thread can read or write while this lock is held.
Implementation: ReentrantReadWriteLock
Below is a complete implementation of a thread-safe shared cache using ReentrantReadWriteLock.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class SharedDataCache {
private int data = 0;
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public int readData() {
rwLock.readLock().lock(); // Acquire shared read lock
try {
System.out.println(Thread.currentThread().getName() + " is reading: " + data);
Thread.sleep(1000); // Simulate read latency
return data;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return -1;
} finally {
rwLock.readLock().unlock(); // Release read lock
}
}
public void writeData(int newValue) {
rwLock.writeLock().lock(); // Acquire exclusive write lock
try {
System.out.println(Thread.currentThread().getName() + " is writing: " + newValue);
Thread.sleep(1000); // Simulate write latency
this.data = newValue;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
rwLock.writeLock().unlock(); // Release write lock
}
}
}
Writer Starvation Pitfall
If a continuous stream of reader threads keeps arriving, the read lock count may never drop to zero. Because a write lock requires exclusive access, the writer thread will block indefinitely. This is known as writer starvation. To mitigate this, configure the lock with a fairness policy (new ReentrantReadWriteLock(true)), which ensures waiting writers are eventually granted access.
6. Lock Downgrading vs. Upgrading
ReentrantReadWriteLock supports Lock Downgrading but does not support Lock Upgrading.
Lock Downgrading (Allowed)
Lock downgrading is the process of acquiring a write lock, performing modifications, acquiring the read lock while still holding the write lock, and then releasing the write lock. This allows you to safely transition from a write state to a read state without letting other writers modify the resource in between.
Downgrading Workflow
Code Example: Lock Downgrading
class DowngradeExample {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private int sharedState = 0;
public void processData() {
rwLock.writeLock().lock(); // 1. Acquire Write Lock
try {
sharedState = 42; // Modify state
rwLock.readLock().lock(); // 2. Acquire Read Lock (Downgrade starts)
} finally {
rwLock.writeLock().unlock(); // 3. Release Write Lock (Still holding Read Lock)
}
try {
// 4. Read state safely without allowing other writers to intervene
System.out.println("Downgraded state read: " + sharedState);
} finally {
rwLock.readLock().unlock(); // 5. Release Read Lock
}
}
}
Lock Upgrading (Not Allowed)
Lock upgrading is the process of holding a read lock and attempting to acquire a write lock. This is not permitted because it easily leads to deadlocks. If two reader threads simultaneously attempt to upgrade to a write lock, both will wait indefinitely for the other to release their read lock.
7. StampedLock: Pessimistic vs. Optimistic Locking
Introduced in Java 8, StampedLock is a highly optimized alternative to ReadWriteLock. It uses a long value called a stamp to represent the lock state or version.
Core Characteristics
- Not Reentrant:
StampedLockis not reentrant. If a thread holding a lock attempts to acquire it again, it will deadlock. - Three Modes: Writing, Pessimistic Reading, and Optimistic Reading.
Pessimistic vs. Optimistic Locking Models
8. Deep Dive: Optimistic Read Validation Workflow
The most powerful feature of StampedLock is Optimistic Reading. Instead of acquiring a shared read lock (which blocks writers), a thread obtains an optimistic read stamp, reads the shared data into local variables, and then validates whether a write occurred during the read window.
Vertical Workflow of Optimistic Read
Code Implementation: StampedLock Optimistic Read
import java.util.concurrent.locks.StampedLock;
class OptimisticPoint {
private double x, y;
private final StampedLock sl = new StampedLock();
// Writer method (Exclusive Lock)
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
// Reader method (Optimistic Read with Pessimistic Fallback)
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 1. Get optimistic stamp
double currentX = x; // 2. Read shared variables into local variables
double currentY = y;
if (!sl.validate(stamp)) { // 3. Validate stamp
// Stamp is invalid! A writer modified 'x' or 'y' during the read.
System.out.println("Validation failed. Falling back to pessimistic read lock.");
stamp = sl.readLock(); // 4. Fallback: Acquire pessimistic read lock
try {
currentX = x; // Re-read
currentY = y;
} finally {
sl.unlockRead(stamp); // Release pessimistic read lock
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
9. Semaphore: Counting, Binary, and the Ownership Distinction
A Semaphore controls access to a shared resource by maintaining a set of virtual permits.
Core Methods
acquire(): Decrements the permit count. If no permits are available, the thread blocks until one becomes available.release(): Increments the permit count. If threads are blocked waiting for a permit, one is unblocked.
Binary vs. Counting Semaphores
- Binary Semaphore (Permits = 1): Acts similarly to a mutual exclusion lock. Only one thread can access the critical section at a time.
- Counting Semaphore (Permits > 1): Allows a specified maximum number of threads to access a resource concurrently (e.g., limiting database connection pool access).
The Crucial Distinction: Ownership
The fundamental difference between a Lock and a Semaphore is ownership:
- Locks have ownership: The thread that calls
lock()must be the same thread that callsunlock(). If a different thread attempts to unlock aReentrantLock, it throws anIllegalMonitorStateException. - Semaphores have no ownership: Any thread can call
release()on a semaphore, even if it never calledacquire(). This makes semaphores highly useful for inter-thread signaling and coordination.
Code Example: Limiting Parallel Tasks with a Semaphore
import java.util.concurrent.Semaphore;
class APIConnectionPool {
// Limit concurrent API requests to 3
private final Semaphore semaphore = new Semaphore(3);
public void executeRequest() {
try {
semaphore.acquire(); // Acquire a permit
System.out.println(Thread.currentThread().getName() + " secured connection. Executing API call...");
Thread.sleep(2000); // Simulate network call
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println(Thread.currentThread().getName() + " releasing connection.");
semaphore.release(); // Release the permit
}
}
}
10. Condition Interface: Multiple Waiting Queues
The Condition interface provides a modern alternative to the traditional Object.wait(), Object.notify(), and Object.notifyAll() methods. It is always bound to an explicit Lock instance.
Method Mapping
The Core Advantage: Multiple Waiting Queues
With traditional monitor locks (synchronized), an object has exactly one wait-set. If you have both producers and consumers waiting on the same object, calling notify() wakes up a random thread. A producer might wake up another producer instead of a consumer, leading to inefficient thread signaling and unnecessary context switches.
With the Condition interface, you can create multiple independent waiting queues on a single lock.
[ ReentrantLock ]
/ \
/ \
[ Condition: NotFull ] [ Condition: NotEmpty ]
(Queue for Producers) (Queue for Consumers)
| |
Producer1 Consumer1
Producer2 Consumer2
Code Example: Thread-Safe Bounded Buffer (Producer-Consumer)
This implementation uses separate conditions for "not full" and "not empty" states, ensuring producers only wake up consumers, and vice versa.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class BoundedBuffer {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // Producer waiting queue
private final Condition notEmpty = lock.newCondition(); // Consumer waiting queue
private final Object[] items = new Object[5];
private int putptr, takeptr, count;
public void put(Object x) throws InterruptedException {
lock.lock();
try {
// Use 'while' loop to protect against spurious wakeups
while (count == items.length) {
System.out.println("Buffer full. Producer waiting...");
notFull.await(); // Producer waits on the 'notFull' condition
}
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
count++;
System.out.println("Produced: " + x);
notEmpty.signal(); // Signal waiting consumers on the 'notEmpty' condition
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
System.out.println("Buffer empty. Consumer waiting...");
notEmpty.await(); // Consumer waits on the 'notEmpty' condition
}
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
count--;
System.out.println("Consumed: " + x);
notFull.signal(); // Signal waiting producers on the 'notFull' condition
return x;
} finally {
lock.unlock();
}
}
}
Spurious Wakeups
A thread waiting on a condition can wake up arbitrarily without receiving an explicit signal. This is known as a spurious wakeup, caused by underlying OS-level thread scheduling mechanics. To protect against this, always invoke await() inside a while loop that checks the condition predicate, rather than an if statement.
// Correct Pattern
while (bufferIsEmpty) {
condition.await();
}
// Incorrect Pattern (Vulnerable to Spurious Wakeups)
if (bufferIsEmpty) {
condition.await();
}
11. Summary and Key Takeaways
Lock Comparison Matrix
Key Takeaways
- Always use
try-finally: When using explicit locks, always release the lock inside afinallyblock to prevent deadlocks from unexpected exceptions. - Prefer Unfair Locks for Throughput: Fair locks incur a significant performance penalty. Use unfair locks unless your application specifically requires starvation prevention.
- Use
ReadWriteLockto Scale Reads: If your application reads data 90% of the time and writes only 10% of the time,ReadWriteLockdramatically improves throughput compared to standard mutual exclusion locks. - Leverage
StampedLockfor Ultra-Low Overhead: For read-heavy systems with minimal write contention,StampedLock's optimistic read mode avoids lock acquisition overhead entirely. - Understand Semaphore Ownership: Unlike locks, semaphores do not track which thread acquired a permit. Any thread can release a permit, making them ideal for signaling.
- Solve Arbitrary Wakeups with
Condition: Use theConditioninterface to separate producer and consumer waiting queues, and always wrapawait()calls in awhileloop to prevent spurious wakeups.