Locks

The java.util.concurrent.locks package offers explicit Lock objects like ReentrantLock that go beyond synchronized, adding tryLock, interruptible acquisition, fairness policies, and multiple wait-sets via Condition.

synchronized blocks and methods use Java's built-in intrinsic monitor locks, which are simple and safe but inflexible: acquisition is always blocking, always FIFO-unspecified, and always released only by leaving the synchronized block. java.util.concurrent.locks introduces the Lock interface and its main implementation, ReentrantLock, which provide the same mutual-exclusion guarantee but as an explicit API — letting code attempt a lock without blocking, respond to interruption while waiting, request fairness, and coordinate multiple independent wait conditions on the same lock via the Condition interface.

The Lock Interface

java.util.concurrent.locks.Lock defines the core operations any explicit lock implementation provides: lock(), unlock(), tryLock(), tryLock(long, TimeUnit), lockInterruptibly(), and newCondition(). Unlike synchronized, acquiring and releasing a Lock are separate explicit method calls rather than implicit block entry/exit, which means the programmer is responsible for guaranteeing unlock() always runs.

This is the contract every Lock implementation fulfills; ReentrantLock is the standard general-purpose implementation.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

ReentrantLock: Basic lock()/unlock() Usage

ReentrantLock behaves like an intrinsic lock in the mutual-exclusion sense — reentrant, at most one thread holds it at a time — but must be acquired and released explicitly. The mandatory pattern is to unlock() inside a finally block, so the lock is released even if the protected code throws.

If the try block threw an exception and unlock() were not in a finally block, the lock would remain held forever, deadlocking every other thread that ever tries to acquire it.

import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // MUST be in finally -- unlock() is never automatic
        }
    }
}

tryLock(): Non-Blocking Acquisition

tryLock() attempts to acquire the lock and returns immediately: true if it succeeded, false if the lock was already held by another thread. This lets code do useful work, or back off and retry, instead of blocking indefinitely — something synchronized has no equivalent for.

tryLock() with no arguments never blocks — it returns a boolean immediately regardless of contention.

tryLock(long timeout, TimeUnit unit) is the middle ground: it blocks, but only up to the given timeout, returning false if the lock still couldn't be acquired in that window (and it responds to interruption while waiting, throwing InterruptedException).

if (lock.tryLock()) {
    try {
        // got the lock, proceed
    } finally {
        lock.unlock();
    }
} else {
    // could not acquire immediately -- do something else instead of blocking
    System.out.println("busy, skipping this attempt");
}

Interruptible Locking

lockInterruptibly() blocks waiting for the lock like lock() does, but if another thread interrupts the waiting thread, it throws InterruptedException and gives up waiting for the lock, instead of ignoring the interrupt until the lock is acquired. Plain lock() does not respond to interruption at all while blocked waiting for the lock — the interrupt status is simply set and observed only after the lock is eventually acquired.

This is useful for cancellable operations: a thread stuck waiting a long time for a contended lock can be interrupted out of that wait, rather than being forced to wait until it finally acquires the lock.

void doWork() throws InterruptedException {
    lock.lockInterruptibly();
    try {
        // critical section
    } finally {
        lock.unlock();
    }
}

Fairness

ReentrantLock's constructor accepts an optional boolean fairness flag. By default (false), the lock is non-fair: an available lock may be granted to whichever waiting thread the JVM chooses, which can sometimes favor a thread that just requested it over one that's been waiting longest (barging), but typically gives higher overall throughput. A fair lock (true) grants access in roughly the order threads requested it (FIFO), avoiding starvation but at a measurable throughput cost due to the extra bookkeeping and reduced scheduling flexibility.

synchronized offers no fairness option at all — this is one of the concrete capabilities Lock adds over intrinsic locks.

Fairness in ReentrantLock is a scheduling policy, not a hard real-time guarantee — the JLS and the java.util.concurrent documentation describe it as an approximate ordering guarantee, and exact behavior can still depend on JVM implementation details.

ReentrantLock fairLock = new ReentrantLock(true);   // FIFO-ish ordering, lower throughput
ReentrantLock defaultLock = new ReentrantLock();     // non-fair (default), higher throughput

Condition: Multiple Wait-Sets Per Lock

