A plain mutual exclusion lock treats every operation the same. Whether a thread is reading a value or modifying it, the lock lets exactly one thread through at a time. That's safe, but it's wasteful when reads vastly outnumber writes. Reading a price from a product catalog doesn't change anything, so blocking ten reader threads while one of them does the read is throwing parallelism away. ReadWriteLock is Java's answer to that waste. It allows many readers to hold the lock at the same time, or one writer alone, never both. This lesson covers the ReadWriteLock interface, its concrete implementation ReentrantReadWriteLock, the read and write views it exposes, lock downgrading, why upgrading is forbidden, the fair vs unfair flavor, and a short look at StampedLock as a higher-throughput alternative.
The previous chapter covered ReentrantLock and its Condition objects in detail. Here we build on that mental model. ReadWriteLock doesn't replace ReentrantLock; it specializes it for a different access pattern.
Consider an in-memory product catalog. Customer requests hit it thousands of times a second to look up a price. An admin updates a price once an hour. With a ReentrantLock, every reader queues behind every other reader, even though none of them are changing the data. The lock is treating the safe case (many threads reading the same value) as if it were the dangerous case (one thread writing while others observe a half-updated value).
A read-write lock splits the access pattern in two:
The rule is simple: many readers OR one writer, never both.
In the diagram, four readers hold the read lock at the same time and pull from the catalog in parallel. The writer is dashed because it can't enter while readers are in. The moment all readers release the read lock, the writer acquires the write lock alone, the catalog is updated, and the next batch of readers gets back in.
That pattern is everywhere in production code. Configuration registries, product catalogs, routing tables, in-memory caches, session lookup maps. Read-mostly data structures see thousands of reads for every write, and a plain mutex serializes work that doesn't need to be serialized.
ReadWriteLock Interfacejava.util.concurrent.locks.ReadWriteLock is a small interface with exactly two methods. Both return a Lock (the same interface we used with ReentrantLock).
Calling readLock() returns the read view of the lock. Calling writeLock() returns the write view. The two views are not separate locks. They are two different lenses onto the same internal state, and the implementation coordinates them. Acquiring the write view excludes everyone (including holders of the read view). Acquiring the read view succeeds as long as no thread holds the write view.
Each view is a regular Lock, so you use it with the same lock(), unlock(), tryLock(), lockInterruptibly() calls you already know. The familiar try-finally pattern carries over.
The two views are obtained once and reused. You don't call readLock() every time you want to read; you cache the Lock reference and call lock() and unlock() on it. Same for the write view. Each view is a long-lived helper, not a freshly allocated object.
ReentrantReadWriteLock: The ImplementationReentrantReadWriteLock is the standard implementation of ReadWriteLock. The "reentrant" part means a thread that already holds the lock can acquire it again without blocking, just like ReentrantLock. A reader holding the read lock can take it again recursively. A writer holding the write lock can take the write lock again recursively. The thread releases it once per acquire, so the hold counts balance out.
A small product catalog cache that uses ReentrantReadWriteLock to serve many readers and the occasional admin price update:
getPrice takes the read lock, looks up the value, and releases. Multiple threads calling getPrice at the same time all hold the read lock together, and they all proceed in parallel. updatePrice takes the write lock, mutates the map, and releases. While the writer holds the lock, every reader and every other writer waits.
The unlock calls are inside a finally block for a reason. If prices.get or prices.put threw an exception, control would skip the rest of the method body. Without finally, the lock would stay held forever, and the catalog would deadlock the next time anyone touched it. The try-finally idiom is non-negotiable for explicit locks.
The parallelism in action with multiple threads:
The three readers print their lines essentially together. They share the read lock, so none of them blocks the others. The writer starts a fraction of a second later and waits until all three readers have unlocked. Then it acquires the write lock alone and runs. If the same example used a ReentrantLock instead of a read-write lock, the three readers would have to take turns one at a time, slowing the program down for no reason.
The same pattern works for any read-mostly data. Consider a configuration registry that an application reads on every request and an admin tool rewrites occasionally:
get takes the read lock for one lookup. set and replaceAll take the write lock. The replaceAll method shows why the write lock has to be exclusive: between clear() and putAll(newSettings), the map is empty. A reader that snuck in at that moment would see an empty registry and break. The write lock blocks every reader for the entire duration of replaceAll, so the map is never observed in its half-cleared state.
A useful trick with ReentrantReadWriteLock is downgrading: a thread holding the write lock acquires the read lock and then releases the write lock, ending up with just the read lock. This lets you publish a freshly written value to readers without giving up exclusive access in between.
The order is what makes this work. You must acquire the read lock before you release the write lock. If you did it the other way around (release write first, then take read), there's a window between the two calls where another writer could sneak in and change the data again before you reread it.
The flow has three phases. Phase one, the thread holds the write lock alone and mutates the map. Phase two, while still holding the write lock, it grabs the read lock. Since ReentrantReadWriteLock is reentrant, this succeeds for the same thread even though writers are normally exclusive against readers. Phase three, it releases the write lock and now holds only the read lock. Any other readers can join in (or already have), but no other writer can break in.
The orange middle of the diagram is the critical bit. The read lock is taken while the write lock is still held, which means there's no gap where another writer could acquire the lock and change the data behind our back. Once the read lock is in hand, the write lock can be released safely, and the value we just wrote is what we're about to read.
Downgrade is the case where you want both views in succession. Don't use it just because you can. If a method only writes, hold only the write lock and release. If it only reads, hold only the read lock. Downgrade is for the specific pattern of "update, then immediately read the updated value while making sure nobody else writes in between."
The opposite move, upgrading from a read lock to a write lock, is not allowed in ReentrantReadWriteLock. A thread that holds the read lock and then tries to acquire the write lock blocks forever. The reason isn't an oversight in the design. It's a deadlock prevention measure.
Consider two readers, R1 and R2, both holding the read lock. Both decide they want to upgrade to a write lock. R1 calls writeLock().lock() and waits for the read count to drop to zero. The problem is that the read count includes R1's own read lock, plus R2's. R1 won't release its read lock because it's waiting for the write lock. R2 does the same. Each reader holds a read lock that the other is waiting for, neither can release, and the program is stuck.
The library chose to reject upgrades rather than try to detect this and recover. If you want to write, the recipe is: release the read lock first, then take the write lock, then reread the data because someone else might have changed it while you were unlocked, then write. The reread step is what makes this safe and what makes upgrade-by-hand uglier than downgrade.
The commented line is what to avoid. If you uncomment it, the program prints the first line and hangs forever, because the same thread is trying to take the write lock while it still owns the read lock. The fix is the two-step pattern after the try block: release the read lock cleanly, then take the write lock fresh.
Like ReentrantLock, ReentrantReadWriteLock has two flavors: fair and unfair. The constructor takes a boolean. Default is unfair.
The unfair version optimizes for throughput. When the lock is released, the first thread to try lock() wins, regardless of who has been waiting longest. This often lets newly arriving threads cut in front of waiting ones, which sounds bad, but it avoids the context-switching cost of waking up a blocked thread when a running thread can just take the lock and keep going. For most workloads, unfair is appropriate.
The fair version grants the lock in FIFO order. The thread that's been waiting longest gets the lock next. Specifically for read-write locks, fairness has an extra rule: a reader that arrives while there's a queued writer waits for the writer to go first. This protects writers from starvation. In the unfair version, a steady stream of incoming readers can keep the write count at one or more indefinitely, and a waiting writer never gets in.
| Aspect | Unfair (default) | Fair |
|---|---|---|
| Order of acquisition | Whoever asks first wins | Longest-waiting thread next |
| Reader starvation by writers | Rare | Possible (writer always cuts in front of new readers) |
| Writer starvation by readers | Possible under heavy read load | Prevented |
| Throughput | Higher | Lower |
| Use when | Most workloads | Writers must not starve |
isFair() reads the constructor flag. The two lock instances behave the same in this single-threaded test; the difference shows up only under contention. If the registry from earlier is hit by a thousand reader threads per second and one writer per second, an unfair lock might let the writer wait minutes for a gap. A fair lock guarantees the writer gets in within one cycle.
Read-write locks aren't free. The bookkeeping needed to track reader counts, queue waiters, and coordinate the two views adds overhead on every acquire and release. For a critical section that does almost nothing (read a single field, return), a ReentrantLock or even synchronized is cheaper. The fancier machinery only earns back that overhead when readers actually overlap and there are enough of them to fill the saved time.
A ReentrantReadWriteLock can be slower than a plain mutex when writes are frequent, critical sections are tiny, or contention is low. The reader bookkeeping has overhead, and a write lock acquisition has to wait for every existing reader to drain. If profiling shows a hot lock and the access pattern is mostly writes or microsecond reads, swap the read-write lock for a ReentrantLock and measure again.
There's also a more subtle pitfall. If your "read" operation does anything that takes real time (a network call, a long computation), readers pile up holding the read lock, and writers can wait far longer than expected. Keep critical sections short on both sides, but especially on the read side, where the optimism about parallelism breaks down if each reader holds the lock for half a second.
Concrete signs you've outgrown ReentrantReadWriteLock:
Map and you don't need atomic compound updates. Use ConcurrentHashMap, which scales much better.AtomicLong, AtomicReference).ReentrantLock or synchronized.Use ReentrantReadWriteLock when there's a richer data structure (multi-field state, custom invariants) that needs to be updated atomically and read often.
StampedLock: Higher Throughput with Optimistic ReadsJava 8 introduced StampedLock as a faster alternative for read-heavy workloads. It supports three modes: write, pessimistic read, and an extra optimistic read mode that doesn't actually acquire anything. Instead, a reader takes a "stamp" (a long value representing the lock state), does its work without blocking, and then checks whether any writer has appeared during the read. If not, the read is valid. If a writer did appear, the reader falls back to a pessimistic read.
The optimistic read avoids the bookkeeping cost of incrementing a reader counter, which is exactly what makes ReentrantReadWriteLock slower than ideal at high contention. The trade-off is that StampedLock is not reentrant, so a thread that already holds the lock cannot reacquire it (which would cause a hang). It also doesn't support a Condition. And the optimistic-read pattern requires careful coding: the work inside the optimistic block must not modify shared state, must not throw on torn reads, and must be ready to retry under a real lock.
StampedLock is well-suited when the read critical section is tiny (single field, single map lookup) and reads dominate. For larger reads, the savings shrink. For mixed workloads with reentrancy, conditions, or interruptible locks, stick with ReentrantReadWriteLock. The decision is workload-specific, and the answer should come from profiling rather than from a default preference.
| Feature | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| Reentrant | Yes | No |
| Optimistic read | No | Yes |
| Condition variables | Yes | No |
| Throughput under heavy reads | Good | Better (with optimistic reads) |
| Complexity | Lower | Higher |
A complete example tying the patterns together. A product catalog cache with many simulated readers, a writer that updates a price, and a downgrade-style method that updates and reads atomically.
The exact interleaving of reader lines varies between runs because the threads race against each other. What's stable is that no reader ever sees a half-updated map, the writer's setAndGet finishes with the freshly written value (it's holding the read lock when it reads, so nobody else can sneak in a write), and the final price reflects the writer's update. The downgrade in setAndGet is what guarantees the write-then-read pair is atomic from the perspective of other writers.
10 quizzes