java.util.concurrent.locks.ReentrantLock gives you the same mutual exclusion as synchronized: one thread at a time inside the critical section, and a thread that already holds the lock may take it again without deadlocking itself. What it adds is control. You can try to acquire and give up, wait with a deadline, abandon the wait when interrupted, choose fair ordering, keep several separate wait sets on one lock, and ask the lock who holds it and how many threads are queued.
This article covers the one idiom you cannot get wrong, how the lock works inside (it is a thin layer over AbstractQueuedSynchronizer), when fairness is worth its cost, conditions, a worked example that moves money between accounts without deadlocking, how the lock behaves with virtual threads, failure modes, and how to diagnose contention in production. It assumes JDK 17 or later; differences in JDK 21 and 24 are called out.
The one idiom: unlock in finally
The JVM releases a synchronized monitor automatically when the block exits, normally or by exception. A ReentrantLock is released only when your code calls unlock(), so the release must live in a finally block:
private final ReentrantLock lock = new ReentrantLock();
void update() {
lock.lock(); // acquire OUTSIDE the try
try {
// critical section: read and modify shared state
} finally {
lock.unlock(); // runs on every exit path
}
}Call lock() immediately before the try, not inside it. If acquisition were inside and threw (lockInterruptibly() can throw InterruptedException), the finally would call unlock() on a lock this thread does not hold. That throws IllegalMonitorStateException and hides the original exception. A missing unlock() is worse: the lock stays held forever and every later caller blocks.
Each lock() must be matched by exactly one unlock(). The lock counts holds; a thread that locks three times must unlock three times before anyone else can enter. The count is capped at 2,147,483,647, after which the locking methods throw an Error, a limit you only hit with runaway recursion.
Inside the lock: AbstractQueuedSynchronizer
Almost all of ReentrantLock is an inner class, Sync, that extends AbstractQueuedSynchronizer (AQS), the same framework behind Semaphore, CountDownLatch and ReentrantReadWriteLock. AQS supplies three things: an int state updated with compare-and-set, a record of the owning thread, and a queue of waiting threads that are parked with LockSupport.park so they burn no CPU.
For this lock, state is the hold count. lock() tries to compare-and-set state from 0 to 1. If that succeeds, the calling thread is recorded as owner and the method returns without touching the queue: this uncontended path is a few nanoseconds. If state is non-zero and the owner is the current thread, the count is incremented; that is reentrancy. Otherwise the thread joins the queue and parks. unlock() decrements the count, and when it reaches zero clears the owner and unparks the next queued thread.
Two consequences matter in practice. First, an uncontended ReentrantLock is cheap, and so is an uncontended synchronized block; neither is worth avoiding for speed alone. Second, the lock has memory semantics: everything a thread wrote before unlock() is visible to the next thread after its successful lock(), exactly like entering and leaving a monitor, so fields guarded by the lock do not need to be volatile.
Fair versus non-fair
new ReentrantLock() is non-fair; new ReentrantLock(true) is fair. The difference is one check. A fair lock only attempts the compare-and-set if no other thread is queued ahead of the caller. A non-fair lock lets an arriving thread try immediately, so it can barge past threads already waiting.
Barging sounds unjust but is why non-fair locks are faster under contention. When the owner unlocks, it unparks the head waiter, and waking a parked thread takes microseconds. Meanwhile a thread that is already running can grab the lock, do its short critical section and release it, often before the woken thread is even scheduled. A fair lock forbids that, so every hand-off pays the full wake-up latency and throughput drops, often sharply when critical sections are short.
Use fairness only when you have measured starvation: a thread that waits far longer than its peers, visible as a long tail in wait-time metrics. Two details are easy to miss. The untimed tryLock() ignores fairness and barges even on a fair lock; use tryLock(0, TimeUnit.SECONDS) if you need the fair version. And fairness governs lock acquisition only, not which thread the operating system schedules, so a fair lock is not a guarantee of fair progress.
Timed, polled and interruptible acquisition
These acquisition methods are the main reason to choose this lock over synchronized:
| Method | Blocks? | Responds to interrupt? | Use it for |
|---|---|---|---|
lock() | until acquired | no | ordinary critical sections |
lockInterruptibly() | until acquired | yes, throws | work that shutdown must be able to cancel |
tryLock() | never | n/a | opportunistic work: skip if busy |
tryLock(time, unit) | up to the timeout | yes, throws | latency budgets and deadlock avoidance |
A thread blocked entering a synchronized block cannot be interrupted; it waits until it gets in. That is the difference between a clean shutdown and a hung executor when a lock holder is stuck on slow I/O. The timed form lets a request thread respect its own deadline:
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) {
metrics.lockTimeouts.increment();
throw new ServiceBusyException("cache rebuild in progress");
}
try {
return readThroughCache(key);
} finally {
lock.unlock();
}If you catch InterruptedException from these methods and cannot rethrow it, restore the flag with Thread.currentThread().interrupt() so code further up still sees the cancellation.
Conditions: several wait sets on one lock
A monitor has one wait set, so notify() cannot choose between threads waiting for different things. lock.newCondition() creates as many independent wait sets as you need. await() atomically releases the lock, however many holds the thread has, and parks; before it returns it reacquires the lock with the same hold count. signal() moves one waiter from the condition back to the lock's queue. Both must be called while holding the lock, or they throw IllegalMonitorStateException.
final ReentrantLock lock = new ReentrantLock();
final Condition hasCapacity = lock.newCondition();
int inFlight = 0;
final int limit = 8;
void acquireSlot(long timeoutNanos) throws InterruptedException, TimeoutException {
lock.lockInterruptibly();
try {
while (inFlight == limit) { // while, never if
if (timeoutNanos <= 0) throw new TimeoutException();
timeoutNanos = hasCapacity.awaitNanos(timeoutNanos);
}
inFlight++;
} finally { lock.unlock(); }
}
void releaseSlot() {
lock.lock();
try { inFlight--; hasCapacity.signal(); }
finally { lock.unlock(); }
}Always re-check the predicate in a loop. Waits can return spuriously, and another thread may take the slot between the signal and your reacquisition. awaitNanos returns the time remaining, which makes deadline loops straightforward. Prefer signal() when any one waiter can use the change, and signalAll() when waiters wait for different predicates on the same condition.
Worked example: transfers without deadlock
Moving money between two accounts needs both accounts locked. If thread A transfers from X to Y while thread B transfers from Y to X, and each takes its first lock, both wait forever. Two standard cures exist; both use only the API above.
The first cure is a global lock order: always lock the account with the smaller id first, so a cycle cannot form.
static void transfer(Account from, Account to, long cents) throws InterruptedException {
Account first = from.id < to.id ? from : to;
Account second = first == from ? to : from;
first.lock.lockInterruptibly();
try {
second.lock.lockInterruptibly();
try {
if (from.balance < cents) throw new InsufficientFundsException(from.id);
from.balance -= cents;
to.balance += cents;
} finally { second.lock.unlock(); }
} finally { first.lock.unlock(); }
}The second cure is try-and-back-off, for cases where no natural order exists, such as locks discovered while walking a graph. Take the first lock, try the second, and if it is busy release everything, sleep a random short time and retry within a budget:
static boolean transfer(Account from, Account to, long cents, long budgetNanos)
throws InterruptedException {
long deadline = System.nanoTime() + budgetNanos;
while (System.nanoTime() < deadline) {
if (from.lock.tryLock()) {
try {
if (to.lock.tryLock()) {
try {
if (from.balance < cents) return false;
from.balance -= cents;
to.balance += cents;
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
LockSupport.parkNanos(ThreadLocalRandom.current().nextLong(20_000, 200_000));
if (Thread.interrupted()) throw new InterruptedException();
}
throw new IllegalStateException("could not lock both accounts in time");
}The random back-off matters: without it, two threads can release and retry in lock-step and livelock. Prefer the ordered version when you can; it never spins. Keep the back-off version for when you cannot order, and record how often the budget runs out.
Virtual threads and pinning
On JDK 21, a virtual thread that blocks inside a synchronized block pins its carrier platform thread: the carrier cannot run other virtual threads until the block exits. With a small carrier pool, a few pinned threads waiting on slow I/O could stall the whole application. Blocking in ReentrantLock parks the virtual thread and frees the carrier, so the common advice for JDK 21 was to replace synchronized around blocking calls with ReentrantLock.
JDK 24 (JEP 491) removed that pinning for synchronized in most cases, and JDK 25, the current long-term-support release, includes the change. On those releases, choose between the two on features, not on pinning. On JDK 21 the advice still holds, and -Djdk.tracePinnedThreads=full (removed in JDK 24) or the JFR jdk.VirtualThreadPinned event will show you where pinning happens. See Java virtual threads for the scheduler details.
Failure modes
These are the failures seen most often with explicit locks:
- Leaked lock. A path that returns or throws without
unlock(). The symptom is every thread parked inlock()while the owner is idle or dead. Cause: nofinally, or locking in one method and unlocking in another. - Unbalanced re-entry. A recursive helper locks twice and unlocks once. The lock looks free to the developer but
getHoldCount()is 1. - Lock-order deadlock. Two locks taken in opposite orders on two paths. Use a global order or the back-off pattern; see deadlock in depth.
- Slow work under the lock. Network calls, logging to a blocking appender or callbacks into unknown code while holding the lock turn a microsecond critical section into milliseconds and serialise the service.
- if instead of while around
await(). Produces rare corruption under load after a spurious or stolen wake-up. - Swallowed interrupt. Catching
InterruptedExceptionand continuing makes the thread impossible to cancel.
Diagnosing locks in production
Explicit locks are easy to diagnose if you know where to look. A thread dump taken with jcmd <pid> Thread.print -l or jstack -l <pid> shows threads parked in ReentrantLock with the lock's identity, and under each thread a "Locked ownable synchronizers" list naming the locks it holds. Match the parked threads to the holder and you have the culprit. ThreadMXBean.findDeadlockedThreads() detects cycles that involve these locks as well as monitors; calling it from a periodic health check turns a silent hang into an alert.
The lock also exposes getQueueLength(), hasQueuedThreads(), isLocked() and isHeldByCurrentThread(). The first two are estimates meant for monitoring, not for control flow. Exporting queue length as a gauge, and timing how long lock() takes, gives you a contention graph. Java Flight Recorder's jdk.ThreadPark events show where threads park and for how long. Use isHeldByCurrentThread() in assertions inside helpers that require the caller to hold the lock.
Trade-offs and alternatives
| Need | Choose | Why |
|---|---|---|
| Simple exclusion, no timeouts | synchronized | shorter, cannot leak, equally fast uncontended |
| Timeout, try or interruptible wait | ReentrantLock | monitors cannot give up |
| Several wait conditions | ReentrantLock + conditions | targeted signalling |
| Many readers, rare writers | ReentrantReadWriteLock | readers proceed in parallel |
| Very read-heavy, short reads | StampedLock | optimistic reads with no write to shared state; not reentrant |
| Hand-off between threads | BlockingQueue | the locking is already done for you |
| Single counter or flag | atomics | no lock at all |
Reach for the highest-level tool that fits. BlockingQueue is built on this lock and its conditions; synchronized remains the default for plain exclusion, and Java concurrency in depth maps the rest of the toolbox.
What to do next
Use this list to review explicit locks in your codebase:
- Search for
.lock()and check that every one sits directly before atrywhosefinallyunlocks. - Find every place that holds two locks at once, write down the order, and enforce one global order.
- Replace untimed
lock()on request paths withtryLock(timeout)and a clear busy response. - Use
lockInterruptibly()in any task that shutdown must cancel. - Remove I/O and callbacks from critical sections; copy data out, unlock, then do the slow work.
- Audit fair locks and keep fairness only where you measured starvation.
- On JDK 21, check JFR for pinning; on JDK 24 or later, revisit
synchronizedwhere it is simpler. - Add a scheduled
findDeadlockedThreads()check and a lock-wait-time metric.