W3docs

Java ReadWriteLock

Allow concurrent readers and exclusive writers in Java with ReadWriteLock — and when StampedLock is a better choice.

Java ReadWriteLock

A ReentrantLock (or a synchronized block) gives one thread exclusive access — readers and writers share the same slot. For read-heavy workloads that's wasteful: if a hundred threads want to read a value and one wants to write it, there's no real conflict among the readers, only between readers and the writer. The ReadWriteLock interface and its standard implementation ReentrantReadWriteLock carve the lock into two parts — a read lock that multiple threads can hold simultaneously, and a write lock that's exclusive against everything. Used correctly, it dramatically reduces contention. Used incorrectly, it's slower than a plain lock.

The interface

public interface ReadWriteLock {
  Lock readLock();
  Lock writeLock();
}

Two Locks, hard-wired together by their parent: the read lock and the write lock obey the rule that either the write lock is held by exactly one thread or the read lock is held by zero-or-more threads. Never both, never one of each.

The standard implementation:

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock r = rw.readLock();
Lock w = rw.writeLock();

Both locks have the standard Lock API — lock, tryLock, lockInterruptibly, unlock. The contract for the rw pair is:

  • Many threads can hold r at once. None of them block each other.
  • Only one thread can hold w at a time.
  • A thread trying to acquire w waits for all current readers to release.
  • Threads trying to acquire r wait if w is held or (depending on policy) if a writer is already queued.

The last point is the write-starvation prevention policy — covered below.

The cache use case

The textbook example. A cache that's read constantly and refreshed occasionally:

class ConfigCache {
  private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
  private final Lock r = rw.readLock();
  private final Lock w = rw.writeLock();
  private Map<String, String> data = new HashMap<>();

  public String get(String k) {
    r.lock();
    try {
      return data.get(k);                               // many readers, no contention
    } finally { r.unlock(); }
  }

  public void reload(Map<String, String> fresh) {
    w.lock();
    try {
      data = new HashMap<>(fresh);                       // exclusive: blocks readers and other writers
    } finally { w.unlock(); }
  }
}

Under a typical workload of 99% reads, this scales much better than a single ReentrantLock. Readers don't block each other; the rare writer briefly stops the world, then everybody continues.

The same try/finally discipline as Lock applies — every lock() must be paired with unlock() in a finally. The read lock is no more forgiving than the write lock about leaks.

Fairness and write starvation

ReentrantReadWriteLock has two policies:

new ReentrantReadWriteLock();          // non-fair (default)
new ReentrantReadWriteLock(true);      // fair (FIFO)

The default non-fair policy lets new incoming readers acquire even if a writer is already waiting — high throughput, but writers can be starved under continuous read load. The fair policy queues every requester in FIFO order: a waiting writer blocks subsequent readers, and the readers wait their turn.

The right default is still non-fair. If you observe writers in production sitting in the queue forever (one of the things getQueueLength exposes), switch to fair.

There's also a subtler protection. Even in non-fair mode, if a writer is "next in line" (in the head of the queue), incoming readers are blocked. This prevents the worst form of starvation; new readers can still barge if no writer is queued.

Lock downgrading: write → read

A useful trick: you can hold the write lock, acquire the read lock too, then release the write lock — without ever letting any other writer in. This is called downgrading:

w.lock();
try {
  data = recompute();                                   // exclusive write
  r.lock();                                              // before releasing w
} finally { w.unlock(); }
// now holding only r — readers can join, but no writer can sneak in until I release r
try {
  process(data);                                         // read-only work, multiple threads can do it
} finally { r.unlock(); }

The point of downgrading: do the actual mutation under the write lock, then continue reading the result without holding others out of the data. The intermediate "acquire read while holding write" works because the lock allows it — you're upgrading the write lock's reservation from "exclusive" to "shared with you specifically still allowed."

