Skip to content

ReentrantLock in Java: tryLock, Fairness and Interrupts

A fair ReentrantLock still lets tryLock() cut the line, and a forgotten unlock() outlives its thread. ReentrantLock explained with a lab, dumps and numbers.

java-locks thread-safety java-threads java-concurrency
Gopi Gorantala
Reading Progress

On This Page

A fair ReentrantLock doesn't make tryLock() fair. Pass true to the constructor, queue a thread behind the lock, and a thread calling plain tryLock() will walk straight past it, on purpose, and the Javadoc says so. I'd read that sentence before. I hadn't believed it.

That's the small surprise. The bigger one is what ReentrantLock costs and what it leaks, which is why this is longer than "it's synchronized with extra methods." Everything here was run on OpenJDK 21.0.12.1, 2 vCPUs, x86_64. Numbers are from that box, not from yours.

A fair lock that let me cut in line

Here's the output of the experiment that started it. A fair lock, a thread parked in lock() behind me, and I unlock and immediately ask for the lock again, 3,000 rounds each:

fair lock, a thread is queued, I just unlocked: tryLock() won 2764 of 3000 rounds
fair lock, a thread is queued, I just unlocked: tryLock(0, ms) won 725 of 3000 rounds

Three runs gave 2764, 2781 and 2846 for the untimed call. The timed call with a zero timeout gave 725, 816 and 802. Same lock, same queue, wildly different answers.

I went in believing new ReentrantLock(true) meant every acquisition goes in arrival order. It means that for lock(), lockInterruptibly() and the timed tryLock. Not for the no-argument tryLock().

What the lock promises, and what it doesn't

ReentrantLock is a mutual-exclusion lock with the same visibility guarantee as synchronized: everything a thread did before unlock() is visible to the next thread that gets the lock (the JLS monitor rule, same family as in the memory model piece). It's reentrant, so a thread that holds it can take it again, and it must unlock the same number of times.

What you get that synchronized can't give you: a non-blocking attempt (tryLock()), a bounded wait (tryLock(time, unit)), a wait you can interrupt (lockInterruptibly()), an optional fairness setting, several Conditions per lock, and methods that tell you who's waiting.

What you pay: the release is your job. There's no block that ends and lets go. A synchronized block can't leak a monitor; a ReentrantLock can leak forever. I'll show you.

Reproduce each surprise in about ten minutes

You need JDK 21 (I used 21.0.12.1; sdk install java 21.0.12-tem gets you a close one, check sdk list java) and the file below. It runs as java Lab.java <mode> too, but I compile it, because the fairness modes run two seconds each.

import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;

public class Lab {
    public static void main(String[] a) throws Exception {
        switch (a[0]) {
            case "fairness" -> fairness(Boolean.parseBoolean(a[1]), Integer.parseInt(a[2]));
            case "barge" -> barge();
            case "holds" -> holds();
            case "leak" -> leak();
            case "vt" -> vt();
            case "timed" -> timed();
        }
    }

