2. ReadWrite Locks
A read-write lock splits one mutex into two coordinated permits — a shared read permit that any number of threads can hold at once, and an exclusive write permit that excludes everyone — so that read-mostly state pays no contention cost on the common path while still serializing every mutation. Internally it is one atomic word: the upper bits count active readers, the lower bits hold the single writer's reentrant depth, and a thread is admitted only when the word is in a state compatible with the permit it wants.
The mechanism, traced on one 32-bit word
ReentrantReadWriteLock keeps its whole state in a single int inside its AQS (AbstractQueuedSynchronizer). The high 16 bits are the shared count (number of read holds across all threads); the low 16 bits are the exclusive count (reentrant depth of the one writer). Acquisition is a CAS on that word: a reader succeeds only if the exclusive count is 0; a writer succeeds only if the entire word is 0. That single rule produces shared-read / exclusive-write behaviour. Below is a real interleaving of 2 writers (W1, W2) and a couple of readers (R1, R2) against the demo's counter.
| Step | Action | state word (shared:excl) | Outcome |
|---|---|---|---|
| 1 | R1 readLock().lock() | 1 : 0 | excl==0 → admitted, reads counter=0 |
| 2 | R2 readLock().lock() | 2 : 0 | excl==0 → admitted concurrently, no blocking |
| 3 | W1 writeLock().lock() | 2 : 0 | word != 0 → blocks, parked in AQS queue |
| 4 | R1 unlock(); R2 unlock() | 0 : 0 | last reader release signals the parked writer |
| 5 | W1 wakes, CAS 0→1 excl | 0 : 1 | word was 0 → admitted, counter 0→1 |
| 6 | W2 writeLock().lock() | 0 : 1 | excl!=0 and not same thread → blocks |
| 7 | R1 readLock().lock() | 0 : 1 | excl!=0 → blocks behind the writer |
| 8 | W1 unlock() | 0 : 0 | release wakes next AQS waiter (W2 or R1 by queue order) |
Steps 1–2 are the whole point: two readers coexist with a single atomic increment of the shared count and zero parking. Step 5 shows write-exclusivity — the writer only proceeds when the word is fully zero, i.e. no readers and no other writer.
The demo, corrected and instrumented
The original page's code is functionally fine but obscures the mechanism. Below it is tightened and the readValue() contract is fixed (the old version returned 0 on the catch-fall-through path, which silently looks like "counter reset to 0" to the reader loop). We also log every acquisition so you can see readers overlapping and writers serializing.
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock;
public class RWDemo {
private static int counter = 0; // guarded by the lock, no volatile needed
private static final int TARGET = 1000;
// true = fair: FIFO admission, prevents writer starvation (see Pitfalls)
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
private static final ReadLock r = lock.readLock();
private static final WriteLock w = lock.writeLock();
static int increment() {
w.lock(); // exclusive: word must be 0
try {
if (counter < TARGET) counter++;
return counter;
} finally { w.unlock(); }
}
static int read() {
r.lock(); // shared: admitted iff no writer
try { return counter; } // single return, no fall-through bug
finally { r.unlock(); }
}
public static void main(String[] args) throws InterruptedException {
long start = System.nanoTime();
Thread[] all = new Thread[10];
for (int i = 0; i < 8; i++) // 8 readers
all[i] = new Thread(() -> { while (read() < TARGET) Thread.onSpinWait(); });
for (int i = 8; i < 10; i++) // 2 writers
all[i] = new Thread(() -> { while (increment() < TARGET); });
for (Thread t : all) t.start();
for (Thread t : all) t.join();
System.out.printf("counter=%d took %.1f ms%n",
counter, (System.nanoTime() - start) / 1e6);
}
}
Why the naive version is wrong: the original readValue() declared return 0; after the try/catch so that any InterruptedException made the reader observe 0 and loop forever — a liveness bug masquerading as a read. Returning inside the try with a single exit removes it. The volatile on counter was also redundant: lock acquire/release already establish happens-before, so the lock alone publishes the writes.
Lock downgrading — the one safe transition
A writer that wants to keep reading the value it just wrote can downgrade: acquire the read lock while still holding the write lock, then release the write lock. This hands off without ever opening a window where another writer could slip in and change the value between your write and your read. The reverse — a reader trying to upgrade to the write lock — is forbidden and deadlocks, because the upgrading thread would have to wait for all readers to leave, including itself.
w.lock();
try {
counter++; // mutate under exclusive lock
r.lock(); // acquire read BEFORE releasing write → downgrade
} finally {
w.unlock(); // now shared; other readers may join, no writer can
}
try {
use(counter); // consistent snapshot, still protected
} finally {
r.unlock();
}
The same logic in Go
Go's sync.RWMutex gives the identical shared-read / exclusive-write semantics, but the runtime underneath is different: these are goroutines multiplexed onto a few OS threads by the Go scheduler, not 10 OS threads as in Java, so blocking on the lock parks a goroutine cheaply rather than parking a kernel thread. Go has no fairness flag — instead RWMutex bakes in a writer-starvation guard: once a writer is waiting, new readers are blocked behind it.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const target = 1000
var mu sync.RWMutex
counter := 0
done := make(chan struct{})
var once sync.Once
finish := func() { once.Do(func() { close(done) }) }
increment := func() int { // exclusive
mu.Lock()
defer mu.Unlock()
if counter < target {
counter++
}
return counter
}
read := func() int { // shared
mu.RLock()
defer mu.RUnlock()
return counter
}
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 8; i++ { // 8 reader goroutines
wg.Add(1)
go func() {
defer wg.Done()
for read() < target {
select {
case <-done:
return
default:
}
}
}()
}
for i := 0; i < 2; i++ { // 2 writer goroutines
wg.Add(1)
go func() {
defer wg.Done()
for increment() < target {
}
finish()
}()
}
wg.Wait()
fmt.Printf("counter=%d took %v\n", counter, time.Since(start))
}
Where the two runtimes differ: Java signals waiters through AQS park/unpark on top of OS-thread monitors and exposes an explicit fairness constructor; Go parks goroutines on the scheduler's run queues and channels (here the done channel) replace the busy-spin reader exit. A Go reader holds the lock via RLock/RUnlock — forgetting defer on an early return is the classic leak, exactly like a missing finally in Java.
The experiment hook, with what to expect
Change read() to take the write lock instead of the read lock and re-run. Now all 10 goroutines/threads serialize through the exclusive permit — the shared-read fast path is gone, and on a read-heavy workload like this (8 readers, 2 writers) you should see total time rise roughly in proportion to how much reader concurrency you destroyed. With the per-call Thread.sleep(1) from the original removed, the gap is smaller but still measurable; add a short sleep back inside the critical section and the read-lock version pulls clearly ahead, because N readers that used to overlap now run back-to-back. That divergence is the entire economic argument for a read-write lock: it only pays off when reads dominate and reads are non-trivial.
Pitfalls
- Writer starvation. With the default non-fair lock and a steady stream of readers, the shared count may never drop to 0, so a waiting writer is never admitted. Construct with
new ReentrantReadWriteLock(true)(fair / FIFO) when writers must make progress; Go'sRWMutexhandles this for you by blocking new readers once a writer waits. - Upgrade deadlock. Holding the read lock and then calling
writeLock().lock()deadlocks — the writer waits for all readers (including the caller) to release. Only downgrade (write→read) is legal. - RW locks are slower than a mutex when writes are frequent or critical sections are tiny. The two-counter bookkeeping and the fairness queue add overhead; for short, write-heavy sections a plain
ReentrantLock/sync.Mutexwins. Benchmark before reaching for RW. - Forgotten unlock on an exception path. Without
try/finally(Java) ordefer(Go), an exception inside the critical section leaks the lock and freezes every other thread. Always pair lock/unlock structurally. - Read lock gives mutual exclusion, not a transaction. The value can change the instant you release. If you read-decide-write, you need the write lock for the whole span or a downgrade, not a read followed by a separate write.
Takeaways
- One atomic word, split into a shared reader count and an exclusive writer depth, encodes the whole protocol: readers admitted when the writer bits are 0, writer admitted only when the entire word is 0.
- RW locks win on read-mostly, non-trivial critical sections; they lose to a plain mutex when writes are frequent or sections are tiny.
- Choose fairness deliberately — non-fair locks can starve writers under continuous read load; downgrade (write→read) is safe, upgrade (read→write) deadlocks.
- Java RW locks ride on OS threads + AQS with an explicit fairness flag; Go's
sync.RWMutexrides on cheap goroutines and builds in a writer-priority rule, with channels replacing wait/notify for coordination.
Sources: Brian Goetz et al., Java Concurrency in Practice (ch. 13, explicit locks & read-write locks); the OpenJDK ReentrantReadWriteLock and AbstractQueuedSynchronizer source and Javadoc (state-word layout, fairness, downgrading example); the Go standard library sync.RWMutex documentation and source (writer-starvation prevention). Re-authored and deepened for this guide: fixed the reader fall-through liveness bug, removed the redundant volatile, added the state-word trace, the downgrade walkthrough, the fairness/starvation treatment, and the side-by-side Go implementation.
🤖 Don't fully get this? Learn it with Claude
Stuck on 2. ReadWrite Locks? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **2. ReadWrite Locks** (Concurrency) and want to truly understand it. Explain 2. ReadWrite Locks from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **2. ReadWrite Locks** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **2. ReadWrite Locks** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **2. ReadWrite Locks** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.