Concurrency

Read-write locks: interview questions and how to answer them

A read-write lock lets any number of readers proceed together but excludes all readers while a writer holds it — profitable only when reads dominate and each read is long enough to pay for the bookkeeping.

Written and reviewed by Sahil Srivastav

ConcurrencyPerformanceTrade-offs

What it actually is

A read-write lock splits acquisition into two modes. Read mode is shared: many threads hold it simultaneously. Write mode is exclusive: it waits for all readers to leave and blocks new ones. The invariant is that a writer never overlaps with anything, and readers never overlap with a writer — which is exactly the invariant that makes concurrent reads of stable data safe.

The part that is usually left out of the definition is that this is not free. A plain mutex tracks one bit; a read-write lock must track a reader count, a writer flag, and typically a queue, and every read acquisition mutates that shared count. That mutation is a contended write to a shared cache line — so N readers acquiring a read lock generate exactly the coherence traffic that N readers of the data were trying to avoid.

Which gives the practical rule that surprises people: a read-write lock beats a mutex only when reads greatly outnumber writes *and* the critical section is long enough that the bookkeeping cost is small relative to the work inside. For a short read — a map lookup, a field read — `ReentrantReadWriteLock` is measurably slower than `synchronized`, and slower still than using a `ConcurrentHashMap` and no lock at all.

Why it matters in production

Because the read-mostly shared structure is ubiquitous: a routing table, a feature-flag snapshot, a rate-limit configuration, a plugin registry. Getting the synchronisation wrong on one of those is felt everywhere, since every request touches it. A coarse exclusive lock around a registry read is how a service ends up with all its request threads queued behind a lock whose critical section is a hash lookup.

Because the failure modes are specific and interviewers know them. Writer starvation: with a reader-preference policy, a steady stream of readers means a writer waits indefinitely, so the configuration update never lands. Upgrade deadlock: two threads holding read locks that both try to take the write lock wait for each other forever, and `ReentrantReadWriteLock` does not support upgrade at all — the attempt deadlocks the single thread immediately.

And because the correct answer is frequently to remove the lock rather than refine it. Replacing a guarded `HashMap` with a `ConcurrentHashMap`, or with an immutable map swapped atomically on write, gives lock-free reads with no bookkeeping — and that is the answer a strong candidate reaches for before discussing read-write lock policy.

How it works

Read acquisition is a write to shared state

Incrementing the reader count requires exclusive ownership of the count's cache line, so readers contend with each other on the lock even though they do not contend on the data. This is why the break-even against a mutex needs a genuinely long critical section, and why a short-read workload regresses.

Fairness policy decides who starves

Reader preference maximises read throughput and can starve writers indefinitely. Writer preference blocks new readers once a writer is queued, which bounds writer latency at the cost of read throughput. `new ReentrantReadWriteLock(true)` selects an approximately fair policy that queues both in arrival order. There is no policy that is best for every workload, which is why it is a constructor argument.

Upgrade is not supported and downgrade is

Calling `writeLock().lock()` while holding the read lock deadlocks in `ReentrantReadWriteLock`, because the write lock waits for readers to drain and you are one of them. Downgrading works: acquire write, acquire read while holding write, release write. The usual correct pattern is to release the read lock, take the write lock, and then re-check the condition, because it may have changed while you held nothing.

StampedLock adds an optimistic read mode

`tryOptimisticRead` returns a stamp and takes no lock at all; you read the fields, then `validate(stamp)` tells you whether a writer intervened, in which case you fall back to a real read lock. Reads become almost free when writes are rare. The costs are that `StampedLock` is not reentrant, the optimistic block must not call anything that could block, and misuse produces torn reads rather than a clean failure.

Copy-on-write removes reader synchronisation entirely

Keep the state in an immutable structure behind a volatile reference. Readers just read the reference — no lock, no counter, no contention. Writers build a new structure and swap the reference, serialised by an ordinary mutex. This is ideal for small, read-dominated, infrequently written state, and unsuitable for large structures or frequent writes because each write copies.

Implementing it

Before reaching for a read-write lock, check whether a concurrent collection or an immutable snapshot behind a volatile reference solves it. Those remove reader coordination rather than making it cheaper.

If you do use one, keep the read section short but not trivial, and never perform I/O inside it — a write lock waiting for a reader that is doing a network call holds up every other reader too.

Choose fairness deliberately and state which side you are protecting. If a configuration writer must land within a bounded time, you need writer preference or fairness, and you should assert it with a test that reads continuously while writing once.

Never attempt to upgrade. Structure the code as: read under the read lock, release, take the write lock, re-validate the condition, write. The re-validation is not optional, because the state can change in the gap.

// Upgrade deadlocks: the write lock waits for readers, and you are a reader
rw.readLock().lock();
if (needsRefresh()) {
    rw.writeLock().lock();        // deadlock, immediately, on this thread
}