    // N threads hammer one lock for 2 seconds; report total and spread per thread
    static void fairness(boolean fair, int n) throws Exception {
        ReentrantLock lock = new ReentrantLock(fair);
        long[] ops = new long[n];
        long[] shared = {0};
        CountDownLatch gate = new CountDownLatch(1);
        AtomicBoolean stop = new AtomicBoolean();
        Thread[] ts = new Thread[n];
        for (int i = 0; i < n; i++) {
            final int id = i;
            ts[i] = new Thread(() -> {
                try { gate.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
                long c = 0;
                while (!stop.get()) {
                    lock.lock();
                    try { shared[0]++; } finally { lock.unlock(); }
                    c++;
                }
                ops[id] = c;
            }, "w-" + i);
            ts[i].start();
        }
        gate.countDown();
        Thread.sleep(2000);
        stop.set(true);
        for (Thread t : ts) t.join();
        long min = Long.MAX_VALUE, max = 0, sum = 0;
        for (long o : ops) { min = Math.min(min, o); max = Math.max(max, o); sum += o; }
        System.out.printf("fair=%b threads=%d total=%,d min=%,d max=%,d max/min=%.1f%n", fair, n, sum, min, max, (double) max / Math.max(1, min));
    }

    // fair lock: one thread uses lock(), the other spins on tryLock(). Who gets the lock?
    static void barge() throws Exception {
        for (String mode : new String[]{"lock-vs-lock", "lock-vs-tryLock"}) {
            ReentrantLock lock = new ReentrantLock(true);
            long[] c = new long[2];
            AtomicBoolean stop = new AtomicBoolean();
            CountDownLatch gate = new CountDownLatch(1);
            Thread a = new Thread(() -> {
                try { gate.await(); } catch (InterruptedException e) { return; }
                while (!stop.get()) { lock.lock(); try { c[0]++; spin(); } finally { lock.unlock(); } }
            });
            Thread b = new Thread(() -> {
                try { gate.await(); } catch (InterruptedException e) { return; }
                while (!stop.get()) {
                    if (mode.equals("lock-vs-lock")) { lock.lock(); }
                    else { while (!lock.tryLock()) { Thread.onSpinWait(); if (stop.get()) return; } }
                    try { c[1]++; spin(); } finally { lock.unlock(); }
                }
            });
            a.start(); b.start(); gate.countDown();
            Thread.sleep(2000); stop.set(true); a.join(); b.join();
            System.out.printf("fair lock, %-16s lock()-thread=%,d  second thread=%,d%n", mode, c[0], c[1]);
        }
    }
    static volatile long sink;
    static void spin() { for (int i = 0; i < 50; i++) sink += i; }

    static void holds() throws Exception {
        ReentrantLock lock = new ReentrantLock();
        lock.lock(); lock.lock();
        System.out.println("after 2x lock: holdCount=" + lock.getHoldCount() + " locked=" + lock.isLocked());
        lock.unlock();
        System.out.println("after 1x unlock: holdCount=" + lock.getHoldCount() + " locked=" + lock.isLocked() + "  <- still held");
        lock.unlock();
        try { lock.unlock(); } catch (IllegalMonitorStateException e) { System.out.println("3rd unlock: " + e); }
        lock.lock();
        Thread t = new Thread(() -> {
            try { lock.unlock(); } catch (IllegalMonitorStateException e) { System.out.println("unlock from other thread: " + e); }
        });
        t.start(); t.join();
        lock.unlock();
        Object mon = new Object();
        try { mon.notify(); } catch (IllegalMonitorStateException e) { System.out.println("for comparison, notify w/o monitor: " + e); }
    }

    // forgot unlock: owner thread ends, lock stays held
    static void leak() throws Exception {
        ReentrantLock lock = new ReentrantLock();
        Thread owner = new Thread(() -> {
            lock.lock();
            if (true) throw new IllegalStateException("boom before unlock"); // no try/finally
            lock.unlock();
        }, "leaker");
        owner.setUncaughtExceptionHandler((t, e) -> System.out.println("leaker died: " + e.getMessage()));
        owner.start(); owner.join();
        System.out.println("owner alive=" + owner.isAlive() + " state=" + owner.getState() + " isLocked=" + lock.isLocked() + " holdCount(main)=" + lock.getHoldCount());
        boolean got = lock.tryLock(500, TimeUnit.MILLISECONDS);
        System.out.println("tryLock(500ms) from main: " + got);
        Thread waiter = new Thread(() -> lock.lock(), "waiter-1");
        waiter.start();
        Thread.sleep(300);
        System.out.println("waiter state=" + waiter.getState() + " queueLength=" + lock.getQueueLength());
        System.out.println("pid=" + ProcessHandle.current().pid());
        if (a_hold()) Thread.sleep(60_000);
        System.exit(0);
    }
    static boolean a_hold() { return System.getProperty("hold") != null; }

    static void timed() throws Exception {
        for (String kind : new String[]{"tryLock()", "tryLock(0, ms)"}) {
            int won = 0, rounds = 3000;
            ReentrantLock lock = new ReentrantLock(true);
            for (int r = 0; r < rounds; r++) {
                lock.lock();
                Thread b = new Thread(() -> { lock.lock(); lock.unlock(); });
                b.start();
                while (!lock.hasQueuedThreads()) Thread.onSpinWait();
                lock.unlock();                       // B is queued and about to be woken
                boolean got = kind.equals("tryLock()") ? lock.tryLock() : lock.tryLock(0, TimeUnit.MILLISECONDS);
                if (got) { won++; lock.unlock(); }
                b.join();
            }
            System.out.printf("fair lock, a thread is queued, I just unlocked: %s won %d of %d rounds%n", kind, won, rounds);
        }
    }

    static void vt() throws Exception {
        ReentrantLock lock = new ReentrantLock();
        long t0 = System.nanoTime();
        try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 4; i++) {
                ex.submit(() -> {
                    lock.lock();
                    try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); }
                    return null;
                });
            }
        }
        System.out.printf("4 virtual threads, 1 carrier, ReentrantLock + sleep inside: %d ms (serial lock = ~800)%n", (System.nanoTime() - t0) / 1_000_000);
        // blocked waiters do not pin: also run 100 sleepers beside
        long t1 = System.nanoTime();
        try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
            ex.submit(() -> { lock.lock(); try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } return null; });
            Thread.sleep(50);
            for (int i = 0; i < 100; i++) ex.submit(() -> { Thread.sleep(100); return null; });
            for (int i = 0; i < 20; i++) ex.submit(() -> { lock.lock(); lock.unlock(); return null; });
        }
        System.out.printf("holder sleeps 500ms, 20 virtual threads parked on the lock, 100 sleepers still finish: total %d ms%n", (System.nanoTime() - t1) / 1_000_000);
    }
}

