ReentrantLock is the explicit, feature-rich alternative to the synchronized keyword. It provides the same mutual exclusion and reentrancy — a thread already holding the lock can acquire it again — but adds capabilities the intrinsic monitor cannot express: acquiring with a timeout, being interrupted while waiting, polling without blocking, optional fairness, and multiple wait sets per lock. The cost is that you manage lock and unlock by hand, which means one non-negotiable idiom.

The unlock-in-finally idiom

With synchronized the JVM releases the monitor automatically when the block exits, even on exception. ReentrantLock does not — you must release it yourself, and the only correct place is a finally:

private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // critical section
} finally {
    lock.unlock();   // ALWAYS, even if the body throws
}

Put the lock() call immediately before the try, never inside it — if acquisition itself threw and you were already in the try, the finally would call unlock() on a lock you never held, throwing IllegalMonitorStateException and masking the original error. Forgetting unlock() leaks the lock permanently and deadlocks every future acquirer.

Advertisement

tryLock: timeouts and deadlock avoidance

The headline feature. tryLock() returns immediately with a boolean; tryLock(timeout) waits only so long. This lets you write lock acquisition that gives up rather than blocking forever — the basis for deadlock avoidance:

if (lock.tryLock(2, TimeUnit.SECONDS)) {
    try { doWork(); }
    finally { lock.unlock(); }
} else {
    // could not get the lock in time -- back off, retry, or fail fast
}

When a thread must hold two locks, acquiring both with tryLock and releasing everything if the second fails breaks the circular-wait condition that deadlock requires. synchronized gives you no such escape hatch — once it starts waiting for a monitor it waits unconditionally.

Interruptible acquisition

lockInterruptibly() lets a blocked thread respond to interrupt() while waiting for the lock — it throws InterruptedException instead of waiting on. A thread stuck acquiring an intrinsic synchronized monitor cannot be interrupted at all; it waits until it gets in. For work that must remain cancellable — a task that a shutdown or timeout should be able to abort — interruptible acquisition is the difference between a clean stop and a hung thread.

Fairness, and why it usually costs more than it is worth

Constructed with new ReentrantLock(true), the lock becomes fair: threads acquire in FIFO arrival order. The default (false) is barging — a thread may jump the queue if the lock happens to be free at the instant it asks.

Fairness prevents starvation but throttles throughput badly: it forbids the barging that lets a just-freed lock be handed straight to a running thread, forcing an expensive context switch to the head of the queue each time. Unless you have measured a real starvation problem, keep the default unfair lock — it is substantially faster under contention, and starvation is rare in practice.

Advertisement

Condition variables: multiple wait sets

A single synchronized monitor has one wait set — wait()/notify() cannot distinguish 'waiting for not-full' from 'waiting for not-empty'. ReentrantLock can create multiple Condition objects, each an independent wait set, so you signal exactly the right waiters. This is how a bounded buffer is written correctly:

final Condition notFull  = lock.newCondition();
final Condition notEmpty = lock.newCondition();

// producer
lock.lock();
try {
    while (count == capacity) notFull.await();  // wait, releasing the lock
    enqueue(item); count++;
    notEmpty.signal();                          // wake ONE consumer
} finally { lock.unlock(); }

Always wait in a while loop, never an if: a signalled thread must re-check its condition because of spurious wakeups and because another thread may have changed state between the signal and reacquisition. signalAll() wakes every waiter on that condition; signal() wakes one.

ReentrantLock vs synchronized: when to pick which

synchronized is simpler, auto-releasing, and since modern JVMs performs comparably for uncontended locks — it should remain your default. Reach for ReentrantLock only when you need one of its specific powers:

You needOnly ReentrantLock has it
Acquire with a timeout / polltryLock
Cancel a thread blocked on acquisitionlockInterruptibly
Multiple distinct wait setsmultiple Conditions
FIFO fairness guaranteefair mode

If you need none of these, synchronized is less code and impossible to leak. Note that synchronized historically pinned virtual threads (JDK 21); much of that was addressed in later releases, but under heavy blocking inside locks it is worth checking your JDK version's behaviour.

ReentrantLock buys you timeouts (tryLock), interruptible acquisition, fairness, and multiple Condition wait sets — at the price of manual unlock() that MUST live in a finally with lock() just before the try. If you need none of those powers, prefer synchronized: it auto-releases and cannot be leaked. Keep locks unfair unless you have measured starvation.