ReentrantReadWriteLock
A reentrant read-write lock that allows multiple concurrent readers OR a single exclusive writer. Built on a single AQS int state split into two 16-bit halves --- upper bits for shared (read) count, lower bits for exclusive (write) count.
Document Structure:
- [Basic Usage](#Basic Usage) --- Quick start
- [Packed State](#Packed State) --- How one
intencodes both read and write counts - [Read Lock Deep Dive](#Read Lock Deep Dive) ---
tryAcquireShared, per-thread hold counts,fullTryAcquireSharedslow path - [Write Lock Deep Dive](#Write Lock Deep Dive) ---
tryAcquire, reentrancy, why read-to-write upgrade deadlocks - [Lock Release](#Lock Release) ---
tryReleaseShared(read),tryRelease(write), wake-up behavior - [Lock Downgrade](#Lock Downgrade) --- Safe write-to-read transition with timeline
- [Fair vs Non-Fair](#Fair vs Non-Fair) ---
readerShouldBlock(),writerShouldBlock(), writer starvation - [Memory Ordering and Happens-Before](#Memory Ordering and Happens-Before) --- Visibility guarantees, volatile state, CAS memory effects
- [Condition Support](#Condition Support) --- Write lock only, why read lock can't have conditions
- [tryLock and Interruptible Acquisition](#tryLock and Interruptible Acquisition) --- Non-blocking and timed acquisition, barging semantics
- [Diagnostic and Monitoring APIs](#Diagnostic and Monitoring APIs) --- Hold counts, queue inspection, debugging tools
- [Common Pitfalls and Anti-Patterns](#Common Pitfalls and Anti-Patterns) --- Mistakes beyond lock upgrade
- [Common Patterns](#Common Patterns) --- Cache, config reload, lock downgrade for publish
- [Performance Characteristics](#Performance Characteristics) --- When RRWL wins, when it loses, benchmarking guidance
- FAQ --- Common questions answered
Basic Usage
java
ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Multiple readers can hold simultaneously
rwLock.readLock().lock();
try { /* read data */ }
finally { rwLock.readLock().unlock(); }
// Writer is exclusive --- blocks all readers and other writers
rwLock.writeLock().lock();
try { /* modify data */ }
finally { rwLock.writeLock().unlock(); }
Core rules:
- Multiple threads can hold the read lock simultaneously (shared mode)
- Only one thread can hold the write lock (exclusive mode)
- Read lock and write lock are mutually exclusive (except for lock downgrade by the same thread)
- Both locks are reentrant --- a thread can acquire the same lock multiple times
Class Hierarchy and Wiring
ReentrantReadWriteLock
│
├── sync: Sync ← created once in constructor
│
├── ReadLock (implements Lock)
│ └── sync ─── points to parent's sync (set in ReadLock constructor)
│
├── WriteLock (implements Lock)
│ └── sync ─── points to parent's sync (set in WriteLock constructor)
│
│ All three sync references → SAME object in memory
│
├── abstract Sync extends AbstractQueuedSynchronizer (AQS)
│ ├── tryAcquire / tryRelease (write lock --- exclusive mode)
│ ├── tryAcquireShared / tryReleaseShared (read lock --- shared mode)
│ ├── sharedCount(), exclusiveCount() (packed state helpers)
│ └── firstReader, cachedHoldCounter, readHolds (per-thread hold counts)
│
├── NonfairSync extends Sync ← default
│ ├── writerShouldBlock() → false (always barge)
│ └── readerShouldBlock() → apparentlyFirstQueuedIsExclusive()
│
└── FairSync extends Sync
├── writerShouldBlock() → hasQueuedPredecessors()
└── readerShouldBlock() → hasQueuedPredecessors()
Construction and wiring:
java
public ReentrantReadWriteLock() {
this(false); // non-fair by default
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync(); // 1. create Sync
readerLock = new ReadLock(this); // 2. wire ReadLock
writerLock = new WriteLock(this); // 3. wire WriteLock
}
// ReadLock grabs sync from the parent --- same pattern for WriteLock
public static class ReadLock implements Lock {
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync; // points to the SAME Sync object
}
}
ReadLock and WriteLock don't extend AQS --- they delegate to sync. The sync field is final in all three classes, wired once at construction. The fair/non-fair choice at construction time is the only way to control which Sync subclass is used.
Call chain: readLock.lock() → sync.acquireShared(1) → AQS.acquireShared() (inherited template) → Sync.tryAcquireShared() (subclass override). The only difference between fair and non-fair is the two shouldBlock methods --- everything else lives in the shared Sync base class.
Packed State --- Single int, Two Counters
Uses AQS with a single int state split into two 16-bit halves:
state (32 bits):
┌────────────────┬────────────────┐
│ upper 16 bits │ lower 16 bits │
│ = read count │ = write count │
└────────────────┴────────────────┘
Bit manipulation:
SHARED_SHIFT = 16
SHARED_UNIT = 1 << 16 = 65536
MAX_COUNT = (1 << 16) - 1 = 65535
EXCLUSIVE_MASK = (1 << 16) - 1
Read count: state >>> SHARED_SHIFT (unsigned right shift)
Write count: state & EXCLUSIVE_MASK (mask lower 16 bits)
Why Pack Two Counters Into One int?
AQS provides a single int state field with CAS operations. CyclicBarrier needed a lock because it has multiple fields that must change atomically (count + generation + barrier action). ReentrantReadWriteLock avoids this by encoding both counters in one int --- a single CAS can atomically update either counter without a lock.
For example, acquiring the read lock is: CAS(state, c, c + SHARED_UNIT) --- this atomically increments the upper 16 bits while leaving the lower 16 bits unchanged. The write lock is: CAS(state, c, c + 1) --- increments the lower 16 bits only.
This is possible because the two counters are independent enough: you never need to change both simultaneously in a single CAS. Read lock acquisition only touches the upper bits; write lock acquisition only touches the lower bits.
State Transitions
No locks: state = 0x0000_0000 (read=0, write=0)
1 writer: state = 0x0000_0001 (read=0, write=1)
Writer reentrant (3x): state = 0x0000_0003 (read=0, write=3)
1 reader: state = 0x0001_0000 (read=1, write=0)
3 readers: state = 0x0003_0000 (read=3, write=0)
Writer + read (downgrade): state = 0x0001_0001 (read=1, write=1)
Reentrant writer + read: state = 0x0001_0002 (read=1, write=2)
Note on downgrade states: Both counters can be > 1, but only via reentrancy by the same thread . While the write lock is held, no other thread can acquire either lock ---
tryAcquireSharedfails on theexclusiveCount(c) != 0 && owner != currentcheck, andtryAcquirefails on the ownership check. Soread=1, write=2means one thread calledwriteLock.lock()twice thenreadLock.lock()once --- not three different threads.
Reentrant downgrade timeline:
Thread-A: writeLock.lock() → state = 0x0000_0001 (read=0, write=1)
Thread-A: writeLock.lock() → state = 0x0000_0002 (read=0, write=2) ← reentrant
Thread-A: readLock.lock() → state = 0x0001_0002 (read=1, write=2) ← downgrade
Thread-A: writeLock.unlock() → state = 0x0001_0001 (read=1, write=1)
Thread-A: writeLock.unlock() → state = 0x0001_0000 (read=1, write=0) ← write fully released
↑ now other readers can enter
Thread-A: readLock.unlock() → state = 0x0000_0000
Full State Machine --- All Lock and Unlock Transitions
Every possible transition between states, showing which operation triggers it and what conditions must hold:
┌─────────────────────────────────────────────────────────┐
│ STATE MACHINE LEGEND │
│ R = read count (upper 16 bits) │
│ W = write count (lower 16 bits) │
│ RL = readLock, WL = writeLock │
│ Thread-X = any thread, Thread-O = write lock owner │
└─────────────────────────────────────────────────────────┘
═══════════════════════════════════════════════════════════════════════════════════════
FROM STATE OPERATION TO STATE WHO / CONDITION
═══════════════════════════════════════════════════════════════════════════════════════
── ACQUIRING FROM UNLOCKED ──────────────────────────────────────────────────────────
R=0, W=0 WL.lock() R=0, W=1 Any thread (CAS)
R=0, W=0 RL.lock() R=1, W=0 Any thread (CAS)
── WRITE LOCK REENTRANCY (same owner only) ──────────────────────────────────────────
R=0, W=n WL.lock() R=0, W=n+1 Owner only (no CAS)
R=0, W=n WL.unlock() R=0, W=n-1 Owner only (no CAS)
R=0, W=1 WL.unlock() R=0, W=0 Owner → clears owner
→ wakes queue head
── READ LOCK CONCURRENCY + REENTRANCY ───────────────────────────────────────────────
R=n, W=0 RL.lock() R=n+1, W=0 Any thread (CAS)
[if !readerShouldBlock]
R=n, W=0 RL.lock() R=n+1, W=0 Reentrant reader
[even if readerShouldBlock]
R=n, W=0 RL.unlock() R=n-1, W=0 Any reader (CAS loop)
R=1, W=0 RL.unlock() R=0, W=0 Last reader
→ wakes queue head
── LOCK DOWNGRADE (write owner acquires read) ──────────────────────────────────────
R=0, W=n RL.lock() R=1, W=n Owner only (CAS)
[owner == current → allowed]
R=m, W=n RL.lock() R=m+1, W=n Owner only (CAS)
[reentrant read during downgrade]
R=m, W=n WL.unlock() R=m, W=n-1 Owner only (no CAS)
R=m, W=1 WL.unlock() R=m, W=0 Owner → clears owner
→ wakes queue head
(spurious if R>0)
R=1, W=0 RL.unlock() R=0, W=0 Former owner
→ wakes queue head (real)
── BLOCKED TRANSITIONS (these FAIL, thread parks) ──────────────────────────────────
R=n, W=0 WL.lock() BLOCKED Any thread
[c!=0, w==0 → fail]
[even if current holds reads]
R=0, W=n WL.lock() BLOCKED Non-owner thread
[owner != current → fail]
R=0, W=n RL.lock() BLOCKED Non-owner thread
[exclusiveCount!=0,
owner!=current → fail]
R=n, W=0 RL.lock() BLOCKED First-time reader
[readerShouldBlock &&
holdCount==0 → must queue]
═══════════════════════════════════════════════════════════════════════════════════════
Visual state diagram:
WL.lock (any thread, CAS)
┌──────────────────────────────────────┐
│ ▼
┌───────────┐ ┌───────────┐
│ R=0 W=0 │ │ R=0 W=1 │◄──┐ WL.lock
│ (unlocked)│ │ (writer) │───┘ (reentrant,
└───────────┘ └───────────┘ W→W+1)
│ ▲ │ │
RL.lock │ │ last RL.unlock │ │
(any, CAS) │ │ (R=1→0, wakes queue) │ │
▼ │ │ │
┌───────────┐ │ │
┌───►│ R=n W=0 │ WL.lock: BLOCKED │ │
│ │ (readers) │ (c!=0, w==0 → fail) │ │
│ └───────────┘ │ │
│ RL.lock (concurrent/reentrant, CAS) │ │
└──────────────────────────────────────────────┘ │
│
RL.lock (owner only, CAS) │
┌──────────────────────────────────────┘
▼
┌───────────┐
┌───►│ R=m W=n │ Only the write lock owner thread exists here.
│ │(downgrade)│ No other thread can acquire any lock.
│ └───────────┘
│ │ │
│ │ │ WL.unlock (owner, no CAS, W→W-1)
│ │ │ when W reaches 0 → clears owner → wakes queue
│ │ │ (spurious wake if R>0 --- woken writer can't acquire)
│ │ │
│ │ └──► transitions to R=m W=0 (readers state above)
│ │
│ └─── RL.lock (owner reentrant, CAS, R→R+1)
└──────────────────────────────────────────────────
Key rules summarized:
| Operation | Succeeds when | Fails when |
|---|---|---|
WL.lock() |
state==0 (CAS) or owner reentrant (w>0, owner==current) |
Any lock held by others, OR read locks held (even by self) |
WL.unlock() |
Owner only. Wakes queue when w reaches 0 |
Non-owner → IllegalMonitorStateException |
RL.lock() |
No write lock, or write lock held by current thread (downgrade) | Write lock held by another thread. First-time reader when readerShouldBlock |
RL.unlock() |
Any reader with holdCount>0. Wakes queue when total R reaches 0 |
holdCount==0 → IllegalMonitorStateException |
Maximum Counts
Each counter is 16 bits, so the maximum is 65535 for both read holds and write holds. Exceeding this throws Error("Maximum lock count exceeded"). In practice, 65535 concurrent readers or 65535 reentrant write acquisitions is more than enough.
Design trade-off: 16 bits per counter is a deliberate choice. A 24/8 split would allow ~16M readers but only 255 reentrant writes. A 20/12 split would give ~1M readers and 4095 writes. The symmetric 16/16 split keeps both limits generous without favoring either mode. If you genuinely need more than 65535 concurrent readers, your bottleneck is elsewhere (context switching, cache coherence traffic) --- not the lock.
Read Lock Deep Dive
The read lock uses AQS shared mode. Multiple threads can hold it simultaneously.
ReadLock.lock() --- Entry Point
java
// ReadLock.lock() → sync.acquireShared(1)
// AQS.acquireShared --- template method
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0) // subclass override --- fast path attempt
doAcquireShared(arg); // failed → enqueue as SHARED node, park
}
readLock.lock() delegates to AQS.acquireShared(1), which calls tryAcquireShared (the fast path). If that returns < 0 (failed), AQS calls doAcquireShared --- which does NOT park immediately. It retries first:
doAcquireShared --- Enqueue and Spin/Park Loop
java
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED); // enqueue as SHARED node
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg); // retry BEFORE parking
if (r >= 0) {
setHeadAndPropagate(node, r); // success → become head, wake next SHARED
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) && // set predecessor waitStatus to SIGNAL
parkAndCheckInterrupt()) // LockSupport.park() ← PARK HERE
selfInterrupt();
// wake up → loop back → retry tryAcquireShared
}
}
The sequence is: try → set SIGNAL → try again → park → wake → try again. At least two tryAcquireShared attempts before parking (when the node is right behind head):
Iteration 1:
predecessor == head → tryAcquireShared() → fails
shouldParkAfterFailedAcquire() → sets predecessor waitStatus to SIGNAL
→ returns false (first time --- just set the flag, don't park yet)
Iteration 2:
predecessor == head → tryAcquireShared() → still fails
shouldParkAfterFailedAcquire() → predecessor already SIGNAL
→ returns true → parkAndCheckInterrupt() → PARK
... woken by unparkSuccessor ...
Iteration 3:
predecessor == head → tryAcquireShared() → success!
→ setHeadAndPropagate → return
The double-check avoids unnecessary parking when the lock became available between enqueue and the first retry. On success, setHeadAndPropagate wakes the next SHARED node in the queue (propagation cascade --- same mechanism as CountDownLatch).
Note on
exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current:getExclusiveOwnerThread()can never benullwhenexclusiveCount(c) != 0under correct usage --- the owner is always set before the write count becomes non-zero, and cleared after it reaches zero. The two fields are updated together during acquire (CAS statethensetExclusiveOwnerThread) and release (setExclusiveOwnerThread(null)thensetState). So thenull != currentcase is impossible in practice.
tryAcquireShared --- Fast Path
java
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// Step 1: If write lock held by ANOTHER thread → fail immediately
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
// Step 2: Fair check + count check + CAS
if (!readerShouldBlock() && // fair mode: no writer queued ahead
r < MAX_COUNT && // haven't hit 65535 readers
compareAndSetState(c, c + SHARED_UNIT)) // CAS upper 16 bits
{
// Step 3: Track per-thread hold count
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
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; // success
}
// Step 4: CAS failed or blocked → slow path
return fullTryAcquireShared(current);
}
Step-by-step:
Step 1 --- Write lock check: If the write lock is held by another thread, the read lock must fail. But if the current thread holds the write lock, the read lock succeeds --- this is the lock downgrade path. The current thread already has exclusive access, so acquiring a read lock is safe.
Step 2 --- Three conditions for fast-path success:
!readerShouldBlock()--- In fair mode, checks if a writer is queued ahead. In non-fair mode, checks if the head of the sync queue is an exclusive (writer) waiter (to prevent writer starvation).r < MAX_COUNT--- Haven't exceeded 65535 readers.CAS succeeds--- Atomically increment the upper 16 bits.
All three must pass. If any fails, fall through to the slow path.
Step 3 --- Per-thread hold count tracking: The upper 16 bits only track the total reader count. But each thread needs its own hold count for reentrancy (so unlock() knows when to actually release). This uses a three-tier optimization.
Step 4 --- Slow path: If the CAS failed (contention) or readerShouldBlock() returned true, delegate to fullTryAcquireShared() which retries in a loop.
Per-Thread Read Hold Count --- Three-Tier Optimization
java
static final class HoldCounter {
int count = 0;
final long tid = getThreadId(Thread.currentThread());
}
private transient HoldCounter cachedHoldCounter; // last thread's counter
private transient Thread firstReader; // first reader thread
private transient int firstReaderHoldCount; // first reader's count
The upper 16 bits of state track total readers, but each thread needs its own count for reentrancy. ThreadLocal lookup is expensive, so three tiers optimize the common cases:
| Tier | Field | When used | Cost |
|---|---|---|---|
| 1 | firstReader + firstReaderHoldCount |
First thread to acquire read lock (most common: single reader) | Direct field access |
| 2 | cachedHoldCounter |
Last thread that acquired read lock | Direct field access |
| 3 | readHolds (ThreadLocal) |
All other threads | ThreadLocal lookup |
In the common case of a single reader or the same thread re-acquiring, tiers 1 and 2 avoid the ThreadLocal entirely.
What is ThreadLocal? ThreadLocal gives each thread its own private copy of a variable --- no locks, no CAS, no visibility issues. Each Thread object has a ThreadLocalMap (a hash map keyed by ThreadLocal instances). threadLocal.get() looks up the value in the current thread's map. The downside is the hash lookup cost (~20-50 ns), which is why the three-tier optimization exists.
Tier 1 --- firstReader (Single Reader Fast Path)
The very first thread to acquire the read lock (when sharedCount(state) goes from 0 → 1) gets stored directly in plain fields:
java
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
}
No ThreadLocal, no hash lookup --- just a direct field read. This optimizes the single-reader case, which is extremely common: one thread holds the read lock, does its work, releases. If the same thread re-acquires later (and no one else held it in between), it hits tier 1 again.
Thread-A: readLock.lock() → sharedCount was 0 → firstReader = Thread-A, count = 1
Thread-A: readLock.lock() → firstReader == current → count = 2
Thread-A: readLock.unlock() → count = 1
Thread-A: readLock.unlock() → count = 0 → firstReader = null
Tier 2 --- cachedHoldCounter (Last Reader Cache)
Caches the HoldCounter of the last thread that acquired the read lock. When multiple readers are active, the most recent one is likely to call lock()/unlock() again soon (temporal locality):
java
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get(); // cache miss → tier 3
else if (rh.count == 0)
readHolds.set(rh); // re-install after previous cleanup
rh.count++;
}
Thread-A: readLock.lock() → firstReader = Thread-A (tier 1)
Thread-B: readLock.lock() → cachedHoldCounter = B's HoldCounter (tier 2/3 first time)
Thread-C: readLock.lock() → cachedHoldCounter = C's HoldCounter
Thread-C: readLock.lock() → cachedHoldCounter.tid == C → hit! count++ (tier 2)
Tier 3 --- ThreadLocal (Fallback)
For all other threads --- neither the first reader nor the most recent one. Full ThreadLocal.get() hash lookup:
Thread-A: firstReader (tier 1)
Thread-C: cachedHoldCounter (tier 2)
Thread-B: readHolds.get() (tier 3) --- ThreadLocal lookup
Shared Object Reference --- cachedHoldCounter and ThreadLocal
cachedHoldCounter and the ThreadLocal entry point to the same HoldCounter object in memory. After cachedHoldCounter = rh = readHolds.get():
Heap:
HoldCounter @ 0xABC { tid = B, count = 1 }
↑ ↑
│ │
cachedHoldCounter ──────────┘
│
Thread-B's ThreadLocalMap:
readHolds → 0xABC ────────┘
One object, two references. When rh.count++ is called via either path, both see the same count.
The rh.count == 0 Edge Case --- Re-Install After Cleanup
When a thread fully releases the read lock, tryReleaseShared removes its ThreadLocal entry to avoid memory leaks:
java
// In tryReleaseShared, when count reaches 0:
if (count <= 1) {
readHolds.remove(); // cleanup ThreadLocal entry
}
But cachedHoldCounter still holds a reference to the old HoldCounter object (it wasn't cleared from the cache). If the same thread acquires the read lock again later:
cachedHoldCounter.tid == current→ tier 2 hitrh.count == 0→ theThreadLocalwas removed, but the object still exists via cachereadHolds.set(rh)→ re-install the same object back into theThreadLocalrh.count++→ count goes from 0 to 1
Without readHolds.set(rh), the ThreadLocal would be empty. When the thread later calls unlock(), readHolds.get() would create a new HoldCounter with count = 0 instead of finding the one with count = 1 --- leading to an unmatched unlock exception.
Timeline:
1. Thread-B: readLock.lock() → cachedHoldCounter = rh(tid=B, count=1)
readHolds[B] = rh (same object)
2. Thread-B: readLock.unlock() → rh.count = 0 → readHolds.remove(B) ← cleanup
cachedHoldCounter still → rh ← NOT cleared
3. Thread-B: readLock.lock() → cachedHoldCounter.tid == B ✓
rh.count == 0
→ readHolds.set(rh) ← re-install into ThreadLocal
→ rh.count++ → count = 1
Performance Summary
| Tier | Lookup cost | When hit |
|---|---|---|
1 (firstReader) |
Field read --- ~1 ns | Single reader, or first reader re-acquiring |
2 (cachedHoldCounter) |
Field read + long compare --- ~2 ns | Last reader re-acquiring |
3 (ThreadLocal) |
Hash lookup --- ~20-50 ns | All other threads |
In a read-heavy workload with 1-2 dominant readers, tiers 1 and 2 handle the vast majority of calls. The ThreadLocal fallback is rarely hit.
fullTryAcquireShared --- Slow Path
When the fast path fails (CAS contention or readerShouldBlock()), this method retries in a loop:
java
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
for (;;) {
int c = getState();
// Write lock held by another thread → fail
if (exclusiveCount(c) != 0) {
if (getExclusiveOwnerThread() != current)
return -1;
// else: current thread holds write lock → allow (downgrade)
}
// Fair check: should this reader block?
else if (readerShouldBlock()) {
// Only allow if this thread already holds a read lock (reentrant)
// First-time readers must queue
if (firstReader == current) {
// already holds read lock --- reentrant, proceed
} else {
if (rh == null) {
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove(); // cleanup unused ThreadLocal
}
}
if (rh.count == 0)
return -1; // first-time reader, must queue behind writer
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// CAS retry
if (compareAndSetState(c, c + SHARED_UNIT)) {
// ... update hold counts (same as fast path) ...
return 1;
}
// CAS failed → loop and retry
}
}
Key insight in the slow path: When readerShouldBlock() returns true, the else if block acts as a filter , not a gate. It only blocks first-time readers (rh.count == 0 → return -1). Reentrant readers (rh.count > 0 or firstReader == current) pass through and reach the CAS at the bottom.
This is critical to prevent deadlock:
Thread-A: holds read lock (count=1)
Writer: queued, waiting for Thread-A to release
Thread-A: readLock.lock() again (reentrant)
→ readerShouldBlock() → true (writer is queued)
→ if we blocked Thread-A here → Thread-A parks behind the writer
→ writer waits for Thread-A to release read lock
→ Thread-A waits for writer to finish
→ DEADLOCK
So the rule is: readerShouldBlock() == true means "first-time readers must queue behind the writer." Reentrant readers are always allowed through. See [Fair vs Non-Fair](#Fair vs Non-Fair) for what readerShouldBlock() checks in each mode (non-fair: writer at head of queue; fair: anyone queued ahead).
Write Lock Deep Dive
The write lock uses AQS exclusive mode. Only one thread can hold it.
WriteLock.lock() --- Entry Point
java
// WriteLock.lock() → sync.acquire(1)
// AQS.acquire --- template method
public final void acquire(int arg) {
if (!tryAcquire(arg) && // subclass override --- fast path
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // failed → enqueue as EXCLUSIVE, park
selfInterrupt(); // restore interrupt flag if interrupted
}
writeLock.lock() delegates to AQS.acquire(1), which calls tryAcquire (the fast path). If that returns false, AQS creates an EXCLUSIVE node, appends it to the sync queue, and enters the acquireQueued spin/park loop. When woken, it retries tryAcquire --- only one exclusive thread can succeed.
tryAcquire
java
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// Step 1: state is non-zero --- someone holds a lock
// If w == 0: read locks are held (c != 0 but no write count)
// → fail --- can't acquire write lock while readers are active
// If w != 0 but different thread: another writer holds it
// → fail
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// Step 2: Reentrant write --- same thread already holds write lock
if (w + acquires > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires); // no CAS needed --- we already hold the lock
return true;
}
// Step 3: state == 0 --- no locks held at all
if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
Step-by-step:
Step 1 --- c != 0 with w == 0: This is the critical check. c != 0 means some lock is held. w == 0 means no write lock --- so it must be read locks (upper bits are non-zero). Write lock always fails if any read locks are held, even by the current thread. This is why read-to-write upgrade is impossible.
Step 1 --- c != 0 with w != 0 and different owner: Another thread holds the write lock. Fail.
Step 2 --- Reentrant write: The current thread already holds the write lock. Just increment the count. No CAS needed because we already have exclusive access --- no other thread can be modifying state concurrently.
Step 3 --- c == 0: No locks held at all. Check fair mode (writerShouldBlock()), then CAS to acquire. Set the exclusive owner thread on success.
Reentrancy --- Same Lock Type, Multiple Acquires
Both read and write locks are reentrant --- the same thread can acquire the same lock type multiple times without unlocking first:
java
// Read lock --- reentrant
rwLock.readLock().lock(); // read count: 0→1, hold count: 0→1
rwLock.readLock().lock(); // read count: 1→2, hold count: 1→2 ← no deadlock
rwLock.readLock().unlock(); // read count: 2→1, hold count: 2→1
rwLock.readLock().unlock(); // read count: 1→0, hold count: 1→0 ← fully released
// Write lock --- reentrant
rwLock.writeLock().lock(); // write count: 0→1, owner = me
rwLock.writeLock().lock(); // write count: 1→2, owner == me → just increment
rwLock.writeLock().unlock(); // write count: 2→1
rwLock.writeLock().unlock(); // write count: 1→0 → fully released
Write lock reentrancy works because tryAcquire checks ownership --- if current == getExclusiveOwnerThread(), it just increments state without CAS (no contention possible since we already hold exclusive access).
Read lock reentrancy works because tryAcquireShared increments the shared count and the per-thread hold count. The per-thread hold count tracks how many times this specific thread acquired, so each unlock() decrements by 1 and only the final unlock() actually releases.
Reentrancy vs Upgrade --- The Key Distinction
| Same lock type (reentrant) | Cross lock type | |
|---|---|---|
| Read → Read | ✅ Just increment count | --- |
| Write → Write | ✅ Owner check, increment | --- |
| Write → Read | ✅ Downgrade (safe) | --- |
| Read → Write | ❌ Deadlock | tryAcquire fails on c != 0 && w == 0 |
Reentrancy is "I already have this type of access, give me more of the same." Upgrade is "I have shared access, give me exclusive access" --- fundamentally different because it requires kicking out all other readers.
No Lock Upgrade (Read → Write) --- Why It Deadlocks
Upgrading from read to write is NOT supported. tryAcquire explicitly fails when c != 0 && w == 0 (read locks held), even if the current thread holds those read locks.
Even a single thread deadlocks itself:
java
rwLock.readLock().lock(); // state = 0x0001_0000 (read=1)
rwLock.writeLock().lock(); // tryAcquire: c != 0, w == 0 → FAIL
// → enqueue → park → DEADLOCK
// thread waits for read lock to be released,
// but it's the one holding it
With two threads it's even clearer:
Thread-0: holds read lock → tries write lock → BLOCKED (Thread-1 holds read)
Thread-1: holds read lock → tries write lock → BLOCKED (Thread-0 holds read)
→ DEADLOCK --- each waits for the other to release read lock
The safe pattern is release-then-acquire, but there's a visibility gap:
java
rwLock.readLock().unlock(); // release read first
// GAP: another writer could modify data here!
rwLock.writeLock().lock(); // now acquire write --- no deadlock
For atomic upgrade without the gap, use StampedLock.tryConvertToWriteLock().
Lock Release
ReadLock.unlock() --- Entry Point
java
// ReadLock.unlock() → sync.releaseShared(1)
// AQS.releaseShared --- template method
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) { // subclass override --- decrement state
doReleaseShared(); // state reached 0 → wake next thread in queue
return true;
}
return false; // other readers still hold --- don't wake anyone
}
readLock.unlock() calls tryReleaseShared. If that returns true (all locks released, state == 0), AQS calls doReleaseShared() to wake the next queued thread. If other readers still hold, returns false --- no one is woken.
tryReleaseShared --- Read Lock Release
java
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
// Step 1: Decrement per-thread hold count
if (firstReader == current) {
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove(); // cleanup ThreadLocal
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
// Step 2: CAS decrement the shared count (upper 16 bits)
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0; // return true if ALL locks released (wake writer)
}
}
Key points:
- Step 1 decrements the per-thread hold count (three-tier: firstReader → cached → ThreadLocal)
- Step 2 CAS-decrements the upper 16 bits in a retry loop
- Returns
trueonly whennextc == 0--- meaning ALL locks (read and write) are released. This is when a queued writer should be woken up. If other readers still hold the lock, returnfalse--- don't wake the writer yet.
WriteLock.unlock() --- Entry Point
java
// WriteLock.unlock() → sync.release(1)
// AQS.release --- template method
public final boolean release(int arg) {
if (tryRelease(arg)) { // subclass override --- decrement state
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // wake the next thread in queue
return true;
}
return false; // reentrant --- write count > 0, don't wake anyone
}
writeLock.unlock() calls tryRelease. If that returns true (write count reached 0), AQS wakes the next queued thread via unparkSuccessor. If the writer re-entered multiple times, returns false until the count reaches 0.
tryRelease --- Write Lock Release
java
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc); // no CAS --- we hold exclusive lock
return free;
}
Key points:
- No CAS needed --- the current thread holds the exclusive lock, so no contention on
state - Returns
trueonly when the write hold count reaches 0 (fully released) - Clears the exclusive owner thread when fully released
- If the writer re-entered multiple times, each
unlock()decrements by 1. Only the finalunlock()actually releases
Wake-Up After Release
When tryReleaseShared returns true (all readers released) or tryRelease returns true (writer released), AQS calls doReleaseShared() or unparkSuccessor() to wake the next thread in the sync queue:
- Writer released → readers queued: All queued readers wake up via shared-mode propagation cascade (like CountDownLatch)
- Writer released → writer queued: Next writer wakes up exclusively
- All readers released → writer queued: The queued writer wakes up
- Some readers released → writer queued: Writer stays parked ---
tryReleaseSharedreturnedfalsebecausenextc != 0(other readers still hold)
The Sync Queue --- Readers and Writers Interleaved
All readers and writers share one single sync queue . Each node is tagged with its mode (Node.SHARED or Node.EXCLUSIVE):
readLock.lock() → addWaiter(Node.SHARED) // shared node
writeLock.lock() → addWaiter(Node.EXCLUSIVE) // exclusive node
Example queue with mixed readers and writers:
Active readers: R1, R2 (acquired successfully, NOT in queue)
Sync queue:
head (dummy) → [W1 EXCLUSIVE] → [R3 SHARED] → [R4 SHARED] → [W2 EXCLUSIVE] ← tail
(parked) (parked) (parked) (parked)
The node mode determines wake-up behavior --- shared nodes trigger propagation cascade, exclusive nodes stop it:
All active readers release → wake W1 (exclusive --- one thread only)
W1 finishes → wake R3 (shared → propagate → also wake R4)
R3 and R4 run concurrently
Propagation stops at W2 (exclusive node blocks cascade)
R3 and R4 finish → wake W2
Readers behind a writer must wait for the writer. Even though readers can normally run concurrently, FIFO ordering means they queue behind the writer. This prevents writer starvation --- if readers could skip ahead of writers, writers might never get a turn.
Downgrade and the Sync Queue --- Spurious Wake-Up
When a thread downgrades (write → read) and a writer is queued, the write release triggers a spurious wake-up:
Thread-0: writeLock.lock() → state = 0x0000_0001
W2 arrives: tryAcquire fails → enqueue → park
Queue: head → [W2 EXCLUSIVE parked]
Thread-0: readLock.lock() → state = 0x0001_0001 (downgrade)
Thread-0: writeLock.unlock() → state = 0x0001_0000
→ tryRelease returns true → unparkSuccessor → wake W2
W2 wakes: tryAcquire → c = 0x0001_0000, c != 0, w == 0 → FAIL
→ Thread-0 still holds read lock!
→ park again (spurious wake-up --- woken but can't acquire)
Thread-0: readLock.unlock() → state = 0x0000_0000 → return true
→ unparkSuccessor → wake W2 again
W2 wakes: tryAcquire → state == 0 → SUCCESS
The queued writer gets woken twice --- once prematurely (can't acquire because the downgraded reader still holds), once for real. This is a minor inefficiency of the downgrade pattern. The downgraded reader effectively blocks the entire queue: the writer waits for it, and any readers behind the writer wait for the writer.
Read vs Write Path --- Key Differences
| Write Lock | Read Lock | |
|---|---|---|
| AQS mode | Exclusive (acquire/release) |
Shared (acquireShared/releaseShared) |
| Node type | Node.EXCLUSIVE |
Node.SHARED |
| State bits | Lower 16 (write count) | Upper 16 (read count) |
| CAS on acquire | CAS(state, 0, 1) |
CAS(state, c, c + SHARED_UNIT) |
| CAS on release | No CAS (already exclusive) | CAS loop (concurrent readers) |
| Owner tracking | setExclusiveOwnerThread |
Per-thread hold count (3-tier) |
| Wake-up on release | unparkSuccessor (one thread) |
doReleaseShared (may propagate) |
| Propagation | No | Yes --- setHeadAndPropagate wakes next SHARED nodes |
| Return value of try* | boolean (true/false) |
int (≥0 = success, <0 = fail) |
Lock Downgrade (Write → Read)
You CAN downgrade from write to read without releasing --- this is safe and explicitly supported:
java
rwLock.writeLock().lock();
try {
modifyData();
// Downgrade: acquire read lock while holding write lock
rwLock.readLock().lock();
} finally {
rwLock.writeLock().unlock(); // release write, keep read
}
try {
// Now holding only read lock --- other readers can enter
readData();
} finally {
rwLock.readLock().unlock();
}
Why Downgrade Is Safe
The writer already has exclusive access --- no other thread can hold any lock. Acquiring the read lock just increments the upper 16 bits via CAS. The state transitions:
Step 1: Writer holds lock
state = 0x0000_0001 (read=0, write=1)
Step 2: Writer acquires read lock (downgrade)
state = 0x0001_0001 (read=1, write=1)
→ tryAcquireShared succeeds because getExclusiveOwnerThread() == current
Step 3: Writer releases write lock
state = 0x0001_0000 (read=1, write=0)
→ Other readers can now enter
→ Writer's modifications are visible (happens-before from write unlock)
Step 4: Thread releases read lock
state = 0x0000_0000 (read=0, write=0)
Why Downgrade Matters
Without downgrade, there's a visibility gap:
java
// WITHOUT downgrade --- dangerous gap
rwLock.writeLock().lock();
modifyData();
rwLock.writeLock().unlock();
// GAP: another writer could modify data here!
rwLock.readLock().lock();
readData(); // might see someone else's modifications
rwLock.readLock().unlock();
With downgrade, the thread transitions atomically from exclusive to shared access --- no gap for another writer to sneak in.
Real-World Example --- Cache Update with Downgrade
java
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Map<String, Object> cache = new HashMap<>();
public void updateAndRead(String key, Object newValue) {
rwLock.writeLock().lock(); // state = 0x0000_0001 (write=1)
try {
cache.put(key, newValue); // exclusive write --- no other thread can read or write
rwLock.readLock().lock(); // state = 0x0001_0001 (read=1, write=1) --- downgrade
} finally {
rwLock.writeLock().unlock(); // state = 0x0001_0000 (read=1, write=0)
// NOW other readers can enter --- but no writer can sneak in
}
try {
Object val = cache.get(key); // holding read lock --- guaranteed to see our own write
process(val); // other readers can also read concurrently
} finally {
rwLock.readLock().unlock(); // state = 0x0000_0000 --- fully released
}
}
Timeline --- Downgrade With Concurrent Readers
Thread-0 (writer):
writeLock.lock() → state = 0x0000_0001
modifyData()
readLock.lock() → state = 0x0001_0001 (downgrade)
writeLock.unlock() → state = 0x0001_0000 (other readers can enter now)
Thread-1 (reader):
readLock.lock() → state = 0x0002_0000 (2 readers)
readData() → sees Thread-0's modifications (happens-before)
Thread-0:
readData()
readLock.unlock() → state = 0x0001_0000
Thread-1:
readLock.unlock() → state = 0x0000_0000
Fair vs Non-Fair
Non-Fair Mode (Default)
java
new ReentrantReadWriteLock(); // non-fair by default
writerShouldBlock() --- always returns false. Writers never voluntarily yield --- they try to CAS immediately. This gives maximum throughput but can starve writers if readers keep arriving.
readerShouldBlock() --- returns true if the first queued thread is a writer (exclusive waiter). This is the "apparent first-queued" heuristic:
java
// Non-fair Sync
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
This prevents writer starvation in non-fair mode: if a writer is waiting at the head of the queue, new readers don't barge in --- they queue behind the writer. Without this, a continuous stream of readers could starve the writer indefinitely.
But it's not perfect: If the first queued thread is a reader, new readers CAN barge in ahead of it. Only a writer at the head triggers blocking.
Reader barging timeline --- when it happens:
Thread-W: holds write lock
Queue: head → [R1 SHARED parked]
Thread-W: writeLock.unlock() → state = 0 → unparkSuccessor → wake R1
R1 is waking up but hasn't re-acquired yet (still in acquireQueued spin loop)...
Thread-R2 arrives RIGHT NOW: readLock.lock()
→ tryAcquireShared:
→ exclusiveCount == 0 (writer released)
→ readerShouldBlock? → apparentlyFirstQueuedIsExclusive()
→ head.next = R1 → R1 is SHARED, not EXCLUSIVE
→ return false → reader should NOT block
→ CAS(state, 0, SHARED_UNIT) → SUCCESS
→ R2 barges in ahead of R1!
R1 finally runs: tryAcquireShared → also succeeds (shared mode, both can read)
R2 acquired the read lock before R1, even though R1 was queued first. This is fine for correctness (both are readers, they run concurrently), but R1 waited longer and got served later --- unfair. This is a deliberate trade-off: barging improves throughput (R2 doesn't need to enqueue/park/wake), at the cost of strict FIFO ordering among readers.
If the head were a writer instead, R2 would be blocked:
Queue: head → [W2 EXCLUSIVE]
R2 arrives: readerShouldBlock? → head.next is EXCLUSIVE → YES → must queue
Fair Mode
java
new ReentrantReadWriteLock(true); // fair
Both readerShouldBlock() and writerShouldBlock() check hasQueuedPredecessors() --- if any thread is queued ahead, the current thread must queue too. Strict FIFO ordering.
java
// Fair Sync
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
Trade-off: Fair mode prevents starvation entirely but reduces throughput. In non-fair mode, a reader can acquire the lock immediately if it's available (no queue check). In fair mode, it must check the queue first --- even if the lock is free, it queues behind earlier arrivals.
Writer Starvation --- The Problem
With many readers in non-fair mode, a writer may wait indefinitely:
Time 0: Reader-1 holds read lock
Time 1: Writer arrives → waits (readers active)
Time 2: Reader-2 arrives → acquires read lock (barges in --- writer not at head yet)
Time 3: Reader-1 releases → Reader-2 still holds → Writer still waits
Time 4: Reader-3 arrives → sees writer at head → queues behind writer ✓
Time 5: Reader-2 releases → Writer finally acquires
The non-fair readerShouldBlock() heuristic (check if head is a writer) mitigates but doesn't eliminate starvation. For guaranteed fairness, use fair mode.
Memory Ordering and Happens-Before
ReentrantReadWriteLock provides the same memory visibility guarantees as synchronized. Understanding the mechanism helps reason about when data written under one lock is visible to threads acquiring another.
The Guarantee
The JMM (Java Memory Model) defines these happens-before edges for read-write locks:
writeLock.unlock() ──happens-before──► writeLock.lock() (by another thread)
writeLock.unlock() ──happens-before──► readLock.lock() (by another thread)
readLock.unlock() ──happens-before──► writeLock.lock() (by another thread)
Notice what's missing : readLock.unlock() does NOT happen-before readLock.lock() by another thread. Two concurrent readers don't synchronize with each other --- they don't need to, since neither is writing.
How It Works --- volatile State + CAS
AQS declares state as volatile. Every lock acquisition reads state (via getState() or CAS), and every release writes state (via setState() or CAS). The JMM guarantees:
- volatile write → volatile read creates a happens-before edge
- CAS has the memory effects of both a volatile read and a volatile write
So when Thread-A calls writeLock.unlock() (writes state), and Thread-B subsequently calls readLock.lock() (reads state via CAS), Thread-B is guaranteed to see all of Thread-A's writes --- not just to state, but to all variables written before the unlock. This is the "piggybacking" effect: the volatile write flushes the entire store buffer.
Thread-A: Thread-B:
data = 42;
cache.put("key", value);
writeLock.unlock()
→ setState(0) [volatile write]
readLock.lock()
→ getState() [volatile read]
→ sees state = 0
→ CAS(0, SHARED_UNIT) [volatile read+write]
// Thread-B now sees data = 42 and cache updates
Why Write Lock Release Doesn't Need CAS
In tryRelease, the write lock uses setState(nextc) --- a plain volatile write, not CAS. This is safe because the current thread holds exclusive access. No other thread can be modifying state concurrently. The volatile write still provides the memory ordering guarantee (store buffer flush + happens-before edge).
In contrast, tryReleaseShared (read lock release) uses a CAS loop because multiple readers may be releasing concurrently --- they need atomic decrement.
Practical Implication --- Lock Downgrade Visibility
Lock downgrade preserves visibility without a gap:
Thread-0:
writeLock.lock()
data = newValue; // write under exclusive lock
readLock.lock() // downgrade --- CAS on state
writeLock.unlock() // volatile write to state
// happens-before any subsequent lock acquisition
Thread-1:
readLock.lock() // volatile read of state → sees Thread-0's writes
use(data); // guaranteed to see newValue
Without downgrade, releasing the write lock and then acquiring the read lock leaves a window where another writer could intervene --- and that writer's modifications would be visible instead.
Condition Support
Only the write lock supports Condition:
java
Condition condition = rwLock.writeLock().newCondition();
rwLock.writeLock().lock();
try {
while (!conditionMet) {
condition.await(); // releases write lock, parks
}
// condition met --- proceed with exclusive access
} finally {
rwLock.writeLock().unlock();
}
The read lock does NOT support newCondition() --- calling it throws UnsupportedOperationException.
Why Read Lock Can't Have Conditions
Condition.await() must release the lock, park, then re-acquire. For the read lock, "release" means decrementing the shared count. But multiple threads hold the read lock simultaneously --- await() would only release ONE thread's hold, leaving others still holding it. When signal() tries to wake the thread, it needs to re-acquire the read lock, but the semantics of "wait until some condition is true" don't map cleanly to shared locks.
More fundamentally, conditions are about mutual exclusion: "I hold exclusive access, I'll release it and wait for a signal, then re-acquire exclusive access." The read lock is shared, not exclusive --- the condition pattern doesn't apply.
tryLock and Interruptible Acquisition
Beyond the standard lock(), both read and write locks support non-blocking and timed acquisition.
tryLock() --- Non-Blocking, Always Barges
java
// Returns immediately --- never parks
if (rwLock.readLock().tryLock()) {
try { /* read */ }
finally { rwLock.readLock().unlock(); }
} else {
// lock not available --- handle gracefully
}
tryLock() (no arguments) always barges --- it ignores fairness settings entirely. Even in fair mode, it skips the queue and attempts to CAS immediately. This is by design: tryLock() is for "try once, don't wait" semantics where FIFO ordering doesn't matter.
Internally, tryLock() on the read lock calls tryReadLock() (not tryAcquireShared), which skips the readerShouldBlock() check:
java
// Simplified --- ReadLock.tryLock()
final boolean tryReadLock() {
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return false; // another writer holds it --- fail
int r = sharedCount(c);
if (r == MAX_COUNT) throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) {
// update hold counts...
return true; // success --- no fairness check!
}
// CAS failed → retry
}
}
tryLock(timeout) --- Timed, Respects Fairness
java
if (rwLock.writeLock().tryLock(5, TimeUnit.SECONDS)) {
try { /* write */ }
finally { rwLock.writeLock().unlock(); }
} else {
// timed out --- couldn't acquire within 5 seconds
}
Unlike tryLock(), the timed variant does respect fairness. It calls tryAcquireNanos → tryAcquire (which checks writerShouldBlock()). If it can't acquire immediately, it parks with a deadline using LockSupport.parkNanos().
lockInterruptibly() --- Responds to Interrupts
java
try {
rwLock.writeLock().lockInterruptibly();
try { /* write */ }
finally { rwLock.writeLock().unlock(); }
} catch (InterruptedException e) {
// thread was interrupted while waiting for the lock
Thread.currentThread().interrupt(); // restore interrupt flag
}
Standard lock() ignores interrupts --- it re-acquires the interrupt flag after waking but doesn't throw. lockInterruptibly() throws InterruptedException immediately when the thread is interrupted while waiting, allowing the caller to cancel the operation.
Comparison
| Method | Blocks? | Respects fairness? | Responds to interrupt? |
|---|---|---|---|
lock() |
Yes (indefinitely) | Yes | No (deferred) |
tryLock() |
No (returns immediately) | No (always barges) | No |
tryLock(timeout) |
Yes (up to timeout) | Yes | Yes |
lockInterruptibly() |
Yes (indefinitely) | Yes | Yes |
Diagnostic and Monitoring APIs
ReentrantReadWriteLock exposes several methods useful for debugging, monitoring, and testing. These are not for control flow --- they return snapshots that may be stale by the time you act on them.
Hold Count Queries
java
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// How many times the CURRENT thread holds the read lock
int readHolds = rwLock.getReadHoldCount(); // per-thread, from the 3-tier system
// Total number of read lock acquisitions across ALL threads
int readCount = rwLock.getReadLockCount(); // upper 16 bits of state
// How many times the CURRENT thread holds the write lock
int writeHolds = rwLock.getWriteHoldCount(); // lower 16 bits of state
// Is the write lock held by ANY thread?
boolean writeLocked = rwLock.isWriteLocked();
// Is the write lock held by the CURRENT thread?
boolean heldByMe = rwLock.isWriteLockedByCurrentThread();
Key distinction: getReadHoldCount() returns the current thread's hold count (from the three-tier per-thread tracking). getReadLockCount() returns the total across all threads (from the upper 16 bits of state). These are different values when multiple readers hold the lock.
Queue Inspection
java
// Are any threads waiting to acquire?
boolean hasWaiters = rwLock.hasQueuedThreads();
// Is a specific thread waiting?
boolean threadQueued = rwLock.hasQueuedThread(someThread);
// Approximate number of waiting threads
int queueLength = rwLock.getQueueLength();
// Snapshot of waiting threads (for debugging only)
Collection<Thread> waiters = rwLock.getQueuedThreads(); // protected method
Warning: These methods are inherently racy. By the time you read the queue length, threads may have acquired or given up. Use them for monitoring dashboards and debug logging, never for lock acquisition decisions.
Practical Use --- Debug Logging
java
public void debugLockState(ReentrantReadWriteLock rwLock) {
log.debug("Lock state: readers={}, writers={}, writeHeldByMe={}, queueLength={}",
rwLock.getReadLockCount(),
rwLock.isWriteLocked() ? rwLock.getWriteHoldCount() : 0,
rwLock.isWriteLockedByCurrentThread(),
rwLock.getQueueLength());
}
Common Pitfalls and Anti-Patterns
Beyond the well-known "no lock upgrade" issue, several subtler mistakes trip up developers.
Pitfall 1: Forgetting to Unlock in Exception Paths
java
// WRONG --- if readData() throws, lock is never released
rwLock.readLock().lock();
Object data = readData(); // might throw!
rwLock.readLock().unlock();
// CORRECT --- always use try-finally
rwLock.readLock().lock();
try {
Object data = readData();
} finally {
rwLock.readLock().unlock();
}
This applies to all locks, but read-write locks make it worse: a leaked read lock silently blocks all future writers. The system appears to work (readers succeed) but writes hang forever. A leaked write lock is more obvious --- everything blocks.
Pitfall 2: Lock Ordering Violations Between Multiple RWLocks
java
// Thread-1: Thread-2:
rwLock1.readLock().lock(); rwLock2.writeLock().lock();
rwLock2.writeLock().lock(); // WAIT rwLock1.writeLock().lock(); // WAIT
// → DEADLOCK
When using multiple ReentrantReadWriteLock instances, establish a consistent acquisition order. This is the same principle as with ReentrantLock, but read-write locks make it easier to miss because read locks feel "safe."
Pitfall 3: Holding Locks During I/O or Long Operations
java
// BAD --- holds read lock during network call
rwLock.readLock().lock();
try {
Object data = cache.get(key);
sendToRemoteService(data); // network I/O --- could take seconds
} finally {
rwLock.readLock().unlock();
}
// BETTER --- copy data, release lock, then do I/O
rwLock.readLock().lock();
Object dataCopy;
try {
dataCopy = deepCopy(cache.get(key));
} finally {
rwLock.readLock().unlock();
}
sendToRemoteService(dataCopy); // no lock held
Long-held read locks starve writers. Copy what you need, release the lock, then do the slow work.
Pitfall 4: Using Read Lock for "Read-Then-Write" (Check-Then-Act)
java
// WRONG --- race condition between check and update
rwLock.readLock().lock();
try {
if (!cache.containsKey(key)) {
rwLock.readLock().unlock();
rwLock.writeLock().lock();
try {
cache.put(key, computeValue()); // another thread may have added it!
} finally {
rwLock.writeLock().unlock();
}
return;
}
return cache.get(key);
} finally {
rwLock.readLock().unlock(); // double-unlock if we took the write path!
}
This has two bugs: (1) the check-then-act race --- another thread could insert the key between the read unlock and write lock, and (2) the finally block double-unlocks. The correct pattern uses double-checked locking under the write lock:
java
rwLock.readLock().lock();
try {
Object val = cache.get(key);
if (val != null) return val;
} finally {
rwLock.readLock().unlock();
}
// No lock held --- gap exists, but we double-check below
rwLock.writeLock().lock();
try {
Object val = cache.get(key); // double-check under write lock
if (val != null) return val;
val = computeValue();
cache.put(key, val);
return val;
} finally {
rwLock.writeLock().unlock();
}
Pitfall 5: Assuming Read Lock Is Free
Read lock acquisition is not free --- it involves a CAS on the shared state field, which causes cache line bouncing under contention (see [Cache Line Bouncing](#Cache Line Bouncing)). If your read path is very short (a single field read), the lock overhead may exceed the protected operation. Consider volatile, AtomicReference, or StampedLock.tryOptimisticRead() for such cases.
Common Patterns
Cache with Read-Heavy Access
java
class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public V get(K key) {
lock.readLock().lock();
try { return map.get(key); }
finally { lock.readLock().unlock(); }
}
public void put(K key, V value) {
lock.writeLock().lock();
try { map.put(key, value); }
finally { lock.writeLock().unlock(); }
}
}
Config Reload with Lock Downgrade
java
class ConfigManager {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private volatile Config config;
public Config getConfig() {
lock.readLock().lock();
try {
if (!config.isStale()) return config; // fast path --- no reload needed
} finally {
lock.readLock().unlock();
}
// GAP: config could become non-stale here (another thread reloaded),
// so we double-check under write lock below.
lock.writeLock().lock();
try {
if (config.isStale()) { // double-check under write lock
config = loadConfig();
}
lock.readLock().lock(); // downgrade: acquire read before releasing write
} finally {
lock.writeLock().unlock(); // release write, keep read
}
try {
return config; // holding read lock --- guaranteed fresh
} finally {
lock.readLock().unlock();
}
}
}
Publish Pattern --- Write Then Allow Reads
java
// Thread-0: prepare data exclusively, then let everyone read
rwLock.writeLock().lock();
try {
prepareData(); // exclusive access
rwLock.readLock().lock(); // downgrade
} finally {
rwLock.writeLock().unlock();
}
// Now holding read lock --- other readers can see the prepared data
// No writer can sneak in between prepare and read
try {
useData();
} finally {
rwLock.readLock().unlock();
}
Performance Characteristics
When ReentrantReadWriteLock Wins
RRWL outperforms ReentrantLock when:
- Read-to-write ratio is high (>10:1 or higher)
- Read-side critical sections are non-trivial (enough work to amortize lock overhead)
- Writer frequency is low (writers don't constantly invalidate reader concurrency)
The benefit comes from reader parallelism: N readers run concurrently instead of serialized. If each read takes 1ms and you have 8 threads, ReentrantLock serializes to 8ms total while RRWL completes in ~1ms.
When ReentrantReadWriteLock Loses
RRWL can be slower than ReentrantLock when:
- Read-to-write ratio is low (<5:1) --- the overhead of packed state, per-thread hold counts, and ThreadLocal lookups exceeds the parallelism benefit
- Read-side critical sections are trivial (a single field read) --- lock overhead dominates the actual work
- High reader contention --- many readers CAS-ing the same
statefield causes cache line bouncing across cores
Cost Breakdown
| Operation | ReentrantLock | RRWL Read Lock | RRWL Write Lock |
|---|---|---|---|
| Uncontended acquire | ~15 ns (1 CAS) | ~25 ns (1 CAS + hold count) | ~15 ns (1 CAS) |
| Uncontended release | ~10 ns (volatile write) | ~20 ns (CAS + hold count) | ~10 ns (volatile write) |
| Contended acquire | Park + wake (~10 μs) | CAS retry loop, then park | Park + wake (~10 μs) |
| Memory per thread | None | HoldCounter in ThreadLocal (~32 bytes) | None |
The read lock is inherently more expensive per-operation than a simple exclusive lock. The win comes from concurrency, not per-operation speed.
Cache Line Bouncing --- The Hidden Cost
Every read lock acquisition CAS-es the state field. CAS is a write operation --- the hardware cache coherence protocol (MESI on x86) requires exclusive ownership of the cache line to perform it. This invalidates the line on all other cores.
With 4 readers acquiring simultaneously:
state lives at address 0x1000, in cache line #42 (64 bytes)
Core 0: CAS(state, 0, SHARED_UNIT)
→ Gets exclusive ownership of line #42
→ Invalidates line #42 on Cores 1, 2, 3
→ Succeeds → state = 0x0001_0000
Core 1: CAS(state, 0, SHARED_UNIT)
→ Cache miss! Line #42 was invalidated
→ Fetches from Core 0's cache (~30-50 ns penalty)
→ CAS fails (expected 0, found 0x0001_0000)
→ Retry: CAS(state, 0x0001_0000, 0x0002_0000)
→ Gets exclusive → invalidates Cores 0, 2, 3
→ Succeeds
Core 2: same pattern --- fetch, fail, retry, invalidate
Core 3: same pattern
The cache line ping-pongs between cores. Each successful CAS invalidates all other cores. The cost scales with core count:
| Cores | CAS behavior |
|---|---|
| 2 | Mild --- occasional retry |
| 4 | Noticeable --- 1-2 retries per CAS |
| 8 | Significant --- retry loops get longer |
| 16+ | Severe --- most CAS attempts fail, throughput degrades |
Why read locks are worse than write locks for this: Write locks are exclusive --- only one thread CAS-es at a time. Read locks are concurrent --- N threads CAS the same field simultaneously during both lock() and unlock(), producing 2N CAS operations bouncing the same cache line.
Why StampedLock.tryOptimisticRead() avoids this: It uses a volatile read (no write), which doesn't invalidate the cache line. Multiple cores can read the same line simultaneously in MESI SHARED state --- no bouncing, no retries. The trade-off is that the optimistic read can fail if a writer intervened, requiring a fallback to a full lock.
Benchmarking Guidance
Before choosing RRWL, measure with your actual workload:
java
// JMH benchmark skeleton
@State(Scope.Benchmark)
public class RWLockBenchmark {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantLock mutex = new ReentrantLock();
private Map<String, Object> data = new HashMap<>();
@Benchmark @Group("rwlock") @GroupThreads(7)
public Object rwlockRead() {
rwLock.readLock().lock();
try { return data.get("key"); }
finally { rwLock.readLock().unlock(); }
}
@Benchmark @Group("rwlock") @GroupThreads(1)
public void rwlockWrite() {
rwLock.writeLock().lock();
try { data.put("key", new Object()); }
finally { rwLock.writeLock().unlock(); }
}
@Benchmark @Group("mutex") @GroupThreads(7)
public Object mutexRead() {
mutex.lock();
try { return data.get("key"); }
finally { mutex.unlock(); }
}
@Benchmark @Group("mutex") @GroupThreads(1)
public void mutexWrite() {
mutex.lock();
try { data.put("key", new Object()); }
finally { mutex.unlock(); }
}
}
What to vary: thread count, read/write ratio, critical section duration (add Blackhole.consumeCPU() to simulate work). The crossover point where RRWL beats ReentrantLock depends on all three.
Decision Heuristic
Is the shared state a Map?
→ Yes → Use ConcurrentHashMap (faster than any lock)
→ No ↓
Read-to-write ratio > 10:1?
→ No → Use ReentrantLock (simpler, less overhead)
→ Yes ↓
Read critical section > 100 ns of work?
→ No → Consider StampedLock.tryOptimisticRead() or volatile/Atomic*
→ Yes ↓
Need reentrancy or Condition on write lock?
→ Yes → Use ReentrantReadWriteLock
→ No → Consider StampedLock (faster reads, but non-reentrant)
FAQ
Q: When should I use ReentrantReadWriteLock vs ReentrantLock?
When reads vastly outnumber writes (>10:1) and the read-side critical section does meaningful work. If reads and writes are roughly equal, the overhead of the read-write lock (packed state, per-thread hold counts, ThreadLocal) makes it slower than a simple ReentrantLock. See [Performance Characteristics](#Performance Characteristics) for the full decision heuristic and benchmarking guidance.
Q: When should I use ReentrantReadWriteLock vs ConcurrentHashMap?
If your shared data is a Map, use ConcurrentHashMap --- it's faster (lock striping, lock-free reads). ReentrantReadWriteLock is for protecting arbitrary shared state that isn't a map: complex objects, multiple fields, data structures without built-in concurrency.
Q: Can a thread hold both read and write locks?
Yes, but only write → read (downgrade). See [Reentrancy vs Upgrade](#Reentrancy vs Upgrade) and [Lock Downgrade](#Lock Downgrade).
Q: Is the read lock truly lock-free?
No. It uses CAS on the AQS state, which is lock-free in the sense of no blocking, but it's not wait-free --- CAS can fail under contention and retry. See [Cache Line Bouncing](#Cache Line Bouncing) for why this matters. For truly optimistic reads without any CAS, use StampedLock.tryOptimisticRead().
Q: What happens if I call readLock().unlock() without holding the read lock?
tryReleaseShared checks the per-thread hold count. If it's 0 or negative, it throws IllegalMonitorStateException ("attempt to unlock read lock, not locked by current thread").
Q: Why is MAX_COUNT 65535 and not higher?
Because the 32-bit state is split 16/16. See [Maximum Counts](#Maximum Counts) for the design trade-off analysis of alternative splits.
Q: Does the read lock provide happens-before guarantees?
Yes. See [Memory Ordering and Happens-Before](#Memory Ordering and Happens-Before) for the full explanation. In short: writeLock.unlock() happens-before a subsequent readLock.lock() by another thread, and readLock.unlock() happens-before a subsequent writeLock.lock(). But two concurrent readers do NOT synchronize with each other --- they don't need to.
Q: Can I use tryLock() with read-write locks?
Yes. Both readLock().tryLock() and writeLock().tryLock() are supported, with optional timeout. See [tryLock and Interruptible Acquisition](#tryLock and Interruptible Acquisition) for the full API comparison and barging semantics.
Q: Is ReentrantReadWriteLock serializable?
Yes --- it implements Serializable. But deserialization always creates an unlocked lock, regardless of the state when serialized. This is intentional: lock state is inherently tied to threads, which don't survive serialization. The same applies to ReentrantLock.
Q: Can I use ReentrantReadWriteLock across multiple JVMs?
No. Like all java.util.concurrent locks, it's a single-JVM, in-process synchronization primitive. For distributed locking, use external coordination services (ZooKeeper, Redis, DynamoDB conditional writes).
Q: What happens if a thread dies while holding a lock?
The lock is never released --- there's no automatic cleanup. Other threads waiting for the lock will block forever. This is why try-finally is critical. In server environments, consider using tryLock(timeout) to avoid indefinite blocking when a lock holder might crash.
Comparison with Other Locks
| Feature | ReentrantLock | ReentrantReadWriteLock | StampedLock |
|---|---|---|---|
| AQS-based | Yes (exclusive) | Yes (packed state) | No (custom) |
| Concurrent readers | No | Yes | Yes (optimistic + read lock) |
| Reentrant | Yes | Yes (both locks) | No |
| Condition support | Yes | Write lock only | No |
| Lock downgrade | N/A | Yes (write → read) | Yes (write → read) |
| Lock upgrade | N/A | No | Yes (tryConvertToWriteLock) |
| Fair mode | Yes | Yes | No |
| Optimistic reads | No | No | Yes (tryOptimisticRead) |
| Performance (read-heavy) | Poor (serialized) | Good | Best (optimistic) |
| Complexity | Low | Medium | High |
When to Use Which
ReentrantLock:
├── Reads and writes roughly equal
├── Need Condition support
├── Simple mutual exclusion
└── Don't want complexity of read-write semantics
ReentrantReadWriteLock:
├── Reads vastly outnumber writes
├── Need reentrant read AND write locks
├── Need Condition on write lock
├── Need lock downgrade (write → read)
└── Need fair mode option
StampedLock:
├── Extreme read-heavy workload
├── Can tolerate non-reentrant locks
├── Want optimistic reads (no CAS, no blocking)
├── Need lock upgrade (read → write)
└── Don't need Condition support
Key Takeaways
-
One int, two locks. The packed 16/16 state design lets a single CAS atomically manage both read and write counts --- no external lock needed.
-
Read lock is shared, not free. Every acquisition CAS-es the state field. Under high contention, cache line bouncing can make it slower than a simple mutex. Measure before assuming RRWL is faster.
-
Downgrade yes, upgrade no. Write → read is safe (you already have exclusive access). Read → write deadlocks because you can't atomically kick out other readers.
-
Three-tier hold count is a performance optimization, not a correctness requirement.
firstReaderandcachedHoldCounterexist solely to avoid ThreadLocal lookups in the common case. -
Non-fair mode prevents writer starvation via
readerShouldBlock(). New readers yield to a queued writer at the head of the sync queue --- but only at the head. -
Memory visibility comes from volatile state + CAS. The same mechanism as
synchronized, just implemented with CAS instead of monitor enter/exit. -
For extreme read-heavy workloads, consider StampedLock. Its optimistic read avoids CAS entirely --- just a volatile read + validation.