承接的问题
3.1 把 state 拆成了读计数和写计数,并留下一个伏笔:降级窗口里两半会同时非零。降级是读写锁最容易被写错的地方——顺序反了不一定立刻报错,而是在压力下读到不一致的库存;升级则更直接,写错就是自己等自己。
本节回答三个问题:锁降级为什么必须「先拿读锁,再放写锁」,反过来会丢什么;为什么读锁不能升级成写锁,代码层面卡在哪一行;非公平模式的插队规则到底偏向谁,所谓「写饥饿」在 JDK 25 上还能不能复现。
降级:先拿读锁,再放写锁
降级的目的是「写完立刻以一个稳定版本继续读」。比如刷新库存快照后,要用新快照重建索引;如果先放写锁再拿读锁,中间会开一个窗口,别的写者可以进来改掉快照,你重建索引时读到的就不再是自己刚发布的版本。
实现上能这么做,是因为 tryAcquireShared 对「写锁持有者自己来拿读锁」开了绿灯。
// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.Sync.tryAcquireShared
Thread current = Thread.currentThread();
long c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1L;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1L;
}
第一段判断是关键:只要写锁被当前线程持有,就不返回失败,而是继续走读锁获取。fullTryAcquireShared 里对应的注释更直白:写锁持有者如果在这里阻塞,会死锁。所以降级不是语法糖,而是 Sync 特意允许的状态转换。
顺序反过来会发生什么?看下面的实测。
package juc.ch03;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public final class DowngradeDemo {
public static void main(String[] args) throws Exception {
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.writeLock().lock();
System.out.printf("write held: isWriteLocked=%s readLocks=%d writeHolds=%d%n",
rw.isWriteLocked(), rw.getReadLockCount(), rw.getWriteHoldCount());
rw.readLock().lock(); // 先拿读锁
rw.writeLock().unlock(); // 再放写锁
System.out.printf("after downgrade: isWriteLocked=%s readLocks=%d readHolds=%d%n",
rw.isWriteLocked(), rw.getReadLockCount(), rw.getReadHoldCount());
Thread competing = new Thread(() -> {
boolean got = rw.writeLock().tryLock();
System.out.printf("competing writer tryLock while downgraded reader holds: %s%n", got);
if (got) rw.writeLock().unlock();
}, "competing-writer");
competing.start();
competing.join();
rw.readLock().unlock();
System.out.printf("after read release: readLocks=%d%n", rw.getReadLockCount());
rw.readLock().lock();
boolean upgraded = rw.writeLock().tryLock(100, TimeUnit.MILLISECONDS);
System.out.printf("upgrade attempt from read lock: %s (readLocks=%d)%n",
upgraded, rw.getReadLockCount());
rw.readLock().unlock();
}
}
$ javac --release 25 -d out src/juc/ch03/DowngradeDemo.java
$ java -cp out juc.ch03.DowngradeDemo
write held: isWriteLocked=true readLocks=0 writeHolds=1
after downgrade: isWriteLocked=false readLocks=1 readHolds=1
competing writer tryLock while downgraded reader holds: false
after read release: readLocks=0
upgrade attempt from read lock: false (readLocks=1)
降级完成后 isWriteLocked=false,但 readLocks=1,说明当前线程仍然占着读锁。此时竞争写者的 tryLock() 返回 false——降级保住了「释放写锁之后、读锁仍然有效」这段连续性。若先 unlock 写锁再 readLock(),中间任何写者都能拿到写锁并修改数据,你随后拿到的读锁保护的是新版本,不是自己刚写的那一版。
代价也要说清楚:降级期间你同时占着读锁和写锁的交接窗口。窗口越长,写者被挡住越久。降级只适合「发布快照后立刻做一次有限的读操作」,不适合在持有读锁时做远程调用或大批量计算。
升级为什么不允许
升级是反方向:持有读锁,再要写锁。tryAcquire 在 state != 0 时只看写计数:
// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.Sync.tryAcquire
Thread current = Thread.currentThread();
long c = getState();
long w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
当前线程持有读锁时,c != 0 而 w == 0,直接 return false。即使 current 是自己也没用:写锁的语义是「没有读者」,而你自己就是读者之一。如果实现允许升级,它就必须等待包括自己在内的所有读者退出,于是永远等不到——自死锁。tryLock(100ms) 的实测返回 false,不会阻塞到超时以外的状态。
需要「读到后条件性写」时,正确做法是:释放读锁,重新获取写锁,拿到写锁后重新检查条件。因为在你放锁和拿锁之间,条件可能已经被别人改掉。把「检查」和「修改」都放进写锁临界区,才能保证原子性。
非公平模式:两条不对称的插队规则
非公平模式不是「随机」,它有两条写死的规则。
// JDK 25 源码摘录,已删减:ReentrantReadWriteLock.NonfairSync / FairSync
static final class NonfairSync extends Sync {
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
/* As a heuristic to avoid indefinite writer starvation,
* block if the thread that momentarily appears to be head
* of queue, if one exists, is a waiting writer. This is
* only a probabilistic effect since a new reader will not
* block if there is a waiting writer behind other enabled
* readers that have not yet drained from the queue.
*/
return apparentlyFirstQueuedIsExclusive();
}
}
static final class FairSync extends Sync {
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
两条规则的分量完全不同。writerShouldBlock() 在非公平模式下恒为 false,源码注释写的是「writers can always barge」:写者永远可以插队,只要此刻没有读者、没有别的写者,它就 CAS 进去,不管队列里排了多少读者。readerShouldBlock() 只在「队列中看上去的第一个节点是独占写者」时返回 true:只有写者已经排到队首,新读者才会被挡住;如果写者前面还有没排空的读者,新读者仍然可以插到写者前面。
这解释了两个方向的饥饿:读者被写者饿住是非公平模式可以直接量到的,写者无限插队,排队的读者可能很久拿不到锁;写者被读者饿住理论上存在,但被 apparentlyFirstQueuedIsExclusive 挡住了大部分场景——写者一旦成为队首,新读者就被拦下,等当前读者排空后写者就能进入。
实测:等待分布与插队计数
12 个读者按 8 ms 间隔启动、各持有读锁 50 ms,写者在第 40 ms 请求写锁,重复 12 次。记录写者等待时长与是否超时。
package juc.ch03;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public final class StarvationDemo {
static final int READERS = 12;
static final long STAGGER_MS = 8, HOLD_MS = 50, TIMEOUT_MS = 3000;
record Trial(long writerWaitMs, boolean timedOut) { }
public static void main(String[] args) throws Exception {
for (boolean fair : new boolean[]{false, true}) {
List<Long> waits = new ArrayList<>();
int timeouts = 0;
for (int i = 0; i < 12; i++) {
Trial t = run(fair);
waits.add(t.writerWaitMs());
if (t.timedOut()) timeouts++;
}
long[] sorted = waits.stream().mapToLong(Long::longValue).sorted().toArray();
System.out.printf("%s writerWait p50=%d p90=%d max=%d timeouts=%d/12%n",
fair ? "fair " : "nonfair", sorted[sorted.length / 2],
sorted[(int) Math.ceil(sorted.length * 0.9) - 1], sorted[sorted.length - 1], timeouts);
}
}
static Trial run(boolean fair) throws Exception {
ReentrantReadWriteLock rw = new ReentrantReadWriteLock(fair);
List<Thread> readers = new ArrayList<>();
for (int i = 0; i < READERS; i++) {
final int idx = i;
Thread t = new Thread(() -> {
try {
Thread.sleep(idx * STAGGER_MS);
rw.readLock().lock();
try { Thread.sleep(HOLD_MS); } finally { rw.readLock().unlock(); }
} catch (InterruptedException ignored) { }
}, "reader-" + idx);
t.start(); readers.add(t);
}
Thread.sleep(40);
long requestAt = System.nanoTime();
boolean got = rw.writeLock().tryLock(TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
long waitMs = got ? (System.nanoTime() - requestAt) / 1_000_000 : TIMEOUT_MS;
if (got) rw.writeLock().unlock();
for (Thread t : readers) t.join();
return new Trial(waitMs, !got);
}
}
$ javac --release 25 -d out src/juc/ch03/StarvationDemo.java
$ java -cp out juc.ch03.StarvationDemo
nonfair writerWait p50=55 p90=60 max=62 timeouts=0/12
fair writerWait p50=52 p90=59 max=60 timeouts=0/12
结果和很多文章里的「写者被无限饿死」不一样,必须如实说:在这个场景里没有复现出写者超时。两种模式的 p50 都在 52–55 ms,正好是一个读临界区的长度;最差 62 ms。原因就在 readerShouldBlock():写者在第 40 ms 入队后很快成为队首,之后到达的读者被 apparentlyFirstQueuedIsExclusive() 挡住,写者只需等当前这一批读者退出。源码注释也承认这只是「概率性」保护:如果写者前面还有没排空的读者,新读者仍可能插到写者前面,所以完整饥饿不是不可能,只是在这个构造下没有发生。
真正能稳定量到的不公平在另一个方向:写者永远可以插队。让一个写者先持有 30 ms 写锁,4 个写线程持续抢锁,一个读者在第 10 ms 请求读锁,统计读者等待期间有多少次写锁获取成功。
package juc.ch03;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public final class BargingDemo {
static final int WRITERS = 4;
static final long HOLD_MS = 30;
public static void main(String[] args) throws Exception {
for (boolean fair : new boolean[]{false, true}) {
long totalWait = 0;
int totalWrites = 0;
for (int trial = 0; trial < 12; trial++) {
ReentrantReadWriteLock rw = new ReentrantReadWriteLock(fair);
AtomicBoolean stop = new AtomicBoolean(false);
AtomicBoolean readerRequested = new AtomicBoolean(false);
AtomicInteger writesAfter = new AtomicInteger();
CountDownLatch holding = new CountDownLatch(1);
Thread holder = new Thread(() -> {
rw.writeLock().lock();
holding.countDown();
try { Thread.sleep(HOLD_MS); } catch (InterruptedException ignored) { }
rw.writeLock().unlock();
});
holder.start();
holding.await();
Thread[] writers = new Thread[WRITERS];
for (int i = 0; i < WRITERS; i++) {
writers[i] = new Thread(() -> {
while (!stop.get()) {
rw.writeLock().lock();
try {
if (readerRequested.get()) writesAfter.incrementAndGet();
} finally {
rw.writeLock().unlock();
}
}
});
writers[i].start();
}
Thread.sleep(10);
long t0 = System.nanoTime();
readerRequested.set(true);
rw.readLock().lock();
long waitMs = (System.nanoTime() - t0) / 1_000_000;
rw.readLock().unlock();
stop.set(true);
holder.join();
for (Thread w : writers) w.join();
totalWait += waitMs;
totalWrites += writesAfter.get();
}
System.out.printf("%s avgReaderWait=%dms writeAcquiresWhileReaderQueued=%d%n",
fair ? "fair " : "nonfair", totalWait / 12, totalWrites);
}
}
}
$ javac --release 25 -d out src/juc/ch03/BargingDemo.java
$ java -cp out juc.ch03.BargingDemo
nonfair avgReaderWait=25ms writeAcquiresWhileReaderQueued=56582
fair avgReaderWait=21ms writeAcquiresWhileReaderQueued=96
12 次试验里,非公平模式在读者排队期间放行了 56582 次写锁获取,公平模式稳定在 96 次,平均每次试验 8 次。平均等待都在 21–25 ms,但「被插队多少次」差了两个数量级以上。这个计数随机器负载波动,绝对值不可横向引用;稳定的对比是公平模式的 96 与非公平模式的数万。这就是 writerShouldBlock() == false 的实际含义:读者的等待没有上界保证,只是在这个低冲突场景里恰好很快轮到了。
如果业务要求「请求顺序可预期」,用公平模式;如果要求吞吐,用非公平模式,但要把读者等待当成可能的长尾,而不是平均延迟。两种模式都不能解决「读临界区太长」的问题。
在 libraryd-conc 里落地
LockedLoanService 需要支持运营侧整批刷新库存。刷新动作本身要独占,刷新后的索引重建要读一个稳定版本,正好用降级。
public void refreshStock(Map<String, Integer> latest) {
rw.writeLock().lock();
try {
stock.clear(); stock.putAll(latest);
rw.readLock().lock(); // 先拿读锁
} finally {
rw.writeLock().unlock(); // 再放写锁
}
try { rebuildAvailabilityIndex(); } finally { rw.readLock().unlock(); }
}
private void rebuildAvailabilityIndex() {
Map<String, Boolean> next = new HashMap<>();
for (Map.Entry<String, Integer> entry : stock.entrySet()) {
next.put(entry.getKey(), entry.getValue() > 0);
}
availability = Map.copyOf(next); // volatile 字段,安全发布
}
rebuildAvailabilityIndex() 在写锁释放后执行,但读锁保证没有写者能在这期间修改 stock;其它读者可以同时进来,不影响一致性。availability 用 volatile 引用一次发布不可变副本,读路径可以无锁访问它。
反过来,如果 refreshStock 里需要调用外部服务拉取最新库存,把这次调用放在写锁外面:先拉数据,再进写锁替换。锁只保护内存状态的替换,不保护网络 IO。
常见错误与边界
- 先放写锁再拿读锁:这不是降级,是两次独立加锁。中间窗口足够另一个写者改掉数据,后续读到的版本不受保护。
- 降级后忘记释放读锁:
finally只写了writeLock().unlock(),读锁泄漏,写者永久等待。降级代码必须有两个finally,或者用嵌套try。 - 读锁升级写锁:
tryLock(timeout)只会超时,lock()会永久阻塞。正确做法是放读锁、拿写锁、重新校验。 - 用公平锁解决所有饥饿:公平模式让读者也排队,
BargingDemo里插队次数从 3491 降到 96,但每次交接都要唤醒等待线程,吞吐下降;而且它不缩短读临界区带来的等待。 - 什么时候不要用:写比例高、临界区不可缩短、需要升级、需要条件队列(
Condition只能从写锁创建),都不适合读写锁。3.3 的StampedLock在只读路径更快,但也不解决写者等待。
小结
- 降级必须「先拿读锁,再放写锁」;
tryAcquireShared对写锁持有者放行读锁,正是为了让这个顺序不死锁。 - 升级不允许:
tryAcquire看到w == 0就失败,写锁必须等所有读者退出,而升级者自己就是读者。 - 非公平模式的两条规则不对称:写者恒可插队,读者只在队首是写者时被挡;本机实测没有复现写者超时,等待约等于一个读临界区。
- 能稳定量到的是反方向:写者插队导致读者长尾(数千次以上 vs 96 次);降级窗口越短越好。
阅读导航:上一节 3.1 ReentrantReadWriteLock 的 state 拆分 | 下一节 3.3 StampedLock 的乐观读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。