☕ Java

ReadWriteLock

ReadWriteLock is an interface in java.util.concurrent.locks that maintains a pair of associated locks — one for read-only operations and one for write operations. The read lock may be held simultaneously by multiple reader threads as long as no writer holds the write lock. The write lock is exclusive: while a writer holds it, no other readers or writers may proceed. This segregation of read and write access enables much higher throughput than a mutual exclusion lock when reads are frequent and writes are infrequent — the common pattern for caches, configuration objects, routing tables, and reference data. The standard implementation is ReentrantReadWriteLock, which supports reentrancy for both locks, optional fairness, lock downgrading from write to read, and rich introspection. This entry covers the ReadWriteLock contract and when it outperforms a plain lock, ReentrantReadWriteLock's reentrancy and fairness behavior, lock downgrading, the write-lock starvation problem, lock upgrading (and why it is not supported), performance characteristics, and patterns for cache and configuration management.

ReadWriteLock Contract and ReentrantReadWriteLock

The ReadWriteLock interface exposes two methods: readLock() returns the Lock used for read operations, and writeLock() returns the Lock used for write operations. The contract between the two locks: multiple threads may hold the read lock simultaneously (shared acquisition); at most one thread may hold the write lock at any time, and only when no thread holds the read lock (exclusive acquisition); while the write lock is held, no thread may acquire the read lock. This allows reads to run in parallel while writes remain exclusive. ReentrantReadWriteLock implements ReadWriteLock and adds reentrancy, fairness control, lock downgrading, and inspection. It is constructed as new ReentrantReadWriteLock() (unfair) or new ReentrantReadWriteLock(true) (fair). The two locks share a single AQS state integer — the upper 16 bits track the read hold count (number of readers) and the lower 16 bits track the write hold count (write reentrancy depth). This allows efficient atomic checks of combined state. Reentrancy for the write lock: a thread that holds the write lock can call writeLock().lock() again without blocking, incrementing the hold count. The write lock is fully released when the hold count reaches zero. Reentrancy for the read lock: a thread that holds the read lock can call readLock().lock() again — the per-thread read hold count is tracked in a ThreadLocal. A thread that holds the write lock can also acquire the read lock — this is lock downgrading, described below. But a thread that holds only the read lock cannot acquire the write lock — this would be lock upgrading, which is not supported by ReentrantReadWriteLock and will deadlock: the upgrading thread would block waiting for all readers to release, but it is itself a reader that never releases. The read lock does not support Condition objects. Calling readLock().newCondition() throws UnsupportedOperationException. Conditions are only meaningful for exclusive locks. If waiting on a condition associated with a shared data structure is needed, use a separate object lock or use the write lock.
Java
// ── Basic ReadWriteLock usage ─────────────────────────────────────────
ReentrantReadWriteLock rwLock   = new ReentrantReadWriteLock();
Lock readLock  = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// ── Read operation: multiple readers can proceed simultaneously ────────
readLock.lock();
try {
    // Any number of threads can be here simultaneously:
    System.out.println("Reading: " + sharedData);
} finally {
    readLock.unlock();   // always unlock in finally
}

// ── Write operation: exclusive — no other readers or writers ──────────
writeLock.lock();
try {
    // Only one thread here at a time; all readers blocked:
    sharedData = "new value";
} finally {
    writeLock.unlock();
}

// ── Concurrent read demonstration ─────────────────────────────────────
String[] data = {"initial"};
ReentrantReadWriteLock dataLock = new ReentrantReadWriteLock();

Runnable reader = () -> {
    dataLock.readLock().lock();
    try {
        System.out.println(Thread.currentThread().getName() + " reading: " + data[0]);
        Thread.sleep(200);   // simulate slow read — other readers proceed simultaneously
        System.out.println(Thread.currentThread().getName() + " done reading");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        dataLock.readLock().unlock();
    }
};

// All three readers run concurrently (total time ~200ms, not ~600ms):
Thread r1 = new Thread(reader, "Reader-1");
Thread r2 = new Thread(reader, "Reader-2");
Thread r3 = new Thread(reader, "Reader-3");
r1.start(); r2.start(); r3.start();
r1.join();  r2.join();  r3.join();
// Reader-1 reading: initial
// Reader-2 reading: initial    ← all three start nearly simultaneously
// Reader-3 reading: initial
// Reader-1 done reading
// Reader-2 done reading
// Reader-3 done reading

// ── Read lock does NOT support Condition ─────────────────────────────
try {
    dataLock.readLock().newCondition();  // UnsupportedOperationException
} catch (UnsupportedOperationException e) {
    System.out.println("Read lock has no Condition support");
}

