Locks and conditions
ReentrantLock, tryLock with timeout, fairness, ReadWriteLock, StampedLock, and Condition for the producer-consumer you would otherwise get wrong.
synchronized has no timeout, cannot be interrupted while waiting, cannot be tried without blocking, and cannot separate readers from writers. java.util.concurrent.locks fixes each of those with explicit lock objects — at the price of having to release them yourself. This lesson is the four locks worth knowing, the one class underneath all of them, and the Condition that makes the producer–consumer problem correct.
ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
void transfer(Account to, long amount) {
lock.lock();
try {
balance -= amount;
to.credit(amount);
} finally {
lock.unlock(); // ALWAYS in finally; nothing else releases it
}
}Same semantics as a monitor — mutual exclusion, reentrancy, happens-before on unlock/lock — plus:
tryLock()returns immediately withfalseif the lock is held.tryLock(1, SECONDS)waits up to a bound. A thread that cannot get the lock in time can back off, log, or fail — instead of blocking forever inside a request.lockInterruptibly()throwsInterruptedExceptionif the thread is interrupted while waiting. A cancelled task stops waiting.- Fairness:
new ReentrantLock(true)grants the lock in FIFO order. Slower; use only when starvation is a real problem. - Introspection:
isLocked,getQueueLength,hasQueuedThreads— useful in diagnostics. - Virtual threads unmount while blocked on a
ReentrantLock; before Java 24 they pin onsynchronized.
The cost is the try/finally. Forget it once, throw inside the block, and the lock is held forever by a thread that has moved on. Every subsequent caller blocks. Use synchronized when you need none of the extras; use ReentrantLock when you do, and never without the finally.
Under the hood: AbstractQueuedSynchronizer
ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch and FutureTask are all thin classes over one framework, AQS. Knowing it once explains all of them:
- State is one
volatile int. ForReentrantLockit is the hold count (0 = free; the owner thread is a separate field so re-entry can be detected). For aSemaphoreit is the permit count. For a latch, the count. Every acquire and release is a compare-and-swap on this integer. - The queue is a linked list of nodes, one per waiting thread, appended with a CAS. A thread that fails to acquire enqueues itself, then
park()s. A release CASes the state andunpark()s the head of the queue, which retries. - Barging. A non-fair
ReentrantLocklets a newly arriving thread try the CAS before looking at the queue. If the lock was just released and the unparked waiter has not yet run, the newcomer wins. That is unfair and it is faster, because the waiter's wake-up (microseconds) is skipped when the lock is free anyway. Fair mode checkshasQueuedPredecessors()first, and pays for it with a context switch per handover.tryLock()with no timeout barges even on a fair lock; the Javadoc says so and people are surprised. - Conditions are a second queue per
Conditionobject.await()moves the thread from the lock's queue to the condition's queue and releases the lock;signal()moves the first condition waiter back to the lock's queue, where it competes for the lock like anyone else.
This is also why a thread dump shows a thread waiting on a ReentrantLock as WAITING (parking) at LockSupport.park with parking to wait for <0x…> (a java.util.concurrent.locks.ReentrantLock$NonfairSync), not as BLOCKED: it is parked in AQS, not in a monitor. The dump lists the lock's owner under "Locked ownable synchronizers" for each thread, which is how you find who holds it.
Timeouts as a design tool
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
throw new ServiceUnavailableException("inventory is busy, retry");
}
try { ... } finally { lock.unlock(); }A request that waits five seconds for a lock is a request that has already failed its latency budget. tryLock with a timeout turns an invisible stall into an explicit, measurable, retryable failure — and, combined with a global lock order, it turns a potential deadlock into a timeout you can see.
Walkthrough: the lock nobody released
A cache refresh method:
void refresh() {
lock.lock();
Map<String, Rate> fresh = client.fetchAll(); // throws on a 503
rates = fresh;
lock.unlock(); // never reached
}- The rates service returns a 503 at 02:14.
fetchAllthrows;refreshexits by exception with the lock held; the scheduler thread that called it logs the error and goes on to its next job. - AQS
stateis 1 andowneris a thread that will never callunlock. Nothing in the JVM notices; a lock has no timeout of its own. - Every request thread that reads through
lock.lock()enqueues and parks. Within a minute all 200 Tomcat threads areWAITING (parking)on the sameReentrantLock$NonfairSync. The dump's "Locked ownable synchronizers" names the owner:scheduling-1, currently sleeping in its next job, unaware. - The fix is the
try/finallythe first code block showed, and the design fix is that a read should not need the write lock at all: avolatilereference to an immutable map, replaced by the refresher, has no lock to leak.
Had the readers used tryLock(200, MILLISECONDS), the incident would have been a burst of 503s with a clear message instead of a hung service, which is the whole argument for the timeout.
ReadWriteLock
Many readers, one writer:
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private final Map<String, Rate> rates = new HashMap<>();
Rate get(String ccy) {
rw.readLock().lock();
try { return rates.get(ccy); } finally { rw.readLock().unlock(); }
}
void refresh(Map<String, Rate> fresh) {
rw.writeLock().lock();
try { rates.clear(); rates.putAll(fresh); } finally { rw.writeLock().unlock(); }
}Any number of threads may hold the read lock at once; the write lock is exclusive and waits for readers to drain. Under the hood the AQS state is split: the high 16 bits count readers, the low 16 the writer's holds, which is why more than 65,535 concurrent readers throws. It wins when reads are frequent and long relative to writes. It loses — measurably, versus a plain ReentrantLock — when reads are short, because every read lock is still a CAS on a shared integer that every reader contends for. For a map that is read constantly and replaced wholesale occasionally, a volatile reference to an immutable map is simpler and faster than either.
StampedLock
An optimistic variant for read-mostly data:
private final StampedLock sl = new StampedLock();
private double x, y;
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // no lock taken
double cx = x, cy = y; // read
if (!sl.validate(stamp)) { // did a write happen meanwhile?
stamp = sl.readLock(); // fall back to a real read lock
try { cx = x; cy = y; } finally { sl.unlockRead(stamp); }
}
return Math.hypot(cx, cy);
}Optimistic reads take no lock at all; they read a version stamp, read the data, then check the stamp again. No CAS on the read path, so readers do not contend with each other. It is fast and it is easy to get wrong: not reentrant, no Condition, not AQS-based (so it does not appear under ownable synchronizers in a dump), and reading inconsistent state before validate must be harmless — no dereferencing a pointer you read optimistically. Use it for a hot path you have measured, and read the Javadoc twice.
Condition: waiting for something to become true
The classic problem: a consumer must wait until a queue has an item; a producer must wait until it has space. wait()/notify() on a monitor do this; Condition is the same thing on a Lock, with the ability to have several conditions per lock:
class BoundedBuffer<T> {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
void put(T t) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) notFull.await(); // WHILE, not if
items.addLast(t);
notEmpty.signal();
} finally { lock.unlock(); }
}
T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) notEmpty.await();
T t = items.removeFirst();
notFull.signal();
return t;
} finally { lock.unlock(); }
}
}await() atomically releases the lock and parks the thread; signal() wakes one waiter, which reacquires the lock before returning from await(). Three rules, each the source of a classic bug:
- Always hold the lock when calling
await/signal(IllegalMonitorStateExceptionotherwise). - Always wait in a
whileloop, re-checking the condition. Spurious wake-ups are permitted by the spec (the OS may wake a parked thread for no reason), and another thread may have consumed the item between the signal and your reacquiring the lock. - Signal after changing the state, and signal the right condition.
signalAllis safe when in doubt; it wakes every waiter, who each re-check and mostly go back to sleep.
With monitors, the equivalent is synchronized + wait() in a while + notifyAll(). One monitor has one wait set, so producers and consumers share it and notifyAll is the only safe choice; two conditions on one lock is the reason to prefer Condition for this shape.
You will rarely write this class: ArrayBlockingQueue is this class, one lock and two conditions. The value of writing it once is understanding what BlockingQueue.take() is doing when a thread dump shows it WAITING at ConditionObject.await.
Try it yourself
Fair or not?
A fair ReentrantLock has three threads queued. The owner unlocks, and at that instant a fourth thread calls lock(), and a fifth calls tryLock(). Who gets the lock?
Answer
The fourth thread sees queued predecessors and enqueues behind the three. The fifth thread's tryLock() (no timeout) barges: it attempts the CAS immediately, ignoring the queue, and if the state is 0 at that instant it wins, ahead of all four. The Javadoc documents this; use tryLock(0, TimeUnit.SECONDS) when fairness must be honoured. The queued head thread wins otherwise, once it is unparked and runs.
Spot the bug
T take() throws InterruptedException {
lock.lock();
try {
if (items.isEmpty()) notEmpty.await();
return items.removeFirst();
} finally { lock.unlock(); }
}Answer
if instead of while. Two consumers await; a producer adds one item and calls signalAll (or two producers each signal once but a third consumer took both items). Consumer one wakes, reacquires the lock, takes the item. Consumer two wakes, reacquires, and removeFirst() throws NoSuchElementException on an empty deque. Spurious wake-ups cause the same failure with a single consumer. Re-check the condition in a while.
Read the dump
Fifty threads show WAITING (parking) at LockSupport.park with parking to wait for <0x7f3a> (a ReentrantLock$NonfairSync). One thread shows Locked ownable synchronizers: <0x7f3a> and is TIMED_WAITING in Thread.sleep. What happened?
Answer
The sleeping thread holds the lock and is doing something slow (or has leaked it and moved on to a sleep in its next task). Fifty threads are parked in AQS behind it. It is the "lock nobody released" walkthrough, or a lock held across a slow operation. Find the sleeping thread's stack, then either the missing finally or the I/O inside the locked region.
Misconceptions
- "
ReentrantLockis faster thansynchronized." Since Java 6 they are comparable uncontended and both park under contention. Choose it for the features, not for speed. - "A fair lock prevents all barging."
tryLock()without a timeout barges by design. The timed and untimedlock()honour the queue. - "
signal()transfers the lock." It moves a waiter to the sync queue; the waiter still has to acquire the lock, and may lose to a barging newcomer, which is one more reason thewhileloop is mandatory. - "
ReadWriteLockis always better for read-heavy data." Read locks contend on one AQS integer; short reads pay more for the bookkeeping than they save. Measure, or use an immutable snapshot. - "A leaked lock times out eventually." Never. It is held until the owner unlocks or the process dies.
Going deeper
java.util.concurrent.locks.AbstractQueuedSynchronizerJavadoc and Doug Lea's paper "The java.util.concurrent Synchronizer Framework".ReentrantLockJavadoc, the fairness paragraph, for thetryLockbarging note.StampedLockJavadoc, whose example code is the reference implementation of the optimistic pattern.ArrayBlockingQueuesource: one lock, two conditions, forty lines.- Java Concurrency in Practice, chapters 13 and 14.