Steps, each a literal command:

Step 1 — throughput, fair vs non-fair
$ javac Lab.java
$ java Lab fairness false 8 ; java Lab fairness true 8
Expected: non-fair does tens of millions of lock/unlock pairs in 2 s,
fair does a hundred thousand or so. Mine: 93,937,464 vs 116,373.

Step 2 — the barging count
$ java Lab timed
Expected: untimed tryLock() wins most rounds (mine: 92 to 95%), tryLock(0, ms) a minority.

Step 3 — hold counts and bad unlocks
$ java Lab holds

Step 4 — the leaked lock
$ java -Dhold=1 Lab leak &
$ jcmd <pid> Thread.print
$ kill <pid>

Forcing levers: the fairness lab uses a start gate (CountDownLatch) so all eight threads hit the lock together, and 8 threads on 2 vCPUs, which is the kind of oversubscription that makes the cost visible. The timed lab spins on hasQueuedThreads() so the queued thread is definitely parked before I unlock. On a machine with many cores the fair/non-fair ratio will differ; I haven't measured that.

How you know you saw it: for step 2, the untimed count is large and the timed count is clearly smaller. For step 4, isLocked=true while the owner thread is TERMINATED.

Output from my runs of steps 3 and 4:

after 2x lock: holdCount=2 locked=true
after 1x unlock: holdCount=1 locked=true  <- still held
3rd unlock: java.lang.IllegalMonitorStateException
unlock from other thread: java.lang.IllegalMonitorStateException
leaker died: boom before unlock
owner alive=false state=TERMINATED isLocked=true holdCount(main)=0
tryLock(500ms) from main: false
waiter state=WAITING queueLength=1

If you see nothing odd in step 2 on a single-core container, that's the scheduler, not you: with one CPU the queued thread often can't run between your unlock and your next call either way. Cleanup: jps, kill any Lab.

My own dead end is worth admitting. My first version of the timed lab freed the lock and then started a third thread to call tryLock(). It lost every time, five runs out of five, and I wrote "timed and untimed both honor the queue" in my notes. What I'd measured was thread start-up: by the time the new thread was running, the queued thread had long since been woken and taken the lock. Doing the unlock and the tryLock on the same thread took the scheduler out of the picture.

Hold counts, queues and who gets woken

Take the lock twice, unlock once, and isLocked() is still true with getHoldCount() at 1. Every lock() needs its own unlock(). synchronized does this counting for you because the block structure does; here it's on you, and a recursive call path that locks once more than it unlocks is a leak that only shows when two threads meet.

Unlock more than you locked, or from a thread that doesn't own it, and you get IllegalMonitorStateException. The same exception notify() throws without the monitor, with a less helpful message (the lock version printed no text at all in my run).

Now the queue. Waiting threads sit in a FIFO queue managed by AbstractQueuedSynchronizer, which I'll take apart in its own piece. When the owner unlocks, the thread at the head of the queue is woken. It then competes for the lock. On a non-fair lock a thread that arrives at that moment can get there first; that's barging. On a fair lock the new arrival checks the queue first and stands behind it.

t0   A: unlock()            state 0, B unparked (not running yet)
t1   A: tryLock()           CAS 0->1 succeeds, no queue check  -> A owns it
t2   B: wakes up, CAS fails -> parks again

That is the sequence I get 92 to 95% of the time. The wake-up of B takes microseconds. A's next call takes nanoseconds.

Reading ReentrantLock.java on 21

I read the class source at the jdk-21-ga tag (through a fetch-and-summarise, not line by line, so treat details as "the summary says" and check the file). Three things.

First, the public tryLock() goes through a method that does a compare-and-set on the state from 0 to 1 with no queue check, for fair and non-fair locks alike. The Javadoc says it out loud: the untimed tryLock() succeeds if the lock is free "whether or not other threads are currently waiting." If you want fairness you have to use the timed one, tryLock(0, TimeUnit.SECONDS), which honors it. That's the 725-vs-2764 gap above. The 25% the timed call still wins is, I think, rounds where B ran to completion before my call; I didn't instrument that, so take it as a guess.

