In-Depth Analysis of Java’s ReentrantReadWriteLock with Modern Enhancements

In multi-threaded applications, it’s common for read operations to outnumber write operations. While traditional mutual exclusion locks (e.g., ReentrantLock) ensure thread safety, they do so at the expense of performance, blocking all operations regardless of type. This results in unnecessary blocking between read-read operations, which are inherently safe to execute concurrently.

Java’s ReadWriteLock interface and its primary implementation, ReentrantReadWriteLock, offer a better solution by allowing concurrent reads and exclusive writes.

1. Overview of ReentrantReadWriteLock

ReentrantReadWriteLock provides two distinct lock views:

This design is ideal for applications with mostly read operations.

2. Basic Usage Example

private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();

public void read() {
    r.lock();
    try {
        // Perform read
    } finally {
        r.unlock();
    }
}

public void write() {
    w.lock();
    try {
        // Perform write
    } finally {
        w.unlock();
    }
}

3. Internal Implementation and Source Code Analysis

3.1 Lock Construction

public ReentrantReadWriteLock() {
    this(false); // Default: non-fair lock
}

public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

The constructor allows specification of fairness. Internally, it initializes either a FairSync or NonfairSync strategy.

3.2 Lock Views

protected ReadLock(ReentrantReadWriteLock lock) {
    sync = lock.sync;
}

protected WriteLock(ReentrantReadWriteLock lock) {
    sync = lock.sync;
}

Both read and write locks are interfaces to the same synchronization mechanism.

3.3 State Variable Design

The internal state variable is a 32-bit integer:

This division enables atomic updates using CAS operations.

3.4 ThreadLocalHoldCounter

ThreadLocalHoldCounter maintains a separate counter for each thread, tracking the number of read locks it holds. This ensures that each thread can only release the locks it has acquired itself.

3.5 Acquiring the Read Lock

tryAcquireShared(int unused) attempts to acquire the shared read lock without blocking. It is the first checkpoint to determine whether a read lock can be granted immediately.

protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState(); //Retrieves the current thread and the lock state. 
    if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
    //If the write lock is held by another thread, 
    //the current thread cannot proceed with the read lock.
        return -1;
    int r = sharedCount(c);//Extracts the current read lock count from the state.
    //The thread doesn't need to block (based on fairness policy)
    //The read count is below the limit
    //The CAS operation successfully increments the read lock count
    if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) {
        //If the current thread is the first to acquire the read lock, 
        //or if it's reentering, update its hold count.
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {// Use ThreadLocalHoldCounter to track the read lock count for this thread
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

If tryAcquireShared() fails, the thread enters a spin-and-wait state in doAcquireShared(). It enqueues the current thread and manages the wait until the lock can be granted.

private void doAcquireShared(int arg) {
    //Adds the current thread to the synchronization queue in shared mode
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                //If this thread is next in line (directly after the head), 
                //it reattempts to acquire the read lock.
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    //If successful, it promotes itself as the head 
                    //and potentially wakes up subsequent threads
                    setHeadAndPropagate(node, r);
                    p.next = null;
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            //If not ready to acquire the lock,
            //the thread is parked (i.e., blocked) until it is signaled.
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }//for
    } finally {
        if (failed)
        //In case of interruption or failure, 
        //the node is removed from the queue to avoid blocking other threads.
            cancelAcquire(node);
    }
}

3.6 Releasing the Read Lock

doReleaseShared() is responsible for waking up the next thread(s) in the queue after a shared lock (typically a read lock) has been released. It is part of the shared-mode lock release mechanism in the AbstractQueuedSynchronizer (AQS) framework.

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        //Checks if the queue is not empty. 
        //Only proceeds if there are other waiting threads.
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                //If the head node is in SIGNAL state, it tries to reset the status to 0.
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;
                //If successful, 
                //it wakes up the next node in the queue using unparkSuccessor(h)
                unparkSuccessor(h);
            } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                //If the status is 0, 
                //it tries to change it to PROPAGATE to ensure 
                //that further propagation happens for shared access.
                continue;
        }
        ////If the head has not changed during the loop, the process is complete
        if (h == head)
            break;
    }//for
}

3.7 Acquiring and Releasing the Write Lock

acquire(int arg) is used to acquire a lock in exclusive mode, typically for write operations.

release(int arg) releases an exclusive lock and potentially wakes up the next waiting thread.

public final void acquire(int arg) {
    //First, it calls tryAcquire(arg) to attempt acquiring the lock immediately.
    //If successful, the method returns and the lock is acquired.
    //If the lock is not immediately available,
    
    //addWaiter(Node.EXCLUSIVE) creates a new node in exclusive mode 
    //and adds it to the waiting queue.
    
    //acquireQueued() blocks the thread in a queue until the lock becomes available.
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        //If the thread is interrupted during this wait, 
        //selfInterrupt() sets the interrupt flag
        selfInterrupt();
}

public final boolean release(int arg) {
    //tryRelease(arg) decreases the lock count 
    //or fully releases it if this is the last hold.
    if (tryRelease(arg)) {//If the lock is fully released
        Node h = head;
        //Checks the head of the queue,
        //If there are waiting threads (waitStatus != 0)
        if (h != null && h.waitStatus != 0)
            //unparkSuccessor(h) wakes up the next node in the queue.
            unparkSuccessor(h);
        return true;
    }
    //Returns true if the lock was released, otherwise false.
    return false;
}

3.8 Lock Downgrading and Upgrading

4. Performance Considerations and Modern Enhancements

4.1 Writer Starvation

When many threads hold the read lock, a write thread may be indefinitely blocked, leading to starvation.

4.2 Java 8: StampedLock

Java 8 introduced StampedLock, which supports:

4.3 Java 21: Virtual Threads

With virtual threads (Project Loom), lock contention may seem reduced due to cheaper thread switching. However, ReentrantReadWriteLock behavior remains unchanged internally and must still be used carefully to avoid starvation.

5. Best Practices