Locks in Java
Introduction
In concurrent programming, managing shared resources is critical. When multiple threads access and modify the same data simultaneously, race conditions, data corruption, and unpredictable behavior can occur. In Java, Locks are the fundamental synchronization mechanism used to enforce mutual exclusion, ensuring that only one thread can execute a critical section of code at any given time.
While Java provides built-in intrinsic locks via the synchronized keyword, the java.util.concurrent.locks package introduced in Java 5 offers much more sophisticated and flexible locking mechanisms.
1. Intrinsic Locks (Monitors)
Every object in Java has an intrinsic lock (also known as a monitor lock) associated with it. When a thread enters a synchronized method or block, it acquires the intrinsic lock for that object (or the Class object for static methods).
public class Counter {
private int count = 0;
// The thread must acquire the lock on 'this' object
public synchronized void increment() {
count++;
}
public void decrement() {
// Synchronized block allowing finer granularity
synchronized (this) {
count--;
}
}
}
Limitations of intrinsic locks:
- They are block-structured (lock acquisition and release must occur in the same block).
- A thread blocked waiting for an intrinsic lock cannot be interrupted.
- They do not support timeouts for acquiring the lock.
- They are strictly non-fair (no guarantee on the order in which waiting threads get the lock).
2. Explicit Locks: The Lock Interface
The Lock interface in java.util.concurrent.locks provides extensive locking operations that go beyond intrinsic locks. The standard idiom for using explicit locks is the try-finally block to ensure the lock is always released, even if an exception is thrown.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ExplicitCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Block until the lock is acquired
try {
count++;
} finally {
lock.unlock(); // Always release in a finally block
}
}
}
Advanced Capabilities of Lock
- Interruptible lock acquisition:
lockInterruptibly()allows a thread to be interrupted while waiting for a lock. - Timed lock acquisition:
tryLock(long time, TimeUnit unit)attempts to acquire the lock but gives up after the specified time. - Non-blocking lock acquisition:
tryLock()acquires the lock only if it is free at the time of invocation.
3. ReentrantLock and Fairness
A ReentrantLock allows the thread that currently holds the lock to reacquire it multiple times without deadlocking itself. The lock maintains a hold count, and the thread must unlock it the same number of times.
ReentrantLock also supports a fairness policy.
- Non-fair (Default): A newly requesting thread can jump the queue and acquire the lock if it happens to be available, optimizing throughput.
- Fair: The lock is granted to the thread that has been waiting the longest. This prevents starvation but incurs a performance penalty due to context switching overhead.
// Creates a fair lock
Lock fairLock = new ReentrantLock(true);
4. ReadWriteLock
Often, data is read frequently but modified infrequently. A ReentrantLock would unnecessarily block concurrent readers. The ReadWriteLock interface maintains a pair of associated locks, one for read-only operations and one for writing.
- Read Lock: Can be held simultaneously by multiple reader threads, as long as there are no writers.
- Write Lock: Exclusive. Only one thread can hold it, and no read locks can be held concurrently.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class SharedDictionary {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private String data = "Initial State";
public String readData() {
rwLock.readLock().lock();
try {
return data;
} finally {
rwLock.readLock().unlock();
}
}
public void writeData(String newData) {
rwLock.writeLock().lock();
try {
data = newData;
} finally {
rwLock.writeLock().unlock();
}
}
}
5. StampedLock (Java 8+)
StampedLock is an advanced locking mechanism that provides optimistic reading capabilities, potentially outperforming ReadWriteLock in scenarios with many readers and few writers.
It does not implement the Lock or ReadWriteLock interfaces. Instead, lock methods return a long stamp that must be used for unlocking or conversion.
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
// Optimistic read
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;
// Check if a write lock was acquired since the stamp was issued
if (!sl.validate(stamp)) {
// Fallback to a pessimistic read lock
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
Common Pitfalls and Best Practices
- Deadlocks: Occur when two or more threads are blocked forever, waiting for each other to release locks. Avoid deadlocks by always acquiring multiple locks in a strict, globally consistent order.
- Lock Starvation: When a thread is perpetually denied access to a lock because other threads keep acquiring it (can be mitigated with fair locks).
- Lock Contention: Having too many threads competing for a single lock degrades performance. Minimize the scope of the critical section or use lock striping (e.g.,
ConcurrentHashMap). - Forgetting
finally: Always release explicit locks in afinallyblock. A thrown exception beforeunlock()will cause the lock to be held indefinitely, crashing your application.
Conclusion
Understanding Locks is essential for building concurrent Java applications. While synchronized is sufficient for simple use cases, ReentrantLock, ReadWriteLock, and StampedLock provide the granular control required for high-performance, complex systems. Always weigh the simplicity of intrinsic locks against the power and overhead of explicit locks.