// ── ReentrantReadWriteLock introspection ─────────────────────────────
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock();

System.out.println("Read lock count: "  + rw.getReadLockCount());     // 1
System.out.println("Read hold count: "  + rw.getReadHoldCount());     // 1 (this thread)
System.out.println("Write locked: "     + rw.isWriteLocked());        // false
System.out.println("Write hold count: " + rw.getWriteHoldCount());    // 0

rw.readLock().unlock();

Lock Downgrading, Starvation, and Performance

Lock downgrading is the process of holding a write lock, acquiring the read lock while still holding the write lock, then releasing the write lock. The result is that the thread transitions from exclusive write access to shared read access without any window during which another writer could interpose. Downgrading is necessary in cache update patterns: a thread must compute a new value (which only it should see), update the cache (requiring write access), and then continue reading from the cache (requiring only read access). If the thread releases the write lock and then acquires the read lock, another writer could change the cache between those two operations, causing the thread to read a value it did not compute or expect. Lock upgrading — acquiring a write lock while holding a read lock — is not supported and causes deadlock. If a thread holds the read lock and calls writeLock().lock(), the write acquisition blocks until all readers (including this thread's read lock) are released. But this thread is waiting for the write lock before it releases its read lock — deadlock. The solution is to always release the read lock before acquiring the write lock, accepting the window where another writer could intervene, and re-checking the state after acquiring the write lock. Writer starvation in unfair mode can occur when a continuous stream of readers keeps the read lock held, preventing a waiting writer from ever acquiring the write lock. In unfair ReentrantReadWriteLock, a new reader that arrives while other readers hold the lock can barge ahead of a waiting writer, joining the current read generation rather than waiting. If the stream of readers is continuous, the writer never gets a turn. This is the write-starvation problem. In fair mode (new ReentrantReadWriteLock(true)), incoming readers queue up behind waiting writers, preventing barging. This guarantees writers are not starved, but at the cost of reader concurrency — readers must queue even when no writer is actively holding the lock, just a writer is queued. The tradeoff: fair mode prevents starvation but reduces the throughput advantage of the read lock.
Java
// ── Lock downgrading: write → read without window ─────────────────────
public class CachedData {
    private volatile boolean dataValid = false;
    private Object           cachedData;
    private final ReentrantReadWriteLock rwl  = new ReentrantReadWriteLock();
    private final Lock                   read  = rwl.readLock();
    private final Lock                   write = rwl.writeLock();

    public Object getData() {
        read.lock();                      // acquire read lock first
        if (!dataValid) {
            read.unlock();                // release read — must do before acquiring write
            write.lock();                 // acquire write — exclusive
            try {
                // Re-check condition: another thread may have updated while we waited
                if (!dataValid) {
                    cachedData = fetchData();   // only this thread here — update cache
                    dataValid  = true;
                }
                read.lock();             // DOWNGRADE: acquire read while still holding write
            } finally {
                write.unlock();          // release write — readers can now proceed
            }
            // Now hold only read lock — downgrade complete, no window for other writers
        }
        try {
            return cachedData;           // safely read the just-updated (or pre-existing) value
        } finally {
            read.unlock();
        }
    }

    private Object fetchData() { return new Object(); }  // simulated fetch
}

// ── Lock UPGRADING causes deadlock — DO NOT DO ────────────────────────
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock();
try {
    // rw.writeLock().lock();  // DEADLOCK: waiting for write, but holding read
    //                         // write waits for all readers (including us) to release
    //                         // we never release read because we're stuck waiting for write
} finally {
    rw.readLock().unlock();
}

// CORRECT pattern for conditional upgrade: release read, acquire write, re-check:
rw.readLock().lock();
boolean needsUpdate;
try {
    needsUpdate = !dataValid;
} finally {
    rw.readLock().unlock();      // release read before acquiring write
}

if (needsUpdate) {
    rw.writeLock().lock();
    try {
        if (!dataValid) {        // re-check: another thread may have updated meanwhile
            cachedData = fetchData();
            dataValid  = true;
        }
    } finally {
        rw.writeLock().unlock();
    }
}

// ── Writer starvation in unfair mode ──────────────────────────────────
// Unfair RWL: barging readers can continuously deny the waiting writer:
ReentrantReadWriteLock unfair = new ReentrantReadWriteLock(false);

// Scenario: readers R1-R100 keep arriving continuously.
// Writer W1 is waiting. In unfair mode:
//   R2 sees "read lock held by R1" and joins (barges ahead of W1).
//   R3 arrives, joins. ... R100 arrives, joins.
//   W1 is never granted the write lock — starved.

// Fix: fair mode ensures queued writer is served before new readers:
ReentrantReadWriteLock fair = new ReentrantReadWriteLock(true);
// With fair=true: new readers queue behind W1 once W1 is waiting.
// Trade-off: lower read concurrency, but no writer starvation.

// ── Performance: when ReadWriteLock helps ─────────────────────────────
// ReadWriteLock is beneficial ONLY when:
//   1. Reads are much more frequent than writes
//   2. Read operations take non-trivial time (hold the lock long enough to matter)
//   3. Multiple readers can actually run concurrently (multi-core)

// Rule of thumb: benefit threshold ~80-90% reads, 10-20% writes on multi-core.
// Below that threshold, the overhead of separate read/write tracking may HURT performance.

// Benchmark scenario (read:write = 95:5, 8 threads):
//   synchronized:       ~50ms for 1M operations
//   ReentrantLock:      ~45ms
//   ReentrantReadWriteLock: ~15ms  ← significant win when reads dominate

Practical Patterns — Cache, Configuration, and Routing Tables

The canonical use case for ReadWriteLock is a read-heavy cache or reference data store: data is loaded or computed infrequently but read by many threads continuously. The write lock protects updates; the read lock allows concurrent reads. The pattern is so common that it has a standard implementation: a map protected by a ReadWriteLock, with a get() method that acquires the read lock and a put() method that acquires the write lock. A configuration object that is reloaded periodically (from a file, database, or remote service) but read on every request is another canonical use case. The reload thread acquires the write lock, replaces the configuration object, and releases the write lock. Request-handling threads acquire the read lock, read the configuration, and release it. With a plain synchronized lock, all request threads would block each other even though they are all reading the same immutable configuration snapshot. A routing table (a map from destinations to routes, as in a network router or load balancer) is frequently read for every packet or request but occasionally updated when topology changes. ReadWriteLock allows all routing lookups to proceed in parallel while updates are applied exclusively. ConcurrentHashMap is an alternative for this use case — it provides even finer-grained concurrency without a global lock — but ReadWriteLock is appropriate when the update is a non-atomic replacement of the entire table or a complex multi-step modification. StampedLock (introduced in Java 8) is an advanced alternative to ReentrantReadWriteLock for read-heavy workloads. It adds an optimistic read mode that does not acquire any lock — a reader reads the data and then validates (using a stamp returned by tryOptimisticRead()) that no write occurred during the read. If validation succeeds, the read is complete without any lock acquisition, eliminating all contention for the common case. If validation fails (a write occurred), the thread falls back to a regular read lock. Optimistic reads provide maximum throughput when contention is low and validation is cheap.
Java
// ── Read-heavy cache pattern ─────────────────────────────────────────
public class ReadHeavyCache<K, V> {
    private final Map<K, V>             cache   = new HashMap<>();
    private final ReentrantReadWriteLock rwl     = new ReentrantReadWriteLock();
    private final Lock                   readLock = rwl.readLock();
    private final Lock                   writeLock= rwl.writeLock();

    public V get(K key) {
        readLock.lock();
        try {
            return cache.get(key);       // multiple readers proceed concurrently
        } finally {
            readLock.unlock();
        }
    }

    public void put(K key, V value) {
        writeLock.lock();
        try {
            cache.put(key, value);       // exclusive — all readers blocked during write
        } finally {
            writeLock.unlock();
        }
    }

    public V computeIfAbsent(K key, Function<K, V> loader) {
        // Optimistic: check with read lock first
        readLock.lock();
        try {
            V existing = cache.get(key);
            if (existing != null) return existing;
        } finally {
            readLock.unlock();
        }
        // Not found: upgrade to write lock to compute and insert
        writeLock.lock();
        try {
            // Re-check: another thread may have inserted while we waited for write lock
            return cache.computeIfAbsent(key, loader);
        } finally {
            writeLock.unlock();
        }
    }

    public int size() {
        readLock.lock();
        try { return cache.size(); }
        finally { readLock.unlock(); }
    }
}

// ── Reloadable configuration ──────────────────────────────────────────
public class Configuration {
    private volatile Map<String, String> config = Collections.emptyMap();
    private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();

    // Called by request-handling threads — many concurrent readers:
    public String get(String key) {
        rw.readLock().lock();
        try {
            return config.getOrDefault(key, "");
        } finally {
            rw.readLock().unlock();
        }
    }

    // Called periodically by a reload thread — exclusive:
    public void reload(Map<String, String> newConfig) {
        rw.writeLock().lock();
        try {
            config = Collections.unmodifiableMap(new HashMap<>(newConfig));
            System.out.println("Configuration reloaded: " + config.size() + " keys");
        } finally {
            rw.writeLock().unlock();
        }
    }
}

// ── StampedLock: optimistic reads for maximum throughput ──────────────
import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    public void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();    // exclusive write lock
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();   // no lock acquired — stamp only
        double curX = x, curY = y;             // read fields optimistically

        if (!sl.validate(stamp)) {             // check if a write occurred
            // Optimistic read failed — fall back to read lock:
            stamp = sl.readLock();
            try {
                curX = x;
                curY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        // Optimistic read succeeded — no lock was ever held:
        return Math.sqrt(curX * curX + curY * curY);
    }
}

// Performance profile: StampedLock optimistic reads have near-zero overhead
// when writes are rare — no CAS, no volatile read on the happy path.
// Suitable for: geospatial data, financial pricing, ML model inference.

Related Topics in Multithreading

Process vs Thread
A process is an independent program in execution with its own isolated memory space, file handles, and system resources, managed by the operating system and separated from all other processes by strict boundaries. A thread is a unit of execution that lives inside a process, sharing that process's memory, heap, and resources with every other thread in the same process. Java programs run inside a JVM process; the JVM itself creates and manages threads, and every Java application starts with at least one thread — the main thread — with additional threads created by the JVM for garbage collection, JIT compilation, signal handling, and other runtime tasks. Understanding the distinction between processes and threads is the foundation for all concurrent programming in Java: it determines what is shared and what is isolated, what is fast and what is expensive, what fails independently and what fails together. This entry covers the OS-level and JVM-level model of processes and threads, the memory model that follows from the shared-versus-isolated distinction, the cost model for creation and context switching, failure isolation and its consequences, inter-process and inter-thread communication mechanisms, and the practical decision of when to use multiple processes versus multiple threads.
Thread Basics
A Java thread is an instance of java.lang.Thread that represents an independent path of execution within the JVM process. Every thread has a lifecycle — from creation through runnable, running, blocked, waiting, timed-waiting, and terminated states — and a set of properties including its name, priority, daemon status, thread group, and uncaught exception handler. The Java memory model specifies what visibility guarantees exist between threads and when writes by one thread are guaranteed to be visible to another. Thread scheduling is controlled by the OS scheduler subject to hints from the JVM via thread priority; the JVM does not provide real-time scheduling guarantees. This entry covers the complete thread lifecycle and its state machine, thread properties and how they affect scheduling and JVM shutdown, the happens-before relationship and why it matters for visibility, daemon threads and their relationship to JVM shutdown, thread interruption as a cooperative cancellation mechanism, and the methods on Thread that every Java developer must understand.
Creating Threads
Java provides three primary abstractions for defining the work a thread will execute: the Thread class itself (subclassed to override run()), the Runnable interface (a task with no return value and no checked exception), and the Callable interface (a task with a return value and a declared checked exception). Each represents a different contract between the task and the infrastructure that runs it. Thread subclassing couples the task definition to the execution mechanism and is the oldest and least flexible approach. Runnable decouples the task from the thread, allowing the same Runnable to be submitted to thread pools, scheduled executors, or wrapped in Thread objects. Callable extends that decoupling to include a return value and exception propagation, returning a Future that allows the caller to retrieve the result or handle exceptions asynchronously. Understanding all three — their contracts, their limitations, and when to use each — is the foundation of concurrent programming in Java before reaching for higher-level constructs.
Thread Lifecycle
The Java thread lifecycle is the complete sequence of states a thread passes through from the moment a Thread object is constructed to the moment its execution ends. Java defines six states in the Thread.State enum — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and the JVM transitions threads between these states in response to specific method calls, lock acquisitions, monitor notifications, timeouts, and exceptions. Each state has a precise meaning, a defined set of entry conditions, and a defined set of exit conditions. Understanding the lifecycle in full is prerequisite knowledge for diagnosing deadlocks, thread leaks, performance bottlenecks in thread dumps, and incorrect synchronization — all of which manifest as threads stuck in specific states. This entry covers every state in the lifecycle with its entry and exit conditions, all legal and illegal state transitions, how thread dumps represent each state, the interaction between lifecycle states and interruption, the effect of uncaught exceptions on lifecycle, and how to observe lifecycle transitions programmatically.