Skip to content
Java synchronization 7 min read

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 a finally block. If you throw an exception between lock() and unlock() without finally, 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 of Object.wait(), and Condition.signal() / signalAll() are the equivalents of notify() / 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

FeaturesynchronizedReentrantLock
SyntaxBuilt-in keywordExplicit lock()/unlock()
ReentrancyYesYes
Fairness optionNo (always unfair)Yes (new ReentrantLock(true))
tryLock / timeoutNoYes
Interruptible waitNoYes (lockInterruptibly())
Multiple wait setsNo (one per object)Yes (multiple Conditions)
PerformanceExcellent (JVM optimizes)Slightly higher overhead
Deadlock debuggingHardgetQueuedThreads(), isLocked()

Tip: If synchronized meets your needs, prefer it — the JVM applies heavy optimizations (lock elision, biased locking, stack-lock coalescing) that ReentrantLock cannot benefit from. Reach for ReentrantLock when 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 Node objects, each representing a parked thread.

When a thread calls lock():

  1. AQS attempts a CAS (compare-and-swap) on the state field (0 → 1).
  2. If the CAS succeeds, the current thread becomes the owner.
  3. If the CAS fails (lock is held), the thread is wrapped in a Node and appended to the CLH queue, then parked via LockSupport.park() (which calls UNSAFE.park() internally — a thin wrapper around pthread_mutex).

When unlock() is called:

  1. The hold count is decremented.
  2. 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.

Last updated June 13, 2026
Was this helpful?