// Release, acquire write, then RE-CHECK — the state may have moved
rw.readLock().lock();
boolean stale;
try { stale = needsRefresh(); } finally { rw.readLock().unlock(); }
if (stale) {
    rw.writeLock().lock();
    try { if (needsRefresh()) refresh(); }   // re-check under the write lock
    finally { rw.writeLock().unlock(); }
}

// Often better than any lock: immutable snapshot swapped atomically
private volatile Map<String, Route> routes = Map.of();
Route lookup(String k) { return routes.get(k); }              // no lock at all
synchronized void publish(Map<String, Route> next) { routes = Map.copyOf(next); }

Interview questions and how to answer them

When does a read-write lock actually beat a plain mutex?

When reads heavily outnumber writes and each critical section is long enough that the lock bookkeeping is a small fraction of it. The reason the qualifier matters is that acquiring a read lock writes to a shared counter, so readers contend on the lock even when they do not contend on the data. For a short read — a map get, a couple of field reads — that shared counter update costs more than the exclusive lock it replaced. My default is to look for a design with no reader coordination at all: a concurrent collection, or an immutable snapshot behind a volatile reference.

What is writer starvation and how do you prevent it?

Under a reader-preference policy, new readers are admitted while a writer waits, so a continuous read stream means the writer never sees the reader count reach zero. The visible symptom is a configuration or cache refresh that silently never applies under load. Preventing it means changing the policy: writer preference, which blocks new readers once a writer queues, or the fair mode of `ReentrantReadWriteLock`, which queues both in arrival order. Both cost read throughput, which is the trade you are making explicitly.

Can you upgrade a read lock to a write lock?

Not in `ReentrantReadWriteLock` — the attempt deadlocks the calling thread, because the write lock waits for all readers to release and the caller is holding a read lock. Even in implementations that support it, upgrade is a deadlock risk whenever two threads try it simultaneously, since each waits for the other to drain. The correct pattern is release-then-acquire followed by re-checking the condition under the write lock, because the state can change during the interval where you hold nothing.

Explain the optimistic read mode of `StampedLock`.

`tryOptimisticRead` takes no lock; it returns a stamp representing the current write version. You read the fields into locals, then call `validate(stamp)`: if no write occurred in between, the values are consistent and you are done at essentially zero cost. If validation fails you discard the locals and retry under a real read lock. It is excellent for read-dominated, write-rare state. The constraints are that the optimistic section must be short and side-effect-free — you may be reading a torn state until validation — and that `StampedLock` is not reentrant, so a recursive path will deadlock.

You have a routing table read on every request and updated once a minute. What do you use?

Copy-on-write with an immutable map behind a volatile field. Readers do a volatile read and a map lookup, with no lock, no counter, and no contention — which is the best possible outcome for a per-request path. The writer builds a new immutable map and assigns the field, serialised by an ordinary lock since writes are rare. The cost is one full copy per update, which at once a minute is irrelevant. A read-write lock here would add reader contention for no benefit.

Answers that lose the round

  • Assuming a read-write lock is always faster than a mutex for read-heavy code; for short critical sections it is reliably slower
  • Attempting to upgrade a read lock to a write lock, which deadlocks the calling thread in `ReentrantReadWriteLock`
  • Releasing the read lock, taking the write lock, and writing without re-checking the condition
  • Leaving the default unfair policy on a structure whose writer must land promptly, then being unable to explain why the config update never applied
  • Doing I/O inside a read section, which blocks the writer and therefore every subsequent reader
  • Using a read-write lock to guard a `HashMap` where a `ConcurrentHashMap` needs no lock at all
  • Reaching for `StampedLock` without knowing it is not reentrant and that an invalid optimistic read must be retried under a real lock

Practise read-write locks in a real repository

Gronex ships this as a runnable repository: a shared registry behind one coarse lock, where every request queues on a hash lookup. The tests measure throughput under concurrent reads, so shrinking the critical section is not enough — the reader coordination has to go.

FAQ

Is `ConcurrentHashMap` a read-write lock internally?

No. Reads are entirely lock-free: it relies on volatile reads of the table and its nodes. Writes lock only the individual bin being modified, so two writers to different buckets do not contend. That is why it beats a read-write-locked `HashMap` on both read and write paths.

Does the read lock guarantee I see the latest write?

It guarantees you see all writes made by any writer that released the write lock before you acquired the read lock — that is the happens-before edge. It does not stop a writer from committing immediately after you release, so a value read under a read lock and used later is stale by construction. If the decision must be atomic with the read, it belongs inside the critical section.

What about read-write locks across processes, like in a database?

The same shapes recur with the same pathologies: shared versus exclusive row locks, writer starvation policies, and upgrade deadlocks when two transactions hold `FOR SHARE` and both attempt to write. The advice transfers directly — take the strong lock first if you intend to write.

Related

More backend concepts