ReadWriteLock & StampedLock: Concurrent Reads, and When an Optimistic Read Fails

Part 6 of 8 in Java Concurrency: Deep Dive

ReentrantReadWriteLock and StampedLock both separate read access from write access, but in different ways. This post runs two small Java programs, each compiled and run for real, to show two readers actually holding a ReadWriteLock at the same time while a writer waits, and a StampedLock optimistic read’s validate() actually returning false after a concurrent write.

ReentrantReadWriteLock: two readers at once, a writer blocks both

ReadWriteLockDemo starts two reader threads that both request the read lock, hold it for 500ms, then release. Fifty milliseconds later a writer requests the write lock. Six hundred milliseconds in, while the writer should be holding the write lock, a third reader requests the read lock too.

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDemo {
    public static void main(String[] args) throws InterruptedException {
        ReadWriteLock rwLock = new ReentrantReadWriteLock();
        long start = System.nanoTime();

        Runnable reader = () -> {
            String name = Thread.currentThread().getName();
            long requestedAt = (System.nanoTime() - start) / 1_000_000;
            System.out.println(name + ": requesting read lock at " + requestedAt + "ms");
            rwLock.readLock().lock();
            try {
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println(name + ": acquired read lock at " + at + "ms");
                Thread.sleep(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                rwLock.readLock().unlock();
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println(name + ": released read lock at " + at + "ms");
            }
        };

        Thread reader1 = new Thread(reader, "reader-1");
        Thread reader2 = new Thread(reader, "reader-2");
        reader1.start();
        reader2.start();

        Thread.sleep(50); // let both readers request/acquire first
        Thread writer = new Thread(() -> {
            long requestedAt = (System.nanoTime() - start) / 1_000_000;
            System.out.println("writer: requesting write lock at " + requestedAt + "ms");
            rwLock.writeLock().lock();
            try {
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println("writer: acquired write lock at " + at + "ms");
                Thread.sleep(300);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                rwLock.writeLock().unlock();
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println("writer: released write lock at " + at + "ms");
            }
        });
        writer.start();

        Thread.sleep(600); // writer should be holding the write lock by now
        Thread reader3 = new Thread(reader, "reader-3");
        reader3.start();

        reader1.join();
        reader2.join();
        writer.join();
        reader3.join();
    }
}

Real javac+java output, JDK 25 (Zulu):

reader-2: requesting read lock at 4ms
reader-1: requesting read lock at 4ms
reader-2: acquired read lock at 8ms
reader-1: acquired read lock at 8ms
writer: requesting write lock at 56ms
reader-1: released read lock at 508ms
reader-2: released read lock at 508ms
writer: acquired write lock at 509ms
reader-3: requesting read lock at 656ms
reader-3: acquired read lock at 809ms
writer: released write lock at 809ms
reader-3: released read lock at 1309ms

reader-1 and reader-2 both acquired the read lock at 8ms — the same instant, both holding it concurrently until they released at 508ms. The writer requested the write lock at 56ms but didn’t acquire it until 509ms, right after both readers released — it waited for the read lock to be completely free. reader-3 requested at 656ms, while the writer still held the write lock (acquired 509ms, held until 809ms), and reader-3 didn’t acquire until 809ms, the moment the writer released.

StampedLock: an optimistic read that fails validation

StampedLockDemo runs two trials against a shared int. Trial 1 takes an optimistic read stamp, reads the value, and calls validate() right away with no write in between. Trial 2 takes another optimistic read stamp, reads the value, then a separate thread performs a real write between the read and the validate() call.

import java.util.concurrent.locks.StampedLock;

public class StampedLockDemo {
    static int sharedValue = 0;
    static final StampedLock lock = new StampedLock();

    public static void main(String[] args) throws InterruptedException {
        long start = System.nanoTime();

        // Trial 1: optimistic read with NO concurrent write -- validate() should succeed
        long stamp1 = lock.tryOptimisticRead();
        int readValue1 = sharedValue;
        boolean valid1 = lock.validate(stamp1);
        System.out.println("trial 1 (no concurrent write): read " + readValue1 + ", validate() = " + valid1);

        // Trial 2: optimistic read WITH a concurrent write in between read and validate
        long stamp2 = lock.tryOptimisticRead();
        int readValue2 = sharedValue;

        Thread writer = new Thread(() -> {
            long writeStamp = lock.writeLock();
            try {
                sharedValue = 42;
                long at = (System.nanoTime() - start) / 1_000_000;
                System.out.println("writer: wrote sharedValue=42 at " + at + "ms");
            } finally {
                lock.unlockWrite(writeStamp);
            }
        });
        writer.start();
        writer.join();

        boolean valid2 = lock.validate(stamp2);
        System.out.println("trial 2 (concurrent write happened): read " + readValue2 + ", validate() = " + valid2);

        if (!valid2) {
            long readStamp = lock.readLock();
            int freshValue;
            try {
                freshValue = sharedValue;
            } finally {
                lock.unlockRead(readStamp);
            }
            System.out.println("trial 2: re-read under a real read lock, got " + freshValue);
        }
    }
}

Real javac+java output, JDK 25 (Zulu):

trial 1 (no concurrent write): read 0, validate() = true
writer: wrote sharedValue=42 at 10ms
trial 2 (concurrent write happened): read 0, validate() = false
trial 2: re-read under a real read lock, got 42

Trial 1’s validate() returned true — nothing wrote to sharedValue between the optimistic read and the validate call, so the stamp was still good. Trial 2’s validate() returned false — the writer thread wrote 42 in between the optimistic read and the validate call, invalidating the stamp. The optimistic read itself had already read the stale value 0 into readValue2; only the follow-up read under a real readLock() picked up the actual current value, 42.

Takeaway

The ReadWriteLock run showed two readers genuinely overlapping (both acquired at 8ms, both released at 508ms) while a writer requested at 56ms but had to wait until 509ms, and a later reader requesting during the writer’s hold waited until the writer released at 809ms. The StampedLock run showed validate() returning true when no write happened between the optimistic read and the check, and false when one did — with the stale value (0) still sitting in the variable the optimistic read had already populated, until a real readLock() fetched the current one (42).