Knowledge Guide
HomeConcurrencyConcurrency Foundations

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.

StepActionstate word (shared:excl)Outcome
1R1 readLock().lock()1 : 0excl==0 → admitted, reads counter=0
2R2 readLock().lock()2 : 0excl==0 → admitted concurrently, no blocking
3W1 writeLock().lock()2 : 0word != 0 → blocks, parked in AQS queue
4R1 unlock(); R2 unlock()0 : 0last reader release signals the parked writer
5W1 wakes, CAS 0→1 excl0 : 1word was 0 → admitted, counter 0→1
6W2 writeLock().lock()0 : 1excl!=0 and not same thread → blocks
7R1 readLock().lock()0 : 1excl!=0 → blocks behind the writer
8W1 unlock()0 : 0release 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.

diagram
diagram

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.

java
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.

go
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

Takeaways


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes