intermediate~2h

Explicit Locks: ReentrantLock, ReadWriteLock & StampedLock

Move beyond the synchronized keyword to java.util.concurrent.locks -- lock objects that can time out, be interrupted, support multiple wait-conditions per lock, and let many readers proceed at once.

Learning objectives

  • Explain what ReentrantLock offers that synchronized cannot, and apply the lock()/try/finally idiom correctly
  • Use tryLock() with a timeout to avoid classic multi-lock deadlocks
  • Choose between fair and unfair locking based on throughput versus starvation tradeoffs
  • Coordinate producers and consumers with multiple Condition objects on a single lock
  • Pick ReentrantLock, ReadWriteLock, or StampedLock correctly for a given read/write access pattern

◆ Story

Two people try to leave a building through two doors that each require a key held behind the other door. Person A grabs Door 1's key and reaches for Door 2's; at the same instant, Person B grabs Door 2's key and reaches for Door 1's. Both freeze, forever, each holding exactly what the other one needs. With synchronized blocks, there's no way to say "wait up to five seconds for that key, and if it doesn't show up, put mine back down and try again later." With an explicit lock, there is.

synchronized is simple because it does one thing and takes no arguments: acquire, run the block, release automatically, no matter how the block exits. That simplicity is also exactly its limit. A thread waiting to enter a synchronized block cannot back out after a timeout, cannot be interrupted while waiting, and has no way to distinguish "waiting to read" from "waiting to write" — everyone queues for the same single lock regardless of what they intend to do once they have it.

The java.util.concurrent.locks package hands that control back, at a real cost: lock objects with lock()/unlock() methods instead of a keyword, which means the JVM no longer releases anything for you automatically. Nothing about a Lock object stops you from acquiring it and forgetting to release it — the discipline that replaces the compiler's guarantee is try/finally, applied without exception, every single time.

this topic covers three lock types built on that same base idea, each trading away simplicity for a specific kind of flexibility: ReentrantLock (a more capable drop-in for synchronized), ReadWriteLock (letting many readers proceed at once, excluding writers only when one is actually writing), and StampedLock (going further still, letting readers proceed with essentially no locking overhead at all, at the cost of real complexity).

ReentrantLock does everything synchronized does — mutual exclusion, reentrancy (a thread that already holds the lock can acquire it again without deadlocking itself), the same visibility guarantees — plus several things synchronized fundamentally cannot: tryLock() to back off instead of waiting forever, lockInterruptibly() so a waiting thread can be interrupted, and newCondition() to support more than one wait-set per lock. All of that power comes with one non-negotiable rule attached.

The idiom is always the same shape: lock() runs before the try block starts, and unlock() runs inside a finally block that's guaranteed to execute whether the protected code finishes normally or throws. lock() is placed outside the try deliberately — if lock() itself were ever to throw before actually acquiring the lock, calling unlock() in a finally block that still ran would throw IllegalMonitorStateException, since this thread was never the lock's owner in the first place.

▲ Common mistake

If a critical section throws an exception and unlock() isn't guaranteed to run afterward, the lock stays held by a thread that has already left the method — permanently. Every other thread calling lock() on it blocks forever. This doesn't even look like a classic deadlock in a thread dump, since there's no cycle of two threads waiting on each other — it just looks like the application quietly stopped making progress on that one piece of shared state. This is the entire reason the try/finally discipline has zero exceptions anywhere in production code.

Reentrancy matters in practice whenever one locked method calls another locked method on the same object from the same thread — a common shape when a public method delegates to a private helper that also needs the lock. ReentrantLock tracks a hold count internally: the same thread can call lock() multiple times without blocking itself, but it must call unlock() exactly as many times before any other thread can acquire it.

💻 Code example