Every intrinsic monitor has exactly one implicit wait-set, accessed via Object.wait()/notify()/notifyAll(). A Lock can have any number of associated Condition objects, each with its own independent wait-set, created via lock.newCondition(). This lets code distinguish between different reasons a thread might be waiting on the same lock — e.g. a bounded buffer needs to wake waiters differently depending on whether it just became 'not full' versus 'not empty'.

Two separate Condition objects on the same lock let producers and consumers wait for and signal exactly the condition relevant to them, rather than every waiter (regardless of which condition it needs) being woken by a single notifyAll() the way intrinsic monitors require.

Condition.await()/signal()/signalAll() mirror Object.wait()/notify()/notifyAll() in behavior — await() must always be called while holding the lock, and is almost always used inside a while loop re-checking the condition, since a woken thread is not guaranteed the condition still holds (spurious wakeups and multiple waiters are both possible).

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.LinkedList;
import java.util.Queue;

class BoundedBuffer<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    BoundedBuffer(int capacity) { this.capacity = capacity; }

    void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                notFull.await(); // releases lock, waits specifically on the "not full" condition
            }
            queue.add(item);
            notEmpty.signal(); // wake a thread waiting specifically on "not empty"
        } finally {
            lock.unlock();
        }
    }

    T take() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await();
            }
            T item = queue.poll();
            notFull.signal();
            return item;
        } finally {

…

Lock vs. synchronized: When to Use Which

For simple mutual exclusion with no special requirements, synchronized remains the simpler, safer default — reach for Lock/ReentrantLock specifically when tryLock, interruptible waiting, fairness, or multiple conditions are actually needed.

synchronized          Lock / ReentrantLock
Syntax                 implicit (block)     explicit lock()/unlock()
Non-blocking attempt   not available         tryLock()
Timed attempt          not available         tryLock(timeout, unit)
Interruptible wait     not available         lockInterruptibly()
Fairness option        not available         constructor flag
Multiple wait-sets     not available (1)     newCondition() (many)
Risk if misused        low (auto-release)    higher (must unlock in finally)
Typical performance    excellent, JIT-tuned  excellent, comparable in most cases

Common Pitfall: Forgetting unlock() in finally

Because Lock release is never automatic, the single most important rule when using explicit locks is: always call lock() immediately before a try block, and always call unlock() in the corresponding finally block. Forgetting this — or calling lock() inside the try block, so an exception before lock() acquisition still triggers an unmatched unlock() in finally — is a frequent and serious bug.

Practical Usage: When Locks Earn Their Complexity

Reach for ReentrantLock (or its relatives, ReentrantReadWriteLock and StampedLock, covered alongside advanced concurrency topics) when a specific capability synchronized lacks is actually needed: a resource pool that should give up trying to acquire a slot after a timeout (tryLock with timeout); a shutdown path that needs a blocked thread to respond to interruption; a producer/consumer structure that benefits from separate wait conditions; or a fairness requirement to prevent thread starvation under heavy contention.

Key Takeaways

Lock is an explicit interface (lock/unlock/tryLock/lockInterruptibly/newCondition); ReentrantLock is its standard, reentrant implementation.

unlock() is never automatic — always release inside a finally block, with lock() called immediately before the try.

tryLock() attempts acquisition without blocking; tryLock(timeout, unit) blocks only up to a bound.

lockInterruptibly() lets a thread waiting for the lock respond to interruption; plain lock() does not.

ReentrantLock(true) requests approximately fair (FIFO-ish) lock acquisition, at some throughput cost; synchronized offers no fairness option.

A single Lock can back multiple independent Condition wait-sets via newCondition(), unlike an intrinsic monitor's single implicit wait-set.

lock.lock(); count++; lock.unlock(); // if count++ throws, unlock() never runs -- lock leaks forever lock.lock(); try { count++; } finally { lock.unlock(); }

Without a finally block, any exception in the critical section leaves the lock permanently held, deadlocking every other thread waiting to acquire it.

try { lock.lock(); // if this itself throws unexpectedly, finally still tries to unlock // work } finally { lock.unlock(); } lock.lock(); // acquire BEFORE the try block try { // work } finally { lock.unlock(); } lock() must be called before the try block, not inside it — otherwise a failure during acquisition could lead finally to call unlock() on a lock this thread never actually acquired, throwing IllegalMonitorStateException.

condition.await(); // called without holding the lock lock.lock(); try { while (!conditionHolds()) { condition.await(); // must be called while holding the associated lock } } finally { lock.unlock(); }

Condition.await() (like Object.wait()) requires the calling thread to already hold the associated lock, and throws IllegalMonitorStateException otherwise; it should also be checked in a while loop, not an if, to guard against spurious wakeups.

What does the Lock interface provide that synchronized does not?

A non-blocking acquisition attempt (tryLock()), a timed acquisition attempt (tryLock(timeout, unit)), interruptible blocking acquisition (lockInterruptibly()), a fairness policy option, and support for multiple independent wait-sets per lock via newCondition().

Why must Lock.unlock() always be called inside a finally block?

Unlike synchronized, which releases the intrinsic lock automatically on exiting the block (normally or via exception), a Lock is never released automatically — if unlock() is not guaranteed to run (e.g. via finally) and the critical section throws, the lock stays held forever, deadlocking any other thread waiting to acquire it.

What is the difference in behavior between lock() and lockInterruptibly() when a thread is blocked waiting for a contended lock and gets interrupted?

lock() ignores the interrupt while waiting — it keeps waiting for the lock and only the thread's interrupt status is set, observable once it eventually acquires the lock. lockInterruptibly() responds immediately: it throws InterruptedException and gives up trying to acquire the lock, letting the thread react to cancellation without first having to wait for the lock.

What does the fairness flag on ReentrantLock's constructor control, and what is the tradeoff?

It controls whether the lock grants access approximately in FIFO order of requesting threads (true, 'fair') versus allowing any waiting or newly-arriving thread to acquire it (false, 'non-fair', the default). Fair locks reduce the risk of thread starvation but typically have lower throughput due to the extra bookkeeping and reduced scheduling flexibility.

How many wait-sets does an intrinsic monitor (synchronized/Object.wait()) support, versus how many a single ReentrantLock can support via Condition?

An intrinsic monitor has exactly one implicit wait-set. A single ReentrantLock can back any number of independent Condition objects, each with its own wait-set, created via lock.newCondition().

In a producer/consumer bounded buffer implemented with ReentrantLock and two Condition objects (notFull, notEmpty), why is a while loop used around await() instead of an if statement?

await() can return due to a spurious wakeup, or after being signaled by a call that only partially satisfies the condition (e.g. multiple consumers were signaled but only one item became available), so the actual condition must be re-checked after waking up; an if statement would proceed based on a possibly-stale assumption, while a while loop safely re-evaluates and waits again if the condition still doesn't hold.

Write a method that attempts to acquire a ReentrantLock for up to 500 milliseconds, doing fallback work if it can't get the lock in time.

import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.TimeUnit; class Resource { private final ReentrantLock lock = new ReentrantLock(); void accessWithTimeout() throws InterruptedException { if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { try { System.out.println("acquired lock, doing critical work"); } finally { lock.unlock(); } } else { System.out.println("timed out waiting for lock, doing fallback work instead"); } } } tryLock(500, TimeUnit.MILLISECONDS) blocks for at most half a second trying to acquire the lock; if it succeeds it returns true and the caller must remember to unlock() in finally, and if the timeout elapses first it returns false, letting the caller take a fallback path instead of blocking indefinitely as plain lock() would.

What do the three printed lines show, and why?

getHoldCount() prints 2 (ReentrantLock is reentrant, so the same thread acquiring it twice increments an internal hold count to 2). After the first unlock(), isLocked() still prints true, because the hold count only dropped to 1 -- the lock is only fully released once unlock() has been called the same number of times lock() was called. After the second unlock(), the hold count reaches 0 and isLocked() prints false.

Write a simple producer/consumer pair using ReentrantLock and a single Condition, where the consumer waits until a shared flag becomes true.

import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; class SignalBox { private final ReentrantLock lock = new ReentrantLock(); private final Condition signaled = lock.newCondition(); private boolean flag = false; void producer() { lock.lock(); try { flag = true; signaled.signal(); // wake a waiting consumer, if any } finally { lock.unlock(); } } void consumer() throws InterruptedException { lock.lock(); try { while (!flag) { signaled.await(); // releases lock while waiting, re-acquires before returning } System.out.println("consumer proceeding, flag is set"); } finally { lock.unlock(); } } } consumer() blocks on signaled.await() (which atomically releases the lock while waiting) until producer() sets flag and calls signaled.signal(); the while loop guards against spurious wakeups by re-checking flag after every wakeup before proceeding.

ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock(); // called again by the same thread
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.isLocked());
lock.unlock();
System.out.println(lock.isLocked());