ReentrantLock & Monitors
The synchronized keyword is Java’s built-in tool for mutual exclusion, but it has real limitations — you can’t time out waiting for a lock, you can’t interrupt a thread that’s stuck waiting, and you can’t have multiple wait-sets on the same object. ReentrantLock (and the broader java.util.concurrent.locks package) gives you all of that, plus explicit control over fairness.
What is a Monitor?
A monitor is the theoretical concept behind Java’s synchronized blocks. Every Java object has an invisible monitor attached to it. When a thread enters a synchronized block, it acquires the monitor; all other threads wanting that same monitor are parked in a wait set until the first thread releases it.
The JVM implements monitors using a combination of object headers (the mark word) and OS-level mutexes. For lightly contended locks the JVM uses biased locking or thin locks (spin-based), escalating to a fat (inflated) lock backed by an OS mutex only when needed.
Note: Java 21 deprecated biased locking. The JVM now relies on thin-lock spinning and flat monitors instead.
ReentrantLock is a Java-level reimplementation of the same concept, built on top of AbstractQueuedSynchronizer (AQS), giving you the same mutual-exclusion guarantee with extra features.
ReentrantLock Basics
A reentrant lock means the same thread can acquire the lock multiple times without deadlocking itself — it just needs to release it the same number of times.
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
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();
}
}
}
Warning: Always call
unlock()inside afinallyblock. If you throw an exception betweenlock()andunlock()withoutfinally, the lock is never released and every other thread blocks forever.
Reentrancy in Action
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantDemo {
private final ReentrantLock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
System.out.println("Outer: hold count = " + lock.getHoldCount());
inner(); // same thread calls inner — no deadlock
} finally {
lock.unlock();
}
}
public void inner() {
lock.lock();
try {
System.out.println("Inner: hold count = " + lock.getHoldCount());
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
new ReentrantDemo().outer();
}
}
Output:
Outer: hold count = 1
Inner: hold count = 2
getHoldCount() shows how many times the current thread holds the lock. The lock is only truly released when the count drops back to zero.
tryLock — Non-Blocking Acquisition
One of the biggest advantages over synchronized: you can try to acquire a lock without blocking forever.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void doWork() throws InterruptedException {
// Try for up to 500 ms, then give up
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
Thread.sleep(200);
} finally {
lock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " could not acquire lock — skipping");
}
}
}
tryLock() (no arguments) returns immediately — true if the lock was free, false otherwise. This is great for avoiding deadlocks in complex multi-lock scenarios.
Fairness Policy
By default, ReentrantLock is unfair: when the lock becomes free, any waiting thread can jump the queue (barging). This maximizes throughput.
Passing true to the constructor creates a fair lock: threads are granted access strictly in the order they requested it (FIFO).
// Fair lock — no thread starvation, but lower throughput
ReentrantLock fairLock = new ReentrantLock(true);
// Default unfair lock — higher throughput, possible starvation
ReentrantLock unfairLock = new ReentrantLock(false);
Tip: Prefer unfair locks unless you have a specific need to prevent starvation. Fair locks are noticeably slower because they cannot take advantage of the “barging” optimization where a thread that just released a CPU can immediately re-acquire the lock.
Condition Variables
synchronized gives you one wait set per object via wait() / notify(). With ReentrantLock you can create multiple Condition objects — each acting as a separate wait set on the same lock. This is invaluable for patterns like producer-consumer.
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer<T> {
private final Queue<T> buffer = new LinkedList<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (buffer.size() == capacity) {
notFull.await(); // wait until space is available
}
buffer.add(item);
notEmpty.signal(); // wake one consumer
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (buffer.isEmpty()) {
notEmpty.await(); // wait until an item is available
}
T item = buffer.poll();
notFull.signal(); // wake one producer
return item;
} finally {
lock.unlock();
}
}
}
Notice that notFull.signal() wakes only a producer, and notEmpty.signal() wakes only a consumer. With plain synchronized + notifyAll() you’d wake every waiting thread — producers and consumers alike — causing unnecessary context switches.
Note:
Condition.await()is the equivalent ofObject.wait(), andCondition.signal()/signalAll()are the equivalents ofnotify()/notifyAll(). Always call them while holding the associated lock.
Interruptible Lock Acquisition
lockInterruptibly() lets a waiting thread be interrupted, which is impossible with synchronized.
import java.util.concurrent.locks.ReentrantLock;
public class InterruptibleTask implements Runnable {
private final ReentrantLock lock;
public InterruptibleTask(ReentrantLock lock) {
this.lock = lock;
}
@Override
public void run() {
try {
lock.lockInterruptibly(); // can be interrupted while waiting
try {
System.out.println("Working...");
Thread.sleep(1000);
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " was interrupted while waiting for lock");
}
}
}
This is essential for implementing cancellable tasks — see Interrupting a Thread for the broader picture.
synchronized vs ReentrantLock at a Glance
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Syntax | Built-in keyword | Explicit lock()/unlock() |
| Reentrancy | Yes | Yes |
| Fairness option | No (always unfair) | Yes (new ReentrantLock(true)) |
| tryLock / timeout | No | Yes |
| Interruptible wait | No | Yes (lockInterruptibly()) |
| Multiple wait sets | No (one per object) | Yes (multiple Conditions) |
| Performance | Excellent (JVM optimizes) | Slightly higher overhead |
| Deadlock debugging | Hard | getQueuedThreads(), isLocked() |
Tip: If
synchronizedmeets your needs, prefer it — the JVM applies heavy optimizations (lock elision, biased locking, stack-lock coalescing) thatReentrantLockcannot benefit from. Reach forReentrantLockwhen you need the extra features.
Under the Hood
ReentrantLock is built on AbstractQueuedSynchronizer (AQS), a framework in java.util.concurrent.locks. AQS maintains:
- An int state field (the lock’s hold count).
- A CLH queue (Craig, Landin, and Hagersten) — a doubly-linked list of
Nodeobjects, each representing a parked thread.
When a thread calls lock():
- AQS attempts a CAS (compare-and-swap) on the state field (0 → 1).
- If the CAS succeeds, the current thread becomes the owner.
- If the CAS fails (lock is held), the thread is wrapped in a
Nodeand appended to the CLH queue, then parked viaLockSupport.park()(which callsUNSAFE.park()internally — a thin wrapper aroundpthread_mutex).
When unlock() is called:
- The hold count is decremented.
- If it reaches zero, the lock is released and AQS unparks the head of the CLH queue.
Because AQS uses CAS instead of OS-level mutual exclusion for the fast path, uncontended ReentrantLock operations are nearly as fast as an uncontended synchronized block. The overhead only appears under contention, where both eventually fall back to OS primitives.
For the Java Memory Model guarantees that make lock releases and acquires safe across threads, see Java Memory Model.
Useful Diagnostic Methods
ReentrantLock lock = new ReentrantLock();
lock.isLocked(); // true if any thread holds it
lock.isHeldByCurrentThread(); // true if THIS thread holds it
lock.getHoldCount(); // how many times current thread has locked it
lock.getQueueLength(); // estimated number of threads waiting
lock.hasQueuedThreads(); // any threads waiting?
lock.isFair(); // was it created with fairness?
These methods are invaluable during debugging and monitoring — far more than anything synchronized exposes.
Related Topics
- Synchronization — the
synchronizedkeyword and how built-in monitors work - Synchronized Block — narrowing the scope of synchronization for better performance
- Deadlock — how deadlocks happen and how
tryLockhelps avoid them - Inter-Thread Communication —
wait(),notify(), andnotifyAll()with monitors - Java Memory Model — the visibility and ordering guarantees that locks provide
- volatile Keyword — a lighter-weight alternative for simple visibility needs