package com.crackedlabs.concurrency.locks; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class InventoryCounter { private final Lock lock = new ReentrantLock(); private int unitsInStock = 100; public void reserveUnit() { // lock() is placed BEFORE the try block on purpose: if lock() // itself throws before acquiring, we must never call unlock() // for a lock this thread never actually became the owner of. lock.lock(); try { if (unitsInStock > 0) { unitsInStock--; } } finally { // Guaranteed to run whether the try block finishes normally // or exits via an exception -- the only safe place to unlock. lock.unlock(); } } public int getUnitsInStock() { lock.lock(); try { return unitsInStock; } finally { lock.unlock(); } } public static void main(String[] args) { InventoryCounter counter = new InventoryCounter(); counter.reserveUnit(); System.out.println("Units left: " + counter.getUnitsInStock()); } }

tryLock(timeout, unit) is the direct fix for the two-door deadlock from the opening story: instead of blocking indefinitely once you're holding one lock and waiting on a second, you attempt the second lock with a bounded wait and, if it doesn't arrive in time, release what you're already holding and back off. This converts an unrecoverable freeze into a detectable, recoverable failure to proceed — though it does not eliminate deadlock risk entirely on its own; the calling code still needs a retry or backoff strategy so that two threads that both back off don't simply collide again immediately.

Fairness is a separate, independent decision: new ReentrantLock() (the default) is unfair, meaning that when the lock becomes free, any thread currently trying to acquire it may get it — including one that just arrived, ahead of threads that have been queued far longer. This sounds bad but is usually faster in practice, since a thread that's already running and asking for the lock right now can acquire and release it before a queued, sleeping thread even gets rescheduled. new ReentrantLock(true) switches to strict FIFO ordering, which prevents starvation at a real, measurable throughput cost. Default to unfair; reach for fair mode only once you've observed an actual starvation problem under real contention.

A Lock can hand out multiple independent Condition objects via newCondition(), each with its own private wait-set — this is the fix for the single-wait-set limitation of intrinsic monitors, where a bounded buffer with both producers and consumers has to broadcast to every waiting thread and let each one re-check its own condition. With separate notFull and notEmpty conditions, put() only ever needs to wake a consumer and take() only ever needs to wake a producer, and each can safely call the more targeted signal() instead of signalAll() — safe specifically because each operation changes the buffer's count by exactly one, so at most one blocked thread on the opposite side could possibly have its condition become true.

💻 Code example

package com.crackedlabs.concurrency.locks; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class BoundedTaskBuffer<T> { private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // producers wait here private final Condition notEmpty = lock.newCondition(); // consumers wait here private final Object[] slots; private int putIndex, takeIndex, size; public BoundedTaskBuffer(int capacity) { slots = new Object[capacity]; } public void put(T task) throws InterruptedException { lock.lock(); try { while (size == slots.length) { notFull.await(); // release the lock and wait until there's room } slots[putIndex] = task; putIndex = (putIndex + 1) % slots.length; size++; notEmpty.signal(); // wake exactly one waiting consumer } finally { lock.unlock(); } } @SuppressWarnings("unchecked") public T take() throws InterruptedException { lock.lock(); try { while (size == 0) { notEmpty.await(); } T task = (T) slots[takeIndex]; takeIndex = (takeIndex + 1) % slots.length; size--; notFull.signal(); // wake exactly one waiting producer return task; } finally { lock.unlock(); } } }

▲ Common mistake

Placing lock.lock() inside the try block instead of before it. This inverts the entire safety guarantee: if acquiring the lock itself fails partway through, the finally block still runs and calls unlock() on a lock this thread never actually held, throwing IllegalMonitorStateException on top of whatever the original problem was.

A second, especially dangerous mistake shows up with ReadWriteLock: accidentally using readLock() where writeLock() belongs. If a put() method mistakenly acquires the read lock, multiple threads can call it concurrently while other threads are simultaneously inside a get() reading from the same backing map — and since a plain HashMap is not thread-safe internally, a concurrent structural modification racing against a read can corrupt the map's internal structure or, in the worst case, send a reading thread into an infinite loop. This bug compiles cleanly and often appears to work in casual single-threaded testing; it only shows up under genuine concurrent load.

Reaching for new ReentrantLock(true) by default, without an actual observed starvation problem, is a subtler mistake: fairness is a real, non-trivial performance tradeoff, not a free correctness upgrade. Most production code should start with the unfair default and only switch when starvation has actually been measured or is a specific, well-understood risk for a latency-sensitive path.

Finally, using signal() instead of signalAll() when a single state change could plausibly unblock more than one waiting thread is a genuine correctness bug, not just a minor inefficiency — some woken threads will simply never be signaled and can wait forever. signal() is only safe under a specific guarantee: "this state change can unblock at most N waiters, and I am waking exactly N." When that guarantee doesn't clearly hold, signalAll() is the only safe choice.

ReadWriteLock is not automatically a win: it's ideal when reads vastly outnumber writes, but the internal bookkeeping of tracking readers and writers has real overhead, and a writer must wait for every currently active reader to fully drain before it can proceed. Once writes become roughly as frequent as reads, that overhead can make ReadWriteLock slower than a plain ReentrantLock — it's a specialization for a specific access pattern, not a strictly better lock.

StampedLock, introduced in Java 8, goes a step further for read-heavy workloads: its optimistic-read mode (tryOptimisticRead()) acquires no real lock at all — it hands back a version stamp, the caller reads the data speculatively, and then calls validate(stamp) to check whether any write happened in between. If validation fails, the caller must fall back to a real, blocking readLock() and redo the read. Skipping that validation step is a genuine, silent-corruption bug: without it, two fields read as part of the same logical value could be a "torn read" — one reflecting a newer value than the other — with no exception, just a quietly wrong computed result.

▲ Edge case

StampedLock is not reentrant, unlike ReentrantLock and ReentrantReadWriteLock. A thread calling writeLock() again while it already holds that write lock deadlocks against itself. StampedLock also does not support Condition objects at all. It's the right tool specifically for very read-heavy, rarely-written state where reads are cheap and side-effect-free — never for a read that's expensive or has side effects, since a failed validate() means that work has to be redone from scratch under a real lock.

Even ReentrantReadWriteLock itself has a starvation nuance worth knowing: in its default, non-fair configuration, a continuous stream of new readers can in principle keep a waiting writer blocked indefinitely, since readers never have to wait for each other. This is the same shape of tradeoff as unfair versus fair ReentrantLock, just one level up in the read/write split.

ReadWriteLock is a natural fit for in-memory configuration caches, permission tables, and feature-flag stores that are read constantly on every request but written only occasionally, when an admin changes a setting — exactly the "reads dominate" profile the lock is built for.

StampedLock's optimistic reads show up in hot-path telemetry and coordinate-tracking code — game engines updating and reading entity positions many times per frame, or metrics collectors where a stat is read far more often than it's updated, and the cost of the bookkeeping a ReadWriteLock would still require becomes measurable at scale.

Resource managers coordinating access to multiple independent locks — a connection pool acquiring both a rate-limit lock and a connection-slot lock, for instance — commonly use tryLock(timeout) specifically to avoid the classic multi-lock deadlock shape, paired with a retry/backoff loop rather than assuming the first attempt will always succeed.

Inside the JDK itself, several java.util.concurrent classes are built on ReentrantLock rather than synchronizedThreadPoolExecutor uses one internally to guard its worker bookkeeping, precisely because it needs the interruptibility and flexibility synchronized doesn't offer.

Custom schedulers and bounded work queues written before BlockingQueue existed — or specialized variants that need behavior the standard implementations don't provide — are commonly built directly on a Lock plus one or more Condition objects, following the exact producer/consumer shape shown earlier in this topic.

Q: What can ReentrantLock do that synchronized fundamentally cannot?

A: Attempt to acquire without blocking (tryLock()), attempt with a timeout, allow a waiting thread to be interrupted (lockInterruptibly()), support multiple independent Condition wait-sets on one lock, and optionally guarantee FIFO fairness. synchronized offers none of these.

Q: Why must lock() be called before the try block, never inside it?

A: If lock() itself throws before actually acquiring the lock, a finally block that still runs and calls unlock() would throw IllegalMonitorStateException, since this thread was never the lock's owner. Placing lock() outside the try avoids ever unlocking a lock you don't hold.

Q: What's the real tradeoff between a fair and an unfair ReentrantLock?

A: Unfair (the default) is usually faster under contention because a thread already running can acquire and release the lock before a queued, sleeping thread even wakes up -- but it permits rare, genuine starvation. Fair mode guarantees strict FIFO acquisition, eliminating starvation, at a measurable throughput cost from extra context switches.

Q: When does a ReadWriteLock actually help, and when does it stop helping?

A: It helps when reads vastly outnumber writes, letting many readers proceed concurrently instead of serializing behind one lock. It stops helping once writes become roughly as frequent as reads, since the reader/writer bookkeeping overhead and the wait for all readers to drain before a write can make it slower than a plain ReentrantLock.

Q: Why must an optimistic read from StampedLock always be validated before it's used?

A: Because tryOptimisticRead() acquires no actual lock -- the read values could be a torn combination of old and new data if a writer ran concurrently. validate(stamp) checks whether a write happened since the stamp was issued; skipping it risks silently acting on inconsistent data with no exception or warning.

Want a visual for this concept?

Generate a diagram tailored to “Explicit Locks: ReentrantLock, ReadWriteLock & StampedLock” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Atomic Variables & Compare-And-Swap← Back to all Java Concurrency & Multithreading chapters