> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# ReentrantLock in Java: tryLock, Fairness and Interrupts
- URL: https://www.ggorantala.dev/java-reentrantlock/
- Published: 2026-10-09T12:46:00.000Z
- Updated: 2026-10-30T13:46:14.000Z
- Description: A fair ReentrantLock still lets tryLock() cut the line, and a forgotten unlock() outlives its thread. ReentrantLock explained with a lab, dumps and numbers.
- Author: Gopi Gorantala
- Tags: java-locks, thread-safety, java-threads, java-concurrency

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:

```log
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](https://ggorantala.dev/java-memory-model-happens-before/?ref=ggorantala.dev)). 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](https://ggorantala.dev/java-lock-condition/?ref=ggorantala.dev) 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.

```java
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:

```log
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:

```log
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](https://ggorantala.dev/java-aqs-abstractqueuedsynchronizer/?ref=ggorantala.dev). 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.

```log
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:

```log
"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](https://ggorantala.dev/java-deadlock/?ref=ggorantala.dev) 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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev). 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`:

```java
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](https://ggorantala.dev/how-to-use-synchronized-blocks-and-methods-correctly-in-java/?ref=ggorantala.dev) covers it, and the head-to-head will be in [synchronized vs ReentrantLock](https://ggorantala.dev/synchronized-vs-reentrantlock/?ref=ggorantala.dev).

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](https://ggorantala.dev/java-virtual-thread-pinning-synchronized-jep-491/?ref=ggorantala.dev) 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](https://ggorantala.dev/synchronized-vs-reentrantlock/?ref=ggorantala.dev). 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

- [ReentrantLock Javadoc (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html?ref=ggorantala.dev), the barging sentence and try/finally guidance
- [ReentrantLock.java at jdk-21-ga](https://github.com/openjdk/jdk/blob/jdk-21-ga/src/java.base/share/classes/java/util/concurrent/locks/ReentrantLock.java?ref=ggorantala.dev), read via a summary, not line by line
- [JEP 444: Virtual Threads](https://openjdk.org/jeps/444?ref=ggorantala.dev) and [JEP 491](https://openjdk.org/jeps/491?ref=ggorantala.dev) for the pinning context
- [JLS chapter 17](https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html?ref=ggorantala.dev)
- Stack Overflow Hot page and the Stack Exchange API were blocked from this environment, so no SO thread is cited.