🧵 Section 9 · Question #7

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:

  1. Conceptual Property: Built-in Java synchronization constructs—both the intrinsic synchronized monitor and explicit lock implementations—are reentrant by design.
  2. Standard Library Class java.util.concurrent.locks.ReentrantLock: An explicit lock implementation built on AbstractQueuedSynchronizer (AQS) that provides capabilities beyond synchronized, 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 a finally block. If an unhandled exception occurs inside the critical section without a finally block, 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. In ReentrantLock, state == 0 signifies an unlocked state. Any value state >= 1 represents 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 Node objects 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:

  1. Calls tryAcquire(1). If the current thread is already the owner, state increments recursively (state + 1).
  2. If owned by another thread, an exclusive Node is created and atomically enqueued at the tail (enq()).
  3. The thread enters an adaptive spin loop in acquireQueued, evaluating whether its node has become the queue head.
  4. If still blocked, the predecessor node’s status is updated to Node.SIGNAL (“wake me up when you finish”).
  5. 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 synchronized block pins the virtual thread to its OS carrier thread, preventing the carrier thread from executing other virtual threads. ReentrantLock uses LockSupport.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: If unlock() is invoked when the hold count is already 0, or if called by a thread that does not currently own the lock, an IllegalMonitorStateException is thrown immediately.
  • Fewer unlock() calls: Each call to unlock() decrements state. If unlock() is called fewer times than lock(), state remains 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 the try block and lock.unlock() inside finally.
  • 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 Condition objects to wake specific consumer or producer threads, eliminating the Thundering Herd Problem.
  • Loom Compatibility: In Java 21, replace synchronized with ReentrantLock around blocking operations to prevent virtual thread carrier pinning.

Red Flags & Anti-Patterns to Avoid

  • ❌ Placing lock.lock() inside the try block: If lock() fails or throws an exception, the finally block executes unlock() on an unacquired lock, throwing IllegalMonitorStateException and masking the real exception.
  • ❌ Omitting the finally block: Forgetting unlock() inside finally guarantees 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.