Java ReadWriteLock

In the last lesson you learned about the Java Lock interface and ReentrantLock. A ReentrantLock lets only one thread in at a time, treating reads and writes the same way. That feels wasteful when most threads only want to look at the data. Java’s answer is the ReadWriteLock, which splits one lock into two so readers can share.

πŸ€” The problem: readers don’t need to wait for each other

Some data is read all the time but changed almost never. Think exchange rates, app settings, or a cache of user profiles. A plain lock treats every reader like a writer:

  • A plain lock lets only one thread in at a time.
  • So reader two waits for reader one, even though both only look.
  • Reading together is always safe, because nobody is changing the data.
  • You only need exclusive access when a thread writes.
  • Making readers wait buys correctness you did not need and costs speed you did want.

The example below shows the slow pattern. Every call takes the same exclusive lock, even the read:

import java.util.concurrent.locks.ReentrantLock;
class Config {
private final ReentrantLock lock = new ReentrantLock();
private String value = "default";
public String read() {
lock.lock(); // ❌ readers block each other for no reason
try {
return value;
} finally {
lock.unlock();
}
}
public void write(String newValue) {
lock.lock();
try {
value = newValue;
} finally {
lock.unlock();
}
}
}

The write method holding an exclusive lock is fine. The read method holding one is the waste. We need a lock that knows the difference between reading and writing.

πŸ”‘ An analogy: a whiteboard in a meeting room

Picture a whiteboard on a wall. The board is your shared data:

  • Readers crowd around and copy what it says. Many can read at once, because reading changes nothing.
  • A writer erases the board and writes something new. Everyone else must step back, or they copy half-erased nonsense.

That is a ReadWriteLock. Many readers share the read side. One writer owns the write side alone. But readers never wait for other readers. That is the whole win.

🧩 What is a ReadWriteLock?

A ReadWriteLock is not one lock. It is a pair that work together:

  • The read lock is shared. Any number of threads can hold it, as long as no thread holds the write lock.
  • The write lock is exclusive. Only one thread holds it, and while it is held, nobody can hold the read lock.
  • The rule that ties it together: read locks share with each other, the write lock shares with nothing.

The standard implementation is ReentrantReadWriteLock, in java.util.concurrent.locks. You create one object, then ask it for its two locks:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock(); // the shared read lock
rwLock.writeLock(); // the exclusive write lock

The word reentrant means the same thread can take a lock it already holds without freezing itself. So a thread holding the write lock can take it again safely.

πŸ”’ Using the read and write locks

You use each lock like ReentrantLock: call lock(), do the work, unlock() in a finally block. The finally releases the lock even on an exception, so no thread waits forever.

The example below protects a shared number. Reads use the read lock, the write uses the write lock:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Counter {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private int count = 0;
public int get() {
rwLock.readLock().lock(); // many readers allowed together
try {
return count;
} finally {
rwLock.readLock().unlock(); // βœ… always unlock in finally
}
}
public void increment() {
rwLock.writeLock().lock(); // only one writer, alone
try {
count++;
} finally {
rwLock.writeLock().unlock(); // βœ… always unlock in finally
}
}
}

Walking through it:

  • get takes the shared read lock, so five threads calling get run together.
  • increment takes the exclusive write lock, so while one thread writes, nobody reads or writes.
  • Each method unlocks in finally, so an exception never leaves a lock stuck.
  • Result: frequent reads no longer block each other, rare writes still get full safety.

πŸ—‚οΈ A worked example: a thread-safe cache

The classic use is a cache or config read often and written rarely. The class below stores settings in a map. Reading takes the read lock, so lookups run at once. Updating takes the write lock, so it happens alone:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class SettingsCache {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Map<String, String> data = new HashMap<>();
public String get(String key) {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " reads " + key);
return data.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, String value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " writes " + key);
data.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
SettingsCache cache = new SettingsCache();
cache.put("theme", "dark");
Runnable reader = () -> {
for (int i = 0; i < 2; i++) cache.get("theme");
};
Thread r1 = new Thread(reader, "reader-1");
Thread r2 = new Thread(reader, "reader-2");
Thread w1 = new Thread(() -> cache.put("theme", "light"), "writer-1");
r1.start();
r2.start();
w1.start();
r1.join();
r2.join();
w1.join();
System.out.println("Final theme: " + cache.get("theme"));
}
}

Here the main thread puts an initial theme. Two reader threads look it up twice each, and one writer changes it. Readers run together, the writer goes alone. The exact order shifts from run to run, but the writer is always exclusive:

Output

reader-1 reads theme
reader-2 reads theme
writer-1 writes theme
reader-1 reads theme
reader-2 reads theme
Final theme: light

The two readers overlap freely, while the writer always gets the data to itself. For a read-heavy store, that is a real speed boost over a plain lock.

HashMap is fine here because the lock guards it

A plain HashMap is not thread-safe on its own. It is safe in this example only because every access goes through the read or write lock. The lock is what makes it safe, not the map. If you skip the lock on even one path, the safety is gone.

⬇️ Lock downgrading (a brief note)

