What is reentrant lock
A Reentrant Lock (recursive mutex) is a synchronization primitive that allows a thread already holding a lock to re-enter any code block protected by the same lock without trigg...
🟢 Junior Level
30-Second Summary
A Reentrant Lock (recursive mutex) is a synchronization primitive that allows a thread already holding a lock to re-enter any code block protected by the same lock without triggering a self-deadlock.
In the Java ecosystem, the term applies in two contexts:
- Conceptual Property: Built-in Java synchronization constructs—both the intrinsic
synchronizedmonitor and explicit lock implementations—are reentrant by design. - Standard Library Class
java.util.concurrent.locks.ReentrantLock: An explicit lock implementation built onAbstractQueuedSynchronizer(AQS) that provides capabilities beyondsynchronized, such as non-blocking lock attempts (tryLock), timed acquisitions, interruptible acquisitions (lockInterruptibly), fairness policies, and multiple condition variables (Condition).
Every re-entrant acquisition increments an internal hold count, and each release decrements it. The lock is only released to other competing threads when the hold count returns to zero.
Why Reentrancy Is Essential
Without reentrancy, any nested method call guarded by the same lock would cause the executing thread to block itself indefinitely:
public class SelfDeadlockPrevention {
// If locks were NOT reentrant:
public synchronized void outer() {
// Current thread acquires monitor
inner(); // THREAD DEADLOCKS ITSELF WAITING FOR ITS OWN LOCK!
}
public synchronized void inner() {
// Safe execution thanks to reentrancy
}
}
Idiomatic Java 21 Example with ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private final ReentrantLock lock = new ReentrantLock();
private int balance = 1000;
public void deposit(int amount) {
// ⚠️ CRITICAL: lock() MUST be called BEFORE the try block
lock.lock();
try {
// Critical section
balance += amount;
} finally {
// ⚠️ CRITICAL: unlock() MUST ALWAYS reside in finally!
lock.unlock();
}
}
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
[!WARNING] Calling
lock.unlock()must strictly reside inside afinallyblock. If an unhandled exception occurs inside the critical section without afinallyblock, the lock remains permanently held, hanging all other competing threads in the application.
🟡 Middle Level
Internal Architecture: AbstractQueuedSynchronizer (AQS)
Unlike synchronized, which is handled natively by JVM HotSpot via object header Mark Words, ReentrantLock is implemented entirely in standard Java library code using AQS (AbstractQueuedSynchronizer).
Core components of the AQS engine:
volatile int state: The synchronization state. InReentrantLock,state == 0signifies an unlocked state. Any valuestate >= 1represents the current recursive hold count.Thread exclusiveOwnerThread: A reference to the thread that currently holds exclusive ownership of the lock.- Double-Linked CLH FIFO Queue: A linked list of
Nodeobjects where threads descheduled by contention wait for their turn.
┌───────────────────────────────────────────────────────┐
│ ReentrantLock (AQS) │
│ exclusiveOwnerThread = Thread-1 │
│ state = 2 (Reentrant Hold Count) │
└───────────────────────────────────────────────────────┘
│
CLH FIFO Contention Queue (Doubly-Linked)
┌────────┐ prev ┌────────┐ prev ┌────────┐
head │ Node 1 │ <───────────── │ Node 2 │ <───────────── │ tail │
│ Thread-2│ ─────────────> │ Thread-3│ ─────────────> │ │
└────────┘ next └────────┘ next └────────┘
Fair vs. Non-Fair Mode
ReentrantLock supports two acquisition policies via its constructor:
ReentrantLock nonFairLock = new ReentrantLock(); // Default: fair = false
ReentrantLock fairLock = new ReentrantLock(true); // Fair: fair = true
1. Non-Fair Mode (Default / Barging):
- When an incoming thread calls
lock(), it immediately attempts an atomic CAS acquisition (compareAndSetState(0, 1)). - If the lock was just released by the previous owner, the incoming thread seizes it immediately, bypassing threads queued in the CLH wait queue (Barging).
- Advantage: Delivers 2x to 10x higher throughput by avoiding the CPU latency of waking up a parked thread (saving ~1,000–5,000 ns context switches).
- Disadvantage: Possibility of thread starvation under high contention.
2. Fair Mode (Strict FIFO):
- Incoming threads check
hasQueuedPredecessors(). If prior threads are already waiting in the AQS queue, the incoming thread enqueues itself at the tail without attempting CAS acquisition. - Advantage: Strict FIFO ordering guarantees absolute absence of thread starvation.
- Disadvantage: Substantial drop in overall throughput due to continuous thread parking and OS context switching.
Advanced Capabilities of ReentrantLock
1. Non-Blocking & Timed Acquisitions (tryLock)
// Immediate non-blocking acquisition
if (lock.tryLock()) {
try {
processTask();
} finally {
lock.unlock();
}
} else {
// Lock unavailable: fallback without blocking
handleBusy();
}
// Timed acquisition (Deadlock mitigation)
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
processTask();
} finally {
lock.unlock();
}
}
2. Interruptible Lock Acquisition (lockInterruptibly)
A thread can abort waiting for a lock if signaled via Thread.interrupt(), throwing InterruptedException, unlike synchronized which blocks indefinitely.
3. Multiple Wait-Sets via Condition
Unlike intrinsic monitors which offer only a single wait-set per object (wait()/notify()), a single ReentrantLock can spawn multiple distinct condition queues:
public class BoundedBuffer<E> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(E item) throws InterruptedException {
lock.lock();
try {
while (isFull()) {
notFull.await(); // Producer waits only on space availability
}
enqueue(item);
notEmpty.signal(); // Wakes up ONLY waiting consumers!
} finally {
lock.unlock();
}
}
}
🔴 Senior Level
Under the Hood: AQS Fast Path vs Slow Path
In OpenJDK source code, non-fair lock acquisition splits into an optimized Fast Path and a fallback Slow Path:
// NonfairSync implementation:
final void lock() {
// Fast path: Atomic CAS attempt on unowned lock
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // Slow path fallback via AQS
}
Slow Path & OS Parking Pipeline:
- Calls
tryAcquire(1). If the current thread is already the owner,stateincrements recursively (state + 1). - If owned by another thread, an exclusive
Nodeis created and atomically enqueued at the tail (enq()). - The thread enters an adaptive spin loop in
acquireQueued, evaluating whether its node has become the queue head. - If still blocked, the predecessor node’s status is updated to
Node.SIGNAL(“wake me up when you finish”). - The thread is descheduled via
LockSupport.park(this), which translates into an OS kernel syscall (futex(FUTEX_WAIT)on Linux).
ReentrantLock and Virtual Threads (Project Loom, Java 21)
The introduction of Virtual Threads (JEP 444) in Java 21 turned the choice between synchronized and ReentrantLock into a critical architectural decision:
| Criterion | synchronized (Java 21) |
ReentrantLock (Java 21) |
|---|---|---|
| Blocking Mechanism | Pins the underlying OS Carrier Thread | Unmounts Virtual Thread from Carrier Thread |
| Scalability Impact | Blocks a real platform thread from ForkJoinPool |
Frees the Carrier Thread to execute other virtual tasks |
| Architecture Recommendation | Avoid for long critical sections or blocking calls | Strongly recommended for blocking critical sections |
[!IMPORTANT] In Java 21, blocking inside a
synchronizedblock pins the virtual thread to its OS carrier thread, preventing the carrier thread from executing other virtual threads.ReentrantLockusesLockSupport.park(), which Loom intercepts natively: the virtual thread’s stack frames are parked on the JVM heap, leaving the carrier thread free to execute other work.
Detailed Comparison: synchronized vs ReentrantLock
| Metric | synchronized |
ReentrantLock |
|---|---|---|
| Implementation Layer | JVM HotSpot Bytecode (monitorenter/monitorexit) |
Java API Class (AbstractQueuedSynchronizer) |
| Release Mechanism | Automatic (guaranteed by bytecode Exception table) |
Manual (developer must provide try-finally) |
| Timed Acquisition | ❌ Not supported | ✅ tryLock(timeout, unit) |
| Interruption Support | ❌ Uninterruptible | ✅ lockInterruptibly() |
| Fairness Option | ❌ Non-fair only | ✅ Optional (new ReentrantLock(true)) |
| Condition Variables | Single wait-set (wait/notify) |
Multiple isolated conditions (Condition) |
| Virtual Thread Friendly | ❌ Pins carrier thread | ✅ Fully non-pinning |
4 Tricky Questions
1. What happens if lock.unlock() is called more times or fewer times than lock.lock()?
Answer:
- Extra
unlock()calls: Ifunlock()is invoked when the hold count is already 0, or if called by a thread that does not currently own the lock, anIllegalMonitorStateExceptionis thrown immediately. - Fewer
unlock()calls: Each call tounlock()decrementsstate. Ifunlock()is called fewer times thanlock(),stateremains greater than 0. The calling thread remains the exclusive owner of the lock, and all other competing threads will block indefinitely, causing application starvation or deadlock.
2. Why does calling lock.tryLock() with no arguments violate fairness even on a new ReentrantLock(true) instance?
Answer:
This is intentional, documented behavior known as barging tryLock. In the JDK implementation:
public boolean tryLock() {
return sync.nonfairTryAcquire(1); // Unconditionally invokes nonfair acquisition!
}
Parameterless tryLock() immediately performs a CAS check on the current state. If the lock happens to be free at that exact instant, it takes ownership immediately—even if other threads have been waiting in the fair FIFO queue. To perform a strictly fair tryLock, you must pass a zero timeout: lock.tryLock(0, TimeUnit.SECONDS).
3. What is the fundamental architectural advantage of Condition over wait()/notify()?
Answer:
Intrinsic object monitors provide only a single implicit wait-set. When multiple thread roles (e.g., both data producers and data consumers) wait on the same monitor, calling notify() risks waking the wrong role, while calling notifyAll() wakes every waiting thread simultaneously (causing the Thundering Herd Problem and massive context-switch churn).
ReentrantLock allows creating multiple isolated Condition instances (e.g., notFull and notEmpty). Calling notEmpty.signal() targets and awakens only a consumer thread, eliminating wasted wakeups and reducing CPU context-switch overhead to $O(1)$.
4. Why is ReentrantLock strongly favored over synchronized when using Virtual Threads in Java 21?
Answer:
In Java 21, when a virtual thread encounters contention on a synchronized block or executes Object.wait(), it cannot unmount from its underlying carrier thread (Virtual Thread Pinning). This ties up an OS platform thread in the carrier ForkJoinPool. If multiple virtual threads pin carrier threads simultaneously, the entire carrier pool becomes exhausted, negating the throughput benefits of Project Loom.
Conversely, ReentrantLock utilizes LockSupport.park(). The Loom runtime intercepts LockSupport.park(), serializes the virtual thread’s continuation to the heap, unmounts it, and allows the carrier thread to immediately execute other virtual threads.
🎯 Interview Cheat Sheet
Core Concepts Matrix
| Feature | synchronized | ReentrantLock |
| :— | :— | :— |
| Reentrant | Yes (Built-in) | Yes (Hold count via state) |
| Release Guarantee | Compiler-enforced | Developer-enforced via finally |
| Timeout Support | None | tryLock(time, unit) |
| Interruption | Unresponsive | lockInterruptibly() |
| Fairness | Unfair only | Configurable (Fair/Non-fair) |
| Condition Queues | 1 (wait/notify) | Arbitrary (newCondition()) |
Key Takeaways
- Mandatory Idiom: Always invoke
lock.lock()immediately before thetryblock andlock.unlock()insidefinally. - Performance Tradeoff: Non-fair locking (barging) provides drastically higher throughput by avoiding OS thread descheduling latency. Fair locking should only be used when starvation is a proven issue.
- Targeted Wakeups: Use multiple
Conditionobjects to wake specific consumer or producer threads, eliminating the Thundering Herd Problem. - Loom Compatibility: In Java 21, replace
synchronizedwithReentrantLockaround blocking operations to prevent virtual thread carrier pinning.
Red Flags & Anti-Patterns to Avoid
- ❌ Placing
lock.lock()inside thetryblock: Iflock()fails or throws an exception, thefinallyblock executesunlock()on an unacquired lock, throwingIllegalMonitorStateExceptionand masking the real exception. - ❌ Omitting the
finallyblock: Forgettingunlock()insidefinallyguarantees permanent deadlocks on unexpected runtime exceptions. - ❌ Defaulting to
new ReentrantLock(true)everywhere: Fair mode destroys throughput under contention; use default non-fair locking unless FIFO ordering is an absolute domain requirement. - ❌ Assuming
tryLock()is always fair:tryLock()without arguments barges through the queue regardless of the fairness setting.
Related Topics
- What is the difference between synchronized and volatile
- What is monitor in Java
- How does synchronized work at monitor level
- What is the difference between synchronized method and synchronized block
- What are Atomic classes
- What is CAS (Compare-And-Swap)
- What is deadlock
- What are Virtual Threads in Java 21