The reverse — upgrading read → write — does not work. Trying to acquire w while you hold r deadlocks: the write lock waits for all readers to release, and you're one of them. The lock will block you forever waiting for yourself.

r.lock();
try {
  if (needsRefresh()) {
    w.lock();                                            // DEADLOCK on the same thread
    ...
  }
} finally { r.unlock(); }

To go read → write you must release the read lock first, then acquire the write lock and re-check the condition (someone else may have refreshed while you were unlocked).

When ReadWriteLock beats ReentrantLock

A rough rule of thumb. ReentrantReadWriteLock wins when:

  • Reads vastly outnumber writes (say, 100:1 or more).
  • The read-protected section is non-trivial — long enough that letting many threads run it concurrently is a meaningful win.
  • The write is also long enough that briefly blocking readers is fine.

It loses (or breaks even) when:

  • Reads are extremely short (one map lookup). The lock-acquisition overhead is comparable to the work; you'd be better off with a plain ReentrantLock or an immutable snapshot via AtomicReference.
  • The reader/writer ratio isn't extreme.
  • You have many threads. The internal accounting that the read/write lock does to count readers gets more expensive as it scales. For very read-heavy data structures on many cores, StampedLock or copy-on-write is usually a better choice.

StampedLock — the modern alternative

Java 8 added java.util.concurrent.locks.StampedLock with three modes — write, read, and optimistic read. The optimistic mode lets a reader proceed without acquiring any lock; after the read, it verifies the value didn't change via a stamp. If it did, the reader falls back to a proper read-lock acquisition.

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
String val = data.get(k);                                // read without locking
if (!sl.validate(stamp)) {                                // somebody wrote during our read
  stamp = sl.readLock();
  try {
    val = data.get(k);                                    // re-read under proper lock
  } finally { sl.unlockRead(stamp); }
}

For read-dominated workloads, StampedLock is usually faster than ReentrantReadWriteLock. The cost: it's not reentrant, doesn't support Condition, and the API is much easier to misuse. Reach for it when you have a profiler pointing at a ReadWriteLock; default to ReentrantReadWriteLock for ergonomics.

A worked example: read-heavy cache, three contenders

The program below contrasts three implementations of the same read-heavy cache under 16 readers and 2 writers: a synchronized map, a ReentrantLock-guarded map, and a ReentrantReadWriteLock-guarded map.

java— editable, runs on the server

What to take from the run:

  • The synchronized and ReentrantLock caches were roughly equivalent in throughput — both treat every reader as exclusive, so 16 readers serialise on the one lock. The numbers are limited by how many get() calls fit through a single-threaded bottleneck.
  • The ReentrantReadWriteLock cache did substantially more reads in the same window. Readers no longer block each other; they all flow through r.lock() concurrently. The two writers still get exclusive access via w.lock() and don't break the data — they just briefly stall the read parade.
  • The win is bigger the longer each read takes. If the read were just a HashMap.get (nanoseconds), the lock-acquisition overhead approaches the work; the rwlock barely beats a plain lock. If the read is a more expensive lookup, search, or computation, the rwlock pulls way ahead. Profile first; the right choice depends on what's inside the lock.
  • The cost of ReadWriteLock is more state to maintain (a reader count, a writer wait, the policy) — every r.lock() is more expensive than a ReentrantLock.lock(). For low contention or very short critical sections, the simpler lock is faster. The rwlock isn't free.
  • Downgrading (wr → release w) is what production code uses when the rebuild has to happen under a write lock but the rest of the request can stay under a read lock. Upgrading the other way deadlocks; release r first, take w, re-check the world, then carry on.

What's next

The next chapter, Java Thread Pools, starts the high-level executor-framework story — the idea that you stop creating threads by hand and instead submit work to a pool that owns the threads for you.

Practice

Practice

You hold the read lock of a `ReentrantReadWriteLock` and decide to upgrade to the write lock by calling `w.lock()` while still holding `r`. What happens?