One neat trick. A thread holding the write lock can grab the read lock before releasing the write lock. This is lock downgrading:

  • It goes from the exclusive write lock down to the shared read lock without ever letting go.
  • Use it when a thread writes a value, then wants to keep reading it while letting other readers back in.
  • No gap appears where another writer could sneak in first.

The example below writes a value, then downgrades to read it safely:

rwLock.writeLock().lock();
try {
data.put("theme", "light"); // write the new value
rwLock.readLock().lock(); // take read lock BEFORE releasing write
} finally {
rwLock.writeLock().unlock(); // now holding only the read lock
}
try {
System.out.println(data.get("theme")); // read it, others may read too
} finally {
rwLock.readLock().unlock();
}

Grab the read lock while you still hold the write lock, then release the write lock. The reverse, read up to write, is not allowed and freezes your thread. Downgrading is fine, upgrading is not.

βš–οΈ When NOT to use a ReadWriteLock

A ReadWriteLock does more bookkeeping than a plain lock, because it tracks active readers and coordinates them with writers. That cost only pays off when reads heavily outnumber writes:

  • Use it when data is read often and written rarely, like a cache, config, or lookup table.
  • Skip it when data is written often. The write lock stays exclusive most of the time, so readers rarely share, and you pay overhead for nothing.
  • For write-heavy data, a plain ReentrantLock is simpler and often faster.
  • For a simple counter, an atomic variable beats both, which is where we head next.

⚠️ Common Mistakes

A few ReadWriteLock traps to avoid.

  • Forgetting to unlock. If you do not release a lock, every thread that needs it waits forever. Always unlock in a finally, so even an exception releases the lock.
// ❌ Wrong: if an exception is thrown, the lock is never released
rwLock.readLock().lock();
String v = data.get(key);
rwLock.readLock().unlock();
// βœ… Right: finally guarantees the unlock runs
rwLock.readLock().lock();
try {
return data.get(key);
} finally {
rwLock.readLock().unlock();
}
  • Trying to upgrade a read lock to a write lock. Taking the write lock while you already hold the read lock will freeze your thread. The write lock waits for all readers to leave, but you are one of those readers, so you wait for yourself forever.
// ❌ Wrong: holding read, then asking for write -> deadlocks itself
rwLock.readLock().lock();
rwLock.writeLock().lock(); // waits for the read lock you are holding
// βœ… Right: release the read lock first, then take the write lock
rwLock.readLock().unlock();
rwLock.writeLock().lock();
try {
data.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
  • Using it for write-heavy data. If writes are frequent, the write lock is held most of the time and readers almost never overlap. You get the cost of a ReadWriteLock with none of the benefit. Use a plain lock instead.

  • Unlocking the wrong lock. The read lock and write lock are separate. If you lock() the read lock you must unlock() the read lock, not the write lock. Mixing them up leaves a lock held and breaks everything.

βœ… Best Practices

Habits for safe, fast read-write locking.

  • Always unlock in a finally block. Pair every lock() with an unlock() that is guaranteed to run.
  • Use the read lock for reads and the write lock for writes. Do not take the write lock just to read; that throws away the whole benefit.
  • Reach for it only when reads greatly outnumber writes. A cache or config is the sweet spot. Write-heavy data should use a plain lock.
  • Never try to upgrade read to write. Release the read lock first, then take the write lock, or use downgrading instead.
  • Keep the locked section small. Do only the read or write inside the lock, and leave slow or unrelated work outside it.
  • Store the lock as a private final field. One shared lock object that nobody outside the class can grab keeps your timing under your control.
  • Guard every access to the shared data. A non-thread-safe collection like HashMap is only safe if every path goes through the lock.

🧩 What You’ve Learned

Nicely done. Let’s recap the ReadWriteLock.

  • βœ… A ReadWriteLock splits one lock into a shared read lock and an exclusive write lock.
  • βœ… Many readers can hold the read lock at once, but the write lock is held by one thread alone.
  • βœ… This boosts speed for read-heavy data, because readers no longer wait for each other.
  • βœ… Use readLock().lock()/unlock() and writeLock().lock()/unlock(), always unlocking in a finally block.
  • βœ… A cache or config read often and written rarely is the ideal use case.
  • βœ… Lock downgrading (write to read) is allowed; upgrading (read to write) freezes the thread.
  • βœ… For write-heavy data, a plain ReentrantLock is simpler and often faster.

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    How does a ReadWriteLock differ from a plain lock?

    Why: A ReadWriteLock has two locks: a shared read lock many threads can hold, and an exclusive write lock held by one thread.

  2. 2

    How many threads can hold the read lock at the same time?

    Why: The read lock is shared, so many readers can hold it together, but not while a writer holds the write lock.

  3. 3

    When is a ReadWriteLock the best choice?

    Why: Read-heavy data, like a cache or config, benefits most because readers run in parallel.

  4. 4

    What happens if a thread holding the read lock tries to take the write lock?

    Why: Upgrading read to write deadlocks the thread; the write lock waits for the read lock the same thread still holds.

πŸš€ What’s Next?

A ReadWriteLock is powerful, but for a simple counter or flag it is more than you need. Java has lighter tools that make single values safe without any lock, using one unbreakable hardware step. Next you will learn about atomic variables like AtomicInteger, how they work, and when they beat locks. Let’s learn it.

Java Atomic Variables

Share & Connect