Second, lock() on a non-fair lock tries one unguarded CAS before it ever looks at the queue. That one CAS is the whole barging window.

Third, tryRelease throws IllegalMonitorStateException before it changes any state, if the caller isn't the owner. Your wrong unlock doesn't corrupt the lock; it fails.

A note on versions, since I can't give you more than this: ReentrantLock and the fair/non-fair choice date from Java 5 (JSR 166). The internals were reorganised in later releases (the stack above shows AbstractQueuedSynchronizer.acquire called from ReentrantLock$Sync.lock on 21), but I didn't diff 17 against 21 and won't claim when. The public behaviour I've described is what the 21 Javadoc says. I didn't run any of this on 22 or later, or on ARM, where timing between wake-up and retry could differ.

It took me the better part of two days to separate "fair means ordered" from what the code does, mostly because every blog I found stated the fair/non-fair difference in one line and none showed a number or a dump.

Ways locks leak, lie and stall in production

The forgotten unlock. Any code between lock() and unlock() that can throw, without a finally, leaves the lock held by a thread that may no longer exist. My leak mode does exactly that and the aftermath is the part that scares me:

"waiter-1" #19 [18715] prio=5 os_prio=0 cpu=0.11ms elapsed=4.07s tid=0x... nid=18715 waiting on condition
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method)
	- parking to wait for  <0x00000000a851d688> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
	at java.util.concurrent.locks.LockSupport.park(java.base@21.0.12.1/LockSupport.java:221)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(...)
	at java.util.concurrent.locks.ReentrantLock.lock(java.base@21.0.12.1/ReentrantLock.java:322)

Look for the owner. There isn't one. The thread that holds the lock is TERMINATED, so no stack in the dump names 0x...d688 as locked. You see waiters and an address, and no culprit. The deadlock piece shows how to detect cycles with ThreadMXBean; a lock held by a corpse isn't a cycle, so the bean reports nothing. That's what I'd look for: WAITING (parking) on a ReentrantLock$...Sync address that nobody else holds.

The lock() inside the try. If you write try { lock.lock(); ... } finally { lock.unlock(); } and lock() itself throws (an Error on hold-count overflow, rare), finally unlocks a lock you never took and throws IllegalMonitorStateException over the original problem. Put lock() before the try.

tryLock() as a fairness bypass. If you opted into a fair lock to stop one thread from monopolising it, one caller using tryLock() in a retry loop defeats that. In my barge mode, a fair lock with a lock() thread and a tryLock() spinner completed 4.29M and 2.97M acquisitions across two runs, against 0.40M and 0.21M when both used lock(). The split between the two threads flipped direction between runs (3.3M vs 1.0M, then 0.5M vs 2.4M), so I won't tell you who wins. I can tell you the bypass is worth roughly 10x in throughput and nothing you wrote in the constructor stops it.

Fair, and slow. On 8 threads fair did 116,373 operations in 2 seconds and non-fair did 93.9 million. On 2 threads, 3.1M against 36.0M. That's about 800x and 12x on this box. I didn't profile why. My working explanation is that every fair handoff has to wake a parked thread before anyone makes progress, while barging lets the running thread take the lock again without a context switch; treat that as a hypothesis.

What I did not see: starvation under the non-fair lock. The slowest of the 8 threads got within 1.2x of the fastest. The textbook warning is real on some hardware and some workloads, and I couldn't make it happen here. If your fairness worry is "one thread never gets in," measure per-thread counts before you pay 800x.

Interrupts. lock() doesn't respond to interrupt() and doesn't clear the flag either; lockInterruptibly() and the timed tryLock throw InterruptedException. The full story, including a stuck shutdown, is in the interruption article. I won't redo it.

What to write instead, and what it costs

Why it works: if the unlock sits in finally, no path out of the critical section can skip it, and if the lock attempt is bounded, no caller waits longer than its budget.

The shape I use, here as a small token bucket for a rate limiter. It compiles and runs on 21; it printed granted 3 of 5:

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

final class TokenBucket {
    private final ReentrantLock lock = new ReentrantLock(); // non-fair on purpose
    private final double capacity;
    private final double perNano;
    private double tokens;
    private long last = System.nanoTime();

    TokenBucket(double capacity, double perSecond) {
        this.capacity = capacity;
        this.perNano = perSecond / 1_000_000_000.0;
        this.tokens = capacity;
    }

    /** Waits up to maxWait for the lock, then takes a token if there is one. */
    boolean tryAcquire(long maxWait, TimeUnit unit) throws InterruptedException {
        if (!lock.tryLock(maxWait, unit)) {
            return false;               // couldn't even get the lock in time: shed load
        }
        try {                           // try starts right after the lock is held
            long now = System.nanoTime();
            tokens = Math.min(capacity, tokens + (now - last) * perNano);
            last = now;
            if (tokens >= 1) { tokens -= 1; return true; }
            return false;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] a) throws InterruptedException {
        TokenBucket b = new TokenBucket(3, 1);
        int ok = 0;
        for (int i = 0; i < 5; i++) if (b.tryAcquire(10, TimeUnit.MILLISECONDS)) ok++;
        System.out.println("granted " + ok + " of 5");
    }
}

Choices and what they cost:

Option Since You get You pay
lock() + finally Java 5 simple, uninterruptible can't give up; hung thread stays hung
lockInterruptibly() Java 5 cancellable wait handle InterruptedException everywhere
tryLock() Java 5 never blocks; ignores queue (even fair) retry logic; barges past waiters
tryLock(time, unit) Java 5 bounded wait, honors fairness slower than untimed; need a failure path
new ReentrantLock(true) Java 5 roughly arrival order for lock() 12x to 800x throughput in my runs

My default is non-fair, lock() in a try/finally, and a timed tryLock anywhere a caller is waiting on a request with a deadline. If synchronized does the job (one monitor, block-structured), use it; the synchronized piece covers it, and the head-to-head will be in synchronized vs ReentrantLock.

One thing that isn't in the usual comparisons: on JDK 21 ReentrantLock and virtual threads get along. Four virtual threads on a single carrier, each holding the lock while sleeping 200 ms, finished in 828 ms (serial, as expected). With one virtual thread holding the lock for 500 ms, 20 virtual threads waiting on it and 100 more sleeping 100 ms each, everything finished in 502 ms. Waiting on a ReentrantLock parks the virtual thread rather than pinning the carrier. For synchronized on 21 that wasn't true; JEP 491 changed it in a later release (I didn't run that release).

Seeing a leaked lock in a dump, and what to retest on upgrade

What to look for: threads WAITING (parking) with parking to wait for <0x...> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) (or FairSync), the same address on several threads, and no thread listing that address as held. Take three dumps a few seconds apart; if the waiters don't change, nobody is releasing. In code, lock.getQueueLength(), lock.isLocked() and lock.isHeldByCurrentThread() are cheap diagnostics, but they're racy snapshots, so log them rather than branching on them.

Metrics: JFR's jdk.JavaMonitorEnter covers synchronized only; for ReentrantLock waits look at jdk.ThreadPark events with a long duration and a parkedClass of the lock's sync class. I haven't recorded either this run, so check the event names in your JFR build.

Tests: a stress test with a start gate that throws an exception from inside the critical section on purpose (a fault-injection hook) and then asserts !lock.isLocked(). That's the test that would have caught every leak I've seen. Static analysis rules that flag lock() without a matching finally also catch most.

On upgrade: the contract (barging, tryLock behaviour, Java 5 semantics) is stable in the Javadoc; what I'd retest when moving LTS to LTS is anything throughput-related, because the implementation moved around under the same API. I verified only JDK 21.

Worth keeping

  • new ReentrantLock(true) orders lock() callers. The untimed tryLock() ignores the queue, so one spinner undoes your fairness.
  • Fair cost me 12x on 2 threads and ~800x on 8 here. Measure before choosing it; I couldn't even show starvation without it.
  • lock() goes right before try, unlock() goes in finally. A leaked lock outlives its thread, and the dump has waiters but no owner.
  • Hold counts are yours to balance. One extra lock() is a permanent lock.
  • Waiting on a ReentrantLock doesn't pin a virtual thread on 21.

Questions people ask about ReentrantLock

Is ReentrantLock faster than synchronized? Not by a rule I can give you. I didn't benchmark them against each other with JMH here; the head-to-head belongs in synchronized vs ReentrantLock. Pick on features, then measure.

What does "reentrant" mean in practice? The thread that owns the lock can acquire it again without blocking, and the hold count goes up. You release it as many times as you took it, and getHoldCount() tells you where you are.

Does tryLock() respect fairness? The no-argument version doesn't. It takes a free lock even when threads are queued. The timed tryLock(time, unit) does honor the queue; that's what the lab shows (about 92% vs about 26% wins on a fair lock).

What happens if I forget unlock()? The lock stays held, even after the owning thread dies. Other threads wait forever on a ReentrantLock$...Sync and no stack in the dump points at an owner.

Sources

java-locksthread-safetyjava-threadsjava-